refactor: separate monitoring from hardware plugins
This commit is contained in:
Binary file not shown.
+75
-520
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,json,os,platform
|
||||
import time,socket,subprocess,configparser,signal,re,argparse,json,os,platform
|
||||
from pathlib import Path
|
||||
APP_VERSION="3.7.0"
|
||||
from smbus2 import SMBus
|
||||
from PIL import Image
|
||||
import sys
|
||||
sys.path.append("/usr/local/share/pigway-pi-control")
|
||||
from shutil import disk_usage
|
||||
from plugin_api import Publisher,registered,discover
|
||||
|
||||
OLED=0x3C; MCU=0x0D; W,H=128,32
|
||||
CFG="/etc/pigway-pi-control.conf"
|
||||
STATUS_PATH="/run/pigway-pi-control/status.json"
|
||||
running=True
|
||||
@@ -84,23 +84,6 @@ def throttled_state():
|
||||
if not m: return {"raw":"UNKNOWN","error":"vcgencmd get_throttled unavailable or invalid"}
|
||||
return {"raw":f"0x{v:x}","under_voltage_now":bool(v&1),"freq_capped_now":bool(v&2),"throttled_now":bool(v&4),"soft_temp_limit_now":bool(v&8),"under_voltage_occurred":bool(v&(1<<16)),"freq_capped_occurred":bool(v&(1<<17)),"throttled_occurred":bool(v&(1<<18)),"soft_temp_limit_occurred":bool(v&(1<<19))}
|
||||
|
||||
bus=None
|
||||
bus_lock=threading.RLock()
|
||||
FONT_PATH="/usr/local/share/pigway-pi-control/oled_font_5x7.bin"
|
||||
FONT5=b""
|
||||
agent={"loop_errors":0,"i2c_errors":0}
|
||||
|
||||
def bus_write(method,*args):
|
||||
with bus_lock:
|
||||
try: return getattr(bus,method)(*args)
|
||||
except OSError:
|
||||
agent["i2c_errors"]+=1
|
||||
raise
|
||||
|
||||
fan_level=0
|
||||
fan_initialized=False
|
||||
pending_fan=None
|
||||
pending_fan_lock=threading.Lock()
|
||||
prev_cpu=None
|
||||
page=0; page_since=time.monotonic()
|
||||
cpu_hi=mem_hi=0
|
||||
@@ -110,56 +93,6 @@ wifi_weak_since=None; wifi_weak_active=False
|
||||
last_net=last_watch=0
|
||||
watch_state=[]
|
||||
|
||||
def cmd(c): bus_write("write_byte_data",OLED,0x00,c)
|
||||
def oled_init():
|
||||
# Seller-native physical 128x32 configuration.
|
||||
for c in [0xAE,0xD5,0x80,0xA8,0x1F,0xD3,0x00,0x40,0x8D,0x14,
|
||||
0x20,0x00,0xA1,0xC8,0xDA,0x02,0x81,0x7F,0xD9,0xF1,
|
||||
0xDB,0x40,0xA4,0xA6,0xAF]:
|
||||
cmd(c)
|
||||
|
||||
def draw_text_5x7(pix,x,y,text):
|
||||
for ch in text:
|
||||
code=ord(ch)
|
||||
if code<0 or code>255: code=ord("?")
|
||||
off=code*5
|
||||
if off+5>len(FONT5): code=ord("?"); off=code*5
|
||||
for cx in range(5):
|
||||
col=FONT5[off+cx]
|
||||
for cy in range(7):
|
||||
if col & (1<<cy):
|
||||
xx=x+cx; yy=y+cy
|
||||
if 0<=xx<W and 0<=yy<H: pix[xx,yy]=1
|
||||
x+=6
|
||||
if x>W-6: break
|
||||
|
||||
def oled_show(lines_lr, title_zh=False):
|
||||
im=Image.new("1",(W,H),0); pix=im.load()
|
||||
for row,(left,right) in enumerate(lines_lr[:4]):
|
||||
y=row*8
|
||||
draw_text_5x7(pix,0,y,left)
|
||||
if right:
|
||||
draw_text_5x7(pix,max(0,W-len(right)*6),y,right)
|
||||
data=[]
|
||||
for pg in range(4):
|
||||
for x in range(W):
|
||||
v=0
|
||||
for bit in range(8):
|
||||
if pix[x,pg*8+bit]: v|=1<<bit
|
||||
data.append(v)
|
||||
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
|
||||
for i in range(0,len(data),16):
|
||||
bus_write("write_i2c_block_data",OLED,0x40,data[i:i+16])
|
||||
|
||||
|
||||
def mcu(reg,val): bus_write("write_byte_data",MCU,reg,val)
|
||||
_last_rgb=None
|
||||
def rgb_off():
|
||||
global _last_rgb
|
||||
if _last_rgb != ("off",):
|
||||
mcu(0x07,0x00) # seller closeRGB(): true OFF
|
||||
_last_rgb=("off",)
|
||||
|
||||
def set_pi_led(name,off=True):
|
||||
base=Path("/sys/class/leds")/name
|
||||
try:
|
||||
@@ -173,47 +106,6 @@ def apply_dark_mode():
|
||||
if DARK_PWR: set_pi_led("PWR",True)
|
||||
if DARK_ACT: set_pi_led("ACT",True)
|
||||
|
||||
FAN_REG=[0x00,0x04,0x06,0x08,0x09,0x01]
|
||||
FAN_NAME=["OFF","L1","L2","L3","L4","MAX"]
|
||||
def fan_target(t):
|
||||
if t<FAN_T[0]: return 0
|
||||
if t<FAN_T[1]: return 1
|
||||
if t<FAN_T[2]: return 2
|
||||
if t<FAN_T[3]: return 3
|
||||
if t<FAN_T[4]: return 4
|
||||
return 5
|
||||
def update_fan(t):
|
||||
global fan_level,fan_initialized,pending_fan
|
||||
want=fan_target(t); old=fan_level if fan_initialized else None
|
||||
if want>fan_level: fan_level=want
|
||||
elif want<fan_level:
|
||||
boundary = FAN_T[max(0,fan_level-1)] - FAN_HYST
|
||||
if t < boundary: fan_level-=1
|
||||
if not fan_initialized or old!=fan_level:
|
||||
with pending_fan_lock:
|
||||
pending_fan=(old,fan_level,float(t))
|
||||
fan_initialized=True
|
||||
|
||||
def apply_pending_fan(keep_rgb_off=False):
|
||||
global pending_fan
|
||||
with pending_fan_lock:
|
||||
command=pending_fan
|
||||
pending_fan=None
|
||||
if command is None: return
|
||||
old,new,temp=command
|
||||
try:
|
||||
mcu(0x08,FAN_REG[new])
|
||||
# Some revisions of the shared MCU briefly expose their previous RGB
|
||||
# state after a fan-register write. When the flash engine is already
|
||||
# in its dark phase, immediately reassert OFF without changing phase.
|
||||
if keep_rgb_off: mcu(0x07,0x00)
|
||||
except Exception:
|
||||
with pending_fan_lock:
|
||||
if pending_fan is None: pending_fan=command
|
||||
raise
|
||||
log("INFO","FAN_LEVEL",old=FAN_NAME[old] if old is not None else "UNKNOWN",
|
||||
new=FAN_NAME[new],temp_c=f"{temp:.1f}",register=f"0x{FAN_REG[new]:02x}")
|
||||
|
||||
def cpu_pct():
|
||||
global prev_cpu
|
||||
p=list(map(int,Path("/proc/stat").read_text().splitlines()[0].split()[1:9]))
|
||||
@@ -290,10 +182,7 @@ if not DEVICE_ID or DEVICE_ID.lower()=="auto": DEVICE_ID=socket.gethostname()
|
||||
DEVICE_NAME=cf("device","name",DEVICE_ID,str).strip() or DEVICE_ID
|
||||
if DEVICE_NAME.lower()=="auto": DEVICE_NAME=DEVICE_ID
|
||||
DEVICE_INFO=device_details()
|
||||
|
||||
SENSOR_INTERVAL=cf("timing","sensor_interval",1.0)
|
||||
OLED_REFRESH=cf("timing","oled_refresh_interval",1.0)
|
||||
PAGE_INTERVAL=cf("timing","page_interval",10.0)
|
||||
NETWORK_INTERVAL=cf("timing","network_interval",5.0)
|
||||
SERVICE_INTERVAL=cf("timing","service_interval",5.0)
|
||||
HEALTH_INTERVAL=cf("timing","health_interval",1.0)
|
||||
@@ -312,44 +201,9 @@ WIFI_WEAK=cf("alerts","wifi_weak",-75.0)
|
||||
WIFI_RECOVER=cf("alerts","wifi_recover",-68.0)
|
||||
WIFI_WEAK_SECONDS=cf("alerts","wifi_weak_seconds",30.0)
|
||||
|
||||
FAN_T=[cf("fan","start",50.0),cf("fan","level2",53.0),cf("fan","level3",56.0),
|
||||
cf("fan","level4",59.0),cf("fan","max",62.0)]
|
||||
FAN_HYST=cf("fan","hysteresis",1.5)
|
||||
ROW_Y=[cf("display","row1_y",0,int),cf("display","row2_y",8,int),cf("display","row3_y",16,int),cf("display","row4_y",24,int)]
|
||||
DARK_PWR=cf("dark_mode","pi_pwr_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||
DARK_ACT=cf("dark_mode","pi_act_led",True,lambda x: str(x).strip().lower() in ("off","0","false","no"))
|
||||
RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int))
|
||||
WARNING_FLASH=(max(50,cf("led","warning_flash_on_ms",1500,int)),max(50,cf("led","warning_flash_off_ms",1500,int)))
|
||||
CRITICAL_FLASH=(max(50,cf("led","critical_flash_on_ms",750,int)),max(50,cf("led","critical_flash_off_ms",750,int)))
|
||||
EMERGENCY_FLASH=(max(50,cf("led","emergency_flash_on_ms",300,int)),max(50,cf("led","emergency_flash_off_ms",300,int)))
|
||||
NORMAL_MODE=cf("led","normal_mode","off",str).strip().lower()
|
||||
NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorful"}
|
||||
if NORMAL_MODE not in NORMAL_MODES: NORMAL_MODE="off"
|
||||
NORMAL_COLOR=tuple(max(0,min(255,cf("led",f"normal_{channel}",value,int)))
|
||||
for channel,value in zip(("r","g","b"),(0,0,0)))
|
||||
NORMAL_FLASH=(max(50,cf("led","normal_flash_on_ms",1000,int)),
|
||||
max(50,cf("led","normal_flash_off_ms",1000,int)))
|
||||
NORMAL_EFFECT_SPEED=max(1,min(3,cf("led","normal_effect_speed",2,int)))
|
||||
NORMAL_EFFECT_COLOR=cf("led","normal_effect_color","green",str).strip().lower()
|
||||
RGB_EFFECT_COLORS={"red":0,"green":1,"blue":2,"yellow":3,"purple":4,"cyan":5,"white":6}
|
||||
if NORMAL_EFFECT_COLOR not in RGB_EFFECT_COLORS: NORMAL_EFFECT_COLOR="green"
|
||||
RGB_EFFECTS={"flow":0,"breathe":1,"marquee":2,"rainbow":3,"colorful":4}
|
||||
NORMAL_RGB_MODE={"off":"OFF","solid":"NORMAL_SOLID","flash":"NORMAL_FLASH",
|
||||
**{name:f"NORMAL_{name.upper()}" for name in RGB_EFFECTS}}[NORMAL_MODE]
|
||||
|
||||
def rgb_cfg(name,default):
|
||||
return tuple(max(0,min(255,cf("led",f"{name}_{channel}",value,int)))
|
||||
for channel,value in zip(("r","g","b"),default))
|
||||
|
||||
RGB_GROUP_VALUES={
|
||||
"CPU":rgb_cfg("cpu",(255,0,0)),
|
||||
"POWER":rgb_cfg("power",(255,40,0)),
|
||||
"MEMORY":rgb_cfg("memory",(255,0,255)),
|
||||
"STORAGE":rgb_cfg("storage",(255,255,255)),
|
||||
"NETWORK":rgb_cfg("network",(0,0,255)),
|
||||
"SERVICE":rgb_cfg("service",(0,255,96)),
|
||||
}
|
||||
RGB_OBJECT_GROUPS={
|
||||
ALERT_CATEGORIES={
|
||||
"ROOT_READONLY":"STORAGE",
|
||||
"TEMP_CRITICAL":"CPU","TEMP_HIGH":"CPU","SOFT_TEMP_LIMIT":"CPU",
|
||||
"THROTTLED":"CPU","FREQ_CAPPED":"CPU","CPU_HIGH":"CPU",
|
||||
@@ -374,7 +228,7 @@ def diagnose():
|
||||
print("Agent:")
|
||||
print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}")
|
||||
print(f"machine_id={DEVICE_ID} machine_name={DEVICE_NAME} hostname={socket.gethostname()}")
|
||||
print("loop_errors=UNAVAILABLE i2c_errors=UNAVAILABLE scope=separate_diagnostic_process")
|
||||
print("loop_errors=UNAVAILABLE scope=separate_diagnostic_process")
|
||||
print("System:")
|
||||
print(f"load_average={load_average()} cpu_freq_mhz={cpu_freq_mhz():.0f} root_readonly={root_readonly()}")
|
||||
print("Power:")
|
||||
@@ -387,27 +241,16 @@ def diagnose():
|
||||
iface,kind,metric,label,address=primary_network()
|
||||
print("Network:")
|
||||
print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} signal_percent={wifi_percent(metric) if kind=='WIF' else 'N/A'} ip_label={label} IP={address} internet={internet()}")
|
||||
print("I2C:")
|
||||
# SMBus receive-byte performs a read only; no register selection or display commands.
|
||||
try:
|
||||
with SMBus(1) as probe:
|
||||
for address,name in ((MCU,"MCU"),(OLED,"OLED")):
|
||||
try:
|
||||
probe.read_byte(address)
|
||||
print(f"0x{address:02X} {name}=PRESENT")
|
||||
except OSError as e:
|
||||
status="MISSING" if e.errno in (errno.ENXIO,getattr(errno,"EREMOTEIO",121)) else "ERROR"
|
||||
print(f"0x{address:02X} {name}={status} error={e}")
|
||||
except OSError as e:
|
||||
for address,name in ((MCU,"MCU"),(OLED,"OLED")):
|
||||
print(f"0x{address:02X} {name}=ERROR error={e}")
|
||||
print("Plugins (read-only API discovery; no hardware access):")
|
||||
for plugin in discover(cfg):
|
||||
print(f"plugin={plugin['id']} linked={plugin['linked']} online={plugin['online']}")
|
||||
print("Watches:")
|
||||
for w in watches:
|
||||
print(f"watch {w[1]}={'UP' if check_watch(w) else 'DOWN'} type={w[0]} target={w[2]}")
|
||||
return 0
|
||||
|
||||
|
||||
DISPLAY_PRIORITIES={
|
||||
ALERT_PRIORITIES={
|
||||
"ROOT_READONLY":100,
|
||||
"TEMP_CRITICAL":90,
|
||||
"SOFT_TEMP_LIMIT":85,
|
||||
@@ -427,8 +270,8 @@ DISPLAY_PRIORITIES={
|
||||
"NORMAL_HOME":0,
|
||||
}
|
||||
|
||||
def display_priority(request_id):
|
||||
return DISPLAY_PRIORITIES[request_id.split("|",1)[0]]
|
||||
def alert_priority(request_id):
|
||||
return ALERT_PRIORITIES[request_id.split("|",1)[0]]
|
||||
|
||||
def transition(key,active,on_event,off_event,**fields):
|
||||
old=event_state.get(key,False)
|
||||
@@ -441,39 +284,39 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
|
||||
global cpu_hi,mem_hi
|
||||
cpu_hi=cpu_hi+sample_elapsed if cpu>=CPU_HIGH else 0 if cpu<CPU_RECOVER else cpu_hi
|
||||
mem_hi=mem_hi+sample_elapsed if mem>=MEM_HIGH else 0 if mem<MEM_RECOVER else mem_hi
|
||||
if cpu_hi>=CPU_HIGH_SEC: display.request("CPU_HIGH",1,"CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","CHECK LOAD")
|
||||
else: display.clear("CPU_HIGH")
|
||||
if cpu_hi>=CPU_HIGH_SEC: alert_manager.request("CPU_HIGH",1,"CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","CHECK LOAD")
|
||||
else: alert_manager.clear("CPU_HIGH")
|
||||
if temp>=TEMP_CRIT:
|
||||
display.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING")
|
||||
display.clear("TEMP_HIGH")
|
||||
alert_manager.request("TEMP_CRITICAL",3,"TEMP CRITICAL",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
|
||||
alert_manager.clear("TEMP_HIGH")
|
||||
elif temp>=TEMP_HIGH:
|
||||
display.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING")
|
||||
display.clear("TEMP_CRITICAL")
|
||||
alert_manager.request("TEMP_HIGH",1,"TEMP HIGH",f"TEMP {temp:.1f}C","CHECK COOLING","CHECK COOLING")
|
||||
alert_manager.clear("TEMP_CRITICAL")
|
||||
else:
|
||||
display.clear("TEMP_HIGH"); display.clear("TEMP_CRITICAL")
|
||||
if mem_hi>=MEM_HIGH_SEC: display.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
|
||||
else: display.clear("MEMORY_HIGH")
|
||||
alert_manager.clear("TEMP_HIGH"); alert_manager.clear("TEMP_CRITICAL")
|
||||
if mem_hi>=MEM_HIGH_SEC: alert_manager.request("MEMORY_HIGH",1,"MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","")
|
||||
else: alert_manager.clear("MEMORY_HIGH")
|
||||
if disk>=DISK_CRIT:
|
||||
display.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
|
||||
display.clear("DISK_HIGH")
|
||||
alert_manager.request("DISK_CRITICAL",2,"DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE")
|
||||
alert_manager.clear("DISK_HIGH")
|
||||
elif disk>=DISK_HIGH:
|
||||
display.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE")
|
||||
display.clear("DISK_CRITICAL")
|
||||
alert_manager.request("DISK_HIGH",1,"DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE")
|
||||
alert_manager.clear("DISK_CRITICAL")
|
||||
else:
|
||||
display.clear("DISK_HIGH"); display.clear("DISK_CRITICAL")
|
||||
alert_manager.clear("DISK_HIGH"); alert_manager.clear("DISK_CRITICAL")
|
||||
if not link_ok:
|
||||
display.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","")
|
||||
display.clear("INTERNET_DOWN")
|
||||
alert_manager.request("NETWORK_DOWN",2,"NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","")
|
||||
alert_manager.clear("INTERNET_DOWN")
|
||||
elif not net_ok:
|
||||
display.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"")
|
||||
display.clear("NETWORK_DOWN")
|
||||
alert_manager.request("INTERNET_DOWN",2,"INTERNET DOWN","LOCAL LINK OK",ip,"")
|
||||
alert_manager.clear("NETWORK_DOWN")
|
||||
else:
|
||||
display.clear("NETWORK_DOWN"); display.clear("INTERNET_DOWN")
|
||||
alert_manager.clear("NETWORK_DOWN"); alert_manager.clear("INTERNET_DOWN")
|
||||
for w,ok in zip(watches,watch_state):
|
||||
alert_id=("SERVICE_DOWN" if w[0]=="service" else "PROCESS_DOWN")+"|"+w[1]
|
||||
if not ok:
|
||||
display.request(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]})
|
||||
else: display.clear(alert_id)
|
||||
alert_manager.request(alert_id,2,"SERVICE DOWN" if w[0]=="service" else "PROCESS DOWN",w[1],"CHECK / RESTART","CRITICAL",{"target":w[2]})
|
||||
else: alert_manager.clear(alert_id)
|
||||
if net_kind=="WIF" and str(net_metric).lstrip("-").isdigit():
|
||||
rssi=float(net_metric)
|
||||
if not wifi_weak_active:
|
||||
@@ -484,339 +327,56 @@ def update_active_alerts(cpu,temp,mem,disk,sample_elapsed=0.0):
|
||||
elif rssi >= WIFI_RECOVER:
|
||||
wifi_weak_active=False; wifi_weak_since=None
|
||||
if wifi_weak_active:
|
||||
display.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL")
|
||||
else: display.clear("WIFI_WEAK")
|
||||
alert_manager.request("WIFI_WEAK",1,"WIFI SIGNAL WEAK",f"RSSI {rssi:.0f} dBm",f"IF {net_iface}","CHECK SIGNAL")
|
||||
else: alert_manager.clear("WIFI_WEAK")
|
||||
else:
|
||||
wifi_weak_since=None; wifi_weak_active=False; display.clear("WIFI_WEAK")
|
||||
wifi_weak_since=None; wifi_weak_active=False; alert_manager.clear("WIFI_WEAK")
|
||||
if health.get("under_voltage_now"):
|
||||
display.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
|
||||
else: display.clear("POWER_UNDERVOLT")
|
||||
alert_manager.request("POWER_UNDERVOLT",2,"POWER LOW","UNDERVOLTAGE","CHECK POWER","CRITICAL",{"raw":health.get("raw","")})
|
||||
else: alert_manager.clear("POWER_UNDERVOLT")
|
||||
if health.get("throttled_now"):
|
||||
display.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
||||
else: display.clear("THROTTLED")
|
||||
alert_manager.request("THROTTLED",2,"CPU THROTTLED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
||||
else: alert_manager.clear("THROTTLED")
|
||||
if health.get("freq_capped_now"):
|
||||
display.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
||||
else: display.clear("FREQ_CAPPED")
|
||||
alert_manager.request("FREQ_CAPPED",2,"FREQ CAPPED",f"FREQ {cpu_freq_mhz():.0f}M",health.get("raw",""),"CHECK POWER")
|
||||
else: alert_manager.clear("FREQ_CAPPED")
|
||||
if health.get("soft_temp_limit_now"):
|
||||
display.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING")
|
||||
else: display.clear("SOFT_TEMP_LIMIT")
|
||||
if fs_readonly: display.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY")
|
||||
else: display.clear("ROOT_READONLY")
|
||||
alert_manager.request("SOFT_TEMP_LIMIT",2,"SOFT TEMP LIMIT","CPU TEMP LIMITED",health.get("raw",""),"CHECK COOLING")
|
||||
else: alert_manager.clear("SOFT_TEMP_LIMIT")
|
||||
if fs_readonly: alert_manager.request("ROOT_READONLY",3,"FILESYSTEM RO","ROOT READ ONLY","CHECK STORAGE","EMERGENCY")
|
||||
else: alert_manager.clear("ROOT_READONLY")
|
||||
|
||||
def rgb_mode_for(request_id,severity):
|
||||
if severity<=0: return "OFF"
|
||||
alert_type=request_id.split("|",1)[0]
|
||||
color=RGB_OBJECT_GROUPS[alert_type]
|
||||
level="EMERGENCY" if severity>=3 else "CRITICAL" if severity>=2 else "WARNING"
|
||||
return f"{color}_{level}_FLASH"
|
||||
|
||||
def rgb_flash(mode,on):
|
||||
global _last_rgb
|
||||
color=mode.split("_",1)[0]
|
||||
channels=RGB_GROUP_VALUES[color]
|
||||
state=(mode,bool(on))
|
||||
if _last_rgb==state: return
|
||||
if not on:
|
||||
mcu(0x07,0x00)
|
||||
else:
|
||||
# Keep the seller's selector -> R -> G -> B protocol contiguous. A
|
||||
# visible delay between these writes lets the MCU render stale partial
|
||||
# channel data, which appears as a brief unrelated color or chase.
|
||||
mcu(0x00,0xff)
|
||||
mcu(0x01,channels[0])
|
||||
mcu(0x02,channels[1])
|
||||
mcu(0x03,channels[2])
|
||||
_last_rgb=state
|
||||
|
||||
def rgb_static(channels,state_name):
|
||||
global _last_rgb
|
||||
state=(state_name,channels)
|
||||
if _last_rgb==state: return
|
||||
mcu(0x07,0x00)
|
||||
mcu(0x00,0xff)
|
||||
mcu(0x01,channels[0])
|
||||
mcu(0x02,channels[1])
|
||||
mcu(0x03,channels[2])
|
||||
_last_rgb=state
|
||||
|
||||
def rgb_normal_effect(mode):
|
||||
global _last_rgb
|
||||
effect_name=mode.removeprefix("NORMAL_").lower()
|
||||
state=("effect",effect_name,NORMAL_EFFECT_SPEED,NORMAL_EFFECT_COLOR)
|
||||
if _last_rgb==state: return
|
||||
# Hardware-calibrated order: speed and color are parameters; the final
|
||||
# effect register write commits and starts the selected MCU animation.
|
||||
mcu(0x05,NORMAL_EFFECT_SPEED); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
||||
mcu(0x06,RGB_EFFECT_COLORS[NORMAL_EFFECT_COLOR]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
||||
mcu(0x04,RGB_EFFECTS[effect_name])
|
||||
_last_rgb=state
|
||||
|
||||
def render_home(cpu,temp,mem,disk):
|
||||
# 128x32 fixed columns. Both columns are LEFT-aligned.
|
||||
# Seller 5x7 font has 6px character pitch; right column starts at x=72.
|
||||
im=Image.new("1",(W,H),0); pix=im.load()
|
||||
display_ip=ip[:7]+".."+ip[-7:] if ip_label=="IP6" and len(ip)>18 else ip
|
||||
left=[
|
||||
f"CPU {cpu:.1f}%",
|
||||
f"TMP {temp:.1f}C",
|
||||
f"DSK {disk:.1f}%",
|
||||
ip_label+" "+display_ip,
|
||||
]
|
||||
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
|
||||
right=[
|
||||
f"MEM {mem:.1f}%",
|
||||
f"FAN {FAN_NAME[fan_level]}",
|
||||
f"WIF {signal}%" if signal is not None else f"{net_kind} {net_metric}",
|
||||
"",
|
||||
]
|
||||
for row in range(4):
|
||||
y=row*8
|
||||
draw_text_5x7(pix,0,y,left[row])
|
||||
if right[row]:
|
||||
draw_text_5x7(pix,72,y,right[row])
|
||||
data=[]
|
||||
for pg in range(4):
|
||||
for x in range(W):
|
||||
v=0
|
||||
for bit in range(8):
|
||||
if pix[x,pg*8+bit]: v|=1<<bit
|
||||
data.append(v)
|
||||
cmd(0x21);cmd(0);cmd(127);cmd(0x22);cmd(0);cmd(3)
|
||||
for i in range(0,len(data),16):
|
||||
bus_write("write_i2c_block_data",OLED,0x40,data[i:i+16])
|
||||
|
||||
|
||||
def render_alert(a,index,total):
|
||||
oled_show([(a["title"],f"{index}/{total}"),
|
||||
(a["l2"],""),(a["l3"],""),(a["l4"],"")])
|
||||
|
||||
|
||||
class DisplayController:
|
||||
class AlertManager:
|
||||
"""Current monitoring facts. Hardware plugins own all display decisions."""
|
||||
def __init__(self):
|
||||
now=time.monotonic()
|
||||
self.lock=threading.RLock()
|
||||
self.requests={"NORMAL_HOME":{
|
||||
"id":"NORMAL_HOME","priority":0,"severity":0,"rgb_mode":NORMAL_RGB_MODE,
|
||||
"title":"","l2":"","l3":"","l4":"","active":True,
|
||||
"first_seen":now,"last_changed":now,"metadata":{"home":(0.0,0.0,0.0,0.0)},
|
||||
}}
|
||||
self.current_owner_id="NORMAL_HOME"
|
||||
self.current_priority=0
|
||||
self.current_rgb_mode="OFF"
|
||||
self.owner_since=now
|
||||
self.current_oled_page_id="NORMAL_HOME"
|
||||
self.page_started=now
|
||||
self.batch_depth=0
|
||||
self.stop_event=threading.Event()
|
||||
self.oled_wake=threading.Event()
|
||||
self.oled_thread=threading.Thread(target=self._oled_worker,name="oled",daemon=True)
|
||||
self.rgb_thread=threading.Thread(target=self._rgb_worker,name="rgb",daemon=True)
|
||||
|
||||
def start(self):
|
||||
oled_init(); rgb_off(); self.oled_thread.start(); self.rgb_thread.start()
|
||||
|
||||
def update_home(self,cpu,temp,mem,disk):
|
||||
with self.lock:
|
||||
request=self.requests["NORMAL_HOME"]
|
||||
values=(cpu,temp,mem,disk)
|
||||
if request["metadata"].get("home")!=values:
|
||||
request["metadata"]={"home":values}; request["last_changed"]=time.monotonic()
|
||||
self.requests={}
|
||||
|
||||
def request(self,request_id,severity,title,l2="",l3="",l4="",metadata=None):
|
||||
now=time.monotonic(); priority=display_priority(request_id)
|
||||
request={"id":request_id,"priority":priority,"severity":severity,
|
||||
"rgb_mode":rgb_mode_for(request_id,severity),"title":title[:21],
|
||||
"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],"active":True,
|
||||
"last_changed":now,"metadata":metadata or {}}
|
||||
with self.lock:
|
||||
current=self.requests.get(request_id)
|
||||
if current is None:
|
||||
request["first_seen"]=now; self.requests[request_id]=request
|
||||
log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2)
|
||||
else:
|
||||
request["first_seen"]=current["first_seen"]
|
||||
changed=any(current.get(k)!=request.get(k) for k in
|
||||
("priority","severity","rgb_mode","title","l2","l3","l4","metadata"))
|
||||
if not changed: request["last_changed"]=current["last_changed"]
|
||||
self.requests[request_id]=request
|
||||
if self.batch_depth==0:
|
||||
self._arbitrate_locked()
|
||||
self._sync_rgb_locked()
|
||||
now=time.monotonic();old=self.requests.get(request_id)
|
||||
value={"id":request_id,"priority":alert_priority(request_id),"severity":severity,
|
||||
"category":ALERT_CATEGORIES[request_id.split("|",1)[0]].lower(),
|
||||
"title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21],
|
||||
"first_seen":old["first_seen"] if old else now,"metadata":metadata or {}}
|
||||
value["last_changed"]=old["last_changed"] if old and all(old[k]==v for k,v in value.items()) else now
|
||||
self.requests[request_id]=value
|
||||
if old is None:log("WARN","ALERT_ACTIVE",alert=request_id,severity=severity,detail=l2)
|
||||
|
||||
def clear(self,request_id):
|
||||
if request_id=="NORMAL_HOME": return
|
||||
with self.lock:
|
||||
if self.requests.pop(request_id,None) is not None:
|
||||
log("INFO","ALERT_RECOVERED",alert=request_id)
|
||||
if self.batch_depth==0:
|
||||
self._arbitrate_locked()
|
||||
if self.current_oled_page_id not in self.requests:
|
||||
self._select_page_locked(self.current_owner_id)
|
||||
else: self._sync_rgb_locked()
|
||||
|
||||
def begin_update(self):
|
||||
with self.lock: self.batch_depth+=1
|
||||
|
||||
def end_update(self):
|
||||
with self.lock:
|
||||
if self.batch_depth<=0: raise RuntimeError("display update batch imbalance")
|
||||
self.batch_depth-=1
|
||||
if self.batch_depth==0:
|
||||
self._arbitrate_locked()
|
||||
if self.current_oled_page_id not in self.requests:
|
||||
self._select_page_locked(self.current_owner_id)
|
||||
else: self._sync_rgb_locked()
|
||||
|
||||
def _arbitrate_locked(self):
|
||||
current=self.requests.get(self.current_owner_id)
|
||||
ordered=sorted(self.requests.values(),key=lambda r:(-r["priority"],r["first_seen"],r["id"]))
|
||||
candidate=ordered[0]
|
||||
if current is not None and candidate["priority"]<=current["priority"]: return
|
||||
self._change_owner_locked(candidate)
|
||||
|
||||
def _change_owner_locked(self,request):
|
||||
old=self.current_owner_id
|
||||
if request["id"]==old: return
|
||||
self.current_owner_id=request["id"]
|
||||
self.current_priority=request["priority"]
|
||||
self.current_rgb_mode=request["rgb_mode"]
|
||||
self.owner_since=time.monotonic()
|
||||
log("INFO","DISPLAY_OWNER",old=old,new=request["id"],priority=request["priority"])
|
||||
self._select_page_locked(request["id"])
|
||||
|
||||
def _ordered_alerts_locked(self):
|
||||
return sorted((r for r in self.requests.values() if r["id"]!="NORMAL_HOME"),
|
||||
key=lambda r:(-r["priority"],r["first_seen"],r["id"]))
|
||||
|
||||
def _select_page_locked(self,page_id,wake=True):
|
||||
request=self.requests.get(page_id,self.requests["NORMAL_HOME"])
|
||||
self.current_oled_page_id=request["id"]
|
||||
self.page_started=time.monotonic()
|
||||
self._sync_rgb_locked()
|
||||
if wake: self.oled_wake.set()
|
||||
|
||||
def _sync_rgb_locked(self):
|
||||
# Alert pages show that alert's object color. HOME keeps the highest-
|
||||
# priority active owner visible instead of briefly switching RGB off.
|
||||
rgb_request=self.requests.get(self.current_oled_page_id)
|
||||
if self.current_oled_page_id=="NORMAL_HOME":
|
||||
rgb_request=self.requests.get(self.current_owner_id,self.requests["NORMAL_HOME"])
|
||||
if rgb_request is None:
|
||||
rgb_request=self.requests["NORMAL_HOME"]
|
||||
wanted_mode=rgb_request["rgb_mode"]
|
||||
if wanted_mode!=self.current_rgb_mode:
|
||||
self.current_rgb_mode=wanted_mode
|
||||
|
||||
def refresh(self):
|
||||
with self.lock:
|
||||
alerts=self._ordered_alerts_locked()
|
||||
pages=["NORMAL_HOME"]+[a["id"] for a in alerts]
|
||||
if self.current_oled_page_id not in pages:
|
||||
self._select_page_locked(self.current_owner_id,wake=False)
|
||||
elif len(pages)>1 and time.monotonic()-self.page_started>=PAGE_INTERVAL:
|
||||
pos=pages.index(self.current_oled_page_id)
|
||||
self._select_page_locked(pages[(pos+1)%len(pages)],wake=False)
|
||||
request=dict(self.requests[self.current_oled_page_id])
|
||||
alert_ids=[a["id"] for a in alerts]
|
||||
if request["id"]=="NORMAL_HOME": render_home(*request["metadata"]["home"])
|
||||
else: render_alert(request,alert_ids.index(request["id"])+1,len(alerts))
|
||||
|
||||
def owner_snapshot(self):
|
||||
with self.lock: return dict(self.requests[self.current_owner_id])
|
||||
if self.requests.pop(request_id,None) is not None:log("INFO","ALERT_RECOVERED",alert=request_id)
|
||||
|
||||
def status_snapshot(self):
|
||||
with self.lock:
|
||||
alerts=self._ordered_alerts_locked()
|
||||
return {
|
||||
"owner":self.current_owner_id,
|
||||
"oled_page":self.current_oled_page_id,
|
||||
"rgb_mode":self.current_rgb_mode,
|
||||
"alerts":[{k:a[k] for k in ("id","priority","severity","title","l2","l3","l4","first_seen","last_changed")}
|
||||
for a in alerts],
|
||||
}
|
||||
values=sorted(self.requests.values(),key=lambda a:(-a["priority"],a["first_seen"],a["id"]))
|
||||
return {"owner":values[0]["id"] if values else "NORMAL_HOME","alerts":values}
|
||||
|
||||
def _oled_worker(self):
|
||||
previous_error=None
|
||||
while not self.stop_event.is_set():
|
||||
self.oled_wake.clear()
|
||||
try:
|
||||
self.refresh()
|
||||
if previous_error is not None: log("INFO","OLED_RECOVERED")
|
||||
previous_error=None
|
||||
except Exception as e:
|
||||
if repr(e)!=previous_error: log("ERROR","OLED_ERROR",error=repr(e))
|
||||
previous_error=repr(e)
|
||||
self.oled_wake.wait(OLED_REFRESH)
|
||||
|
||||
def _rgb_worker(self):
|
||||
previous_error=None; applied_mode="OFF"; applied_flash=False
|
||||
flash_on=None; animation_started=time.monotonic()
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
with self.lock: wanted_mode=self.current_rgb_mode
|
||||
wanted_flash=wanted_mode.endswith("_FLASH")
|
||||
if wanted_mode!=applied_mode:
|
||||
with bus_lock:
|
||||
if wanted_mode=="OFF": rgb_off()
|
||||
elif wanted_mode=="NORMAL_SOLID": rgb_static(NORMAL_COLOR,"normal_solid")
|
||||
elif wanted_mode=="NORMAL_FLASH": rgb_static(NORMAL_COLOR,"normal_flash_on")
|
||||
elif wanted_mode.startswith("NORMAL_"): rgb_normal_effect(wanted_mode)
|
||||
elif wanted_flash:
|
||||
rgb_off()
|
||||
rgb_flash(wanted_mode,True)
|
||||
else: raise ValueError(f"unsupported RGB mode: {wanted_mode}")
|
||||
log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode)
|
||||
applied_mode=wanted_mode; animation_started=time.monotonic()
|
||||
flash_on=True if wanted_mode.endswith("_FLASH") else None
|
||||
applied_flash=applied_mode.endswith("_FLASH")
|
||||
if applied_flash:
|
||||
elapsed_ms=(time.monotonic()-animation_started)*1000.0
|
||||
if applied_mode=="NORMAL_FLASH": on_ms,off_ms=NORMAL_FLASH
|
||||
elif "_EMERGENCY_" in applied_mode: on_ms,off_ms=EMERGENCY_FLASH
|
||||
elif "_CRITICAL_" in applied_mode: on_ms,off_ms=CRITICAL_FLASH
|
||||
else: on_ms,off_ms=WARNING_FLASH
|
||||
on=elapsed_ms%(on_ms+off_ms)<on_ms
|
||||
if on!=flash_on:
|
||||
with bus_lock:
|
||||
if applied_mode=="NORMAL_FLASH":
|
||||
rgb_static(NORMAL_COLOR,"normal_flash_on") if on else rgb_off()
|
||||
else: rgb_flash(applied_mode,on)
|
||||
flash_on=on
|
||||
# Fan and RGB share the same MCU. During an alert flash, send
|
||||
# fan changes only while RGB is already dark so MCU-internal
|
||||
# register handling cannot expose a transient unrelated color.
|
||||
if not applied_flash or flash_on is False:
|
||||
with bus_lock: apply_pending_fan(keep_rgb_off=applied_flash)
|
||||
if previous_error is not None: log("INFO","RGB_RECOVERED")
|
||||
previous_error=None
|
||||
except Exception as e:
|
||||
if repr(e)!=previous_error: log("ERROR","RGB_ERROR",error=repr(e))
|
||||
previous_error=repr(e)
|
||||
delay=0.04 if applied_flash else 0.25
|
||||
self.stop_event.wait(delay)
|
||||
|
||||
def shutdown(self):
|
||||
self.stop_event.set()
|
||||
self.oled_wake.set()
|
||||
if self.oled_thread.is_alive(): self.oled_thread.join()
|
||||
if self.rgb_thread.is_alive(): self.rgb_thread.join()
|
||||
for name,action in (("rgb",rgb_off),("oled_clear",lambda:oled_show([("","")]*4)),
|
||||
("oled_off",lambda:cmd(0xAE))):
|
||||
try: action()
|
||||
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step=name,error=repr(e))
|
||||
|
||||
def cleanup():
|
||||
log("INFO","STOP",loop_errors=agent["loop_errors"],i2c_errors=agent["i2c_errors"])
|
||||
if bus is None: return
|
||||
display.shutdown()
|
||||
for name,action in (("fan",lambda:mcu(0x08,0x00)),("bus_close",bus.close)):
|
||||
try: action()
|
||||
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step=name,error=repr(e))
|
||||
try: Path(STATUS_PATH).unlink(missing_ok=True)
|
||||
except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step="status_file",error=repr(e))
|
||||
publisher.close()
|
||||
log("INFO","STOP",loop_errors=agent["loop_errors"])
|
||||
try:Path(STATUS_PATH).unlink(missing_ok=True)
|
||||
except Exception as e:log("ERROR","SHUTDOWN_CLEANUP",step="status_file",error=repr(e))
|
||||
|
||||
def write_status(cpu,temp,mem,disk):
|
||||
display_state=display.status_snapshot()
|
||||
display_state=alert_manager.status_snapshot()
|
||||
memory_total,memory_used,memory_available,memory_calculated=memory_usage()
|
||||
disk_total,disk_used,disk_free=disk_usage("/")
|
||||
signal=wifi_percent(net_metric) if net_kind=="WIF" else None
|
||||
@@ -831,7 +391,7 @@ def write_status(cpu,temp,mem,disk):
|
||||
"memory_available_bytes":memory_available,
|
||||
"disk_percent":round(disk,1),"disk_total_bytes":disk_total,
|
||||
"disk_used_bytes":disk_used,"disk_free_bytes":disk_free,
|
||||
"root_readonly":bool(fs_readonly),"fan_level":fan_level,"fan_name":FAN_NAME[fan_level]},
|
||||
"root_readonly":bool(fs_readonly)},
|
||||
"power":health,
|
||||
"network":{"interface":net_iface,"kind":net_kind,"metric":net_metric,
|
||||
"signal_percent":signal,
|
||||
@@ -839,8 +399,10 @@ def write_status(cpu,temp,mem,disk):
|
||||
"watches":[{"type":w[0],"name":w[1],"target":w[2],"up":bool(ok)}
|
||||
for w,ok in zip(watches,watch_state)],
|
||||
"display":display_state,
|
||||
"agent":{"loop_errors":agent["loop_errors"],"i2c_errors":agent["i2c_errors"]},
|
||||
"agent":{"loop_errors":agent["loop_errors"]},
|
||||
"plugins":publisher.current_status(),
|
||||
}
|
||||
publisher.publish(state)
|
||||
path=Path(STATUS_PATH); path.parent.mkdir(mode=0o755,parents=True,exist_ok=True)
|
||||
temporary=path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(state,ensure_ascii=False,separators=(",",":")))
|
||||
@@ -856,18 +418,14 @@ watch_state=[]
|
||||
last_sensor=last_status=0.0
|
||||
last_net=last_watch=last_health=0.0
|
||||
health={}; fs_readonly=False
|
||||
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0,"i2c_errors":0}
|
||||
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0}
|
||||
event_state={}
|
||||
history_logged=set()
|
||||
latest=(0.0,0.0,0.0,0.0)
|
||||
display=DisplayController()
|
||||
alert_manager=AlertManager()
|
||||
publisher=Publisher({k:v for k,v in registered(cfg).items() if not cfg.getboolean("plugin_paused",k,fallback=False)},DEVICE_ID,log)
|
||||
try:
|
||||
bus=SMBus(1)
|
||||
# Quench the MCU's power-on RGB effect before OLED initialization and all
|
||||
# other startup work. The display controller remains the sole RGB owner.
|
||||
rgb_off()
|
||||
FONT5=Path(FONT_PATH).read_bytes()
|
||||
display.start(); apply_dark_mode()
|
||||
apply_dark_mode()
|
||||
log("INFO","START",version=APP_VERSION,uptime=uptime_seconds())
|
||||
while running:
|
||||
try:
|
||||
@@ -879,7 +437,7 @@ try:
|
||||
if now-last_sensor >= SENSOR_INTERVAL:
|
||||
sample_elapsed=now-last_sensor if last_sensor else 0.0
|
||||
c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct()
|
||||
latest=(c,t,m,d); update_fan(t); last_sensor=now; agent["last_sensor_ok"]=now
|
||||
latest=(c,t,m,d); last_sensor=now; agent["last_sensor_ok"]=now
|
||||
|
||||
if now-last_net >= NETWORK_INTERVAL:
|
||||
old_id=(net_iface,ip)
|
||||
@@ -912,10 +470,7 @@ try:
|
||||
agent["last_health_ok"]=now; last_health=now
|
||||
|
||||
c,t,m,d=latest
|
||||
display.update_home(c,t,m,d)
|
||||
display.begin_update()
|
||||
try: update_active_alerts(c,t,m,d,sample_elapsed)
|
||||
finally: display.end_update()
|
||||
update_active_alerts(c,t,m,d,sample_elapsed)
|
||||
if now-last_status >= 1.0:
|
||||
write_status(c,t,m,d); last_status=now
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
sys.path.append("/usr/local/share/pigway-pi-control")
|
||||
from plugin_api import discover,registered,request as plugin_request,socket_path
|
||||
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
|
||||
from concurrent.futures import ThreadPoolExecutor,as_completed
|
||||
from http import HTTPStatus
|
||||
@@ -12,7 +15,7 @@ CFG=Path("/etc/pigway-pi-control.conf")
|
||||
STATUS=Path("/run/pigway-pi-control/status.json")
|
||||
TOKEN=Path("/etc/pigway-pi-control-api.token")
|
||||
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
|
||||
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","fan","led","timing","dark_mode","display","api"}
|
||||
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","timing","dark_mode","api"}
|
||||
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
|
||||
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
|
||||
@@ -21,6 +24,40 @@ NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorf
|
||||
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
|
||||
PROTECTED_UNITS={"pigway-pi-control-api.service"}
|
||||
|
||||
def plugin_inventory():
|
||||
cfg=load_cfg();items=discover(cfg)
|
||||
for item in items:item["paused"]=cfg.getboolean("plugin_paused",item["id"],fallback=False)
|
||||
return items
|
||||
|
||||
|
||||
def find_plugin(plugin_id):
|
||||
for item in plugin_inventory():
|
||||
if item["id"]==plugin_id:return item
|
||||
raise ValueError("unknown plugin")
|
||||
|
||||
|
||||
def plugin_operation(body):
|
||||
plugin_id=str(body.get("id",""))
|
||||
if not NAME_RE.fullmatch(plugin_id):raise ValueError("invalid plugin id")
|
||||
item=find_plugin(plugin_id);operation=body.get("operation")
|
||||
if operation=="link":
|
||||
if not item["online"]:raise ValueError("plugin is offline")
|
||||
text=replace_ini_value("plugins",plugin_id,socket_path(item["socket"]))
|
||||
save_ini_text(text,"plugin-link")
|
||||
text=replace_ini_value("plugin_paused",plugin_id,"false")
|
||||
elif operation in {"pause","resume","unlink"}:
|
||||
if not item["linked"]:raise ValueError("plugin is not linked")
|
||||
text=replace_ini_value("plugins",plugin_id,None) if operation=="unlink" else replace_ini_value("plugin_paused",plugin_id,"true" if operation=="pause" else "false")
|
||||
else:raise ValueError("invalid plugin operation")
|
||||
backup=save_ini_text(text,"plugin")
|
||||
# Only reload a monitor that is already running. Never start hardware here.
|
||||
subprocess.run(["systemctl","try-restart","pigway-pi-control.service"],check=True,timeout=15)
|
||||
if operation in {"pause","unlink"}:
|
||||
try:plugin_request(item["socket"],"DELETE","/v1/state",{})
|
||||
except Exception:pass # An offline plugin expires its lease independently.
|
||||
return {"ok":True,"backup":backup}
|
||||
|
||||
|
||||
def machine_identity():
|
||||
cfg=load_cfg(); identifier=cfg.get("device","identifier",fallback="auto").strip()
|
||||
if not identifier or identifier.lower()=="auto": identifier=socket.gethostname()
|
||||
@@ -92,12 +129,19 @@ def api_setting(key,default,cast=str):
|
||||
|
||||
def config_json():
|
||||
cfg=load_cfg()
|
||||
return {section:dict(cfg.items(section)) for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
||||
return {section:{k:v for k,v in cfg.items(section) if not (section=="timing" and k in {"oled_refresh_interval","page_interval"})} for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
||||
|
||||
def validate_value(section,key,value,existing):
|
||||
if section=="timing" and key in {"oled_refresh_interval","page_interval"}:raise ValueError("display timing is managed by the hardware plugin")
|
||||
if section not in {"services","processes","hosts"} and key not in existing.get(section,set()):
|
||||
raise ValueError(f"unsupported option: {section}.{key}")
|
||||
if RGB_KEY_RE.fullmatch(key):
|
||||
if section=="hardware" and key=="cooling_hat_enabled":
|
||||
if value.lower() not in ("true","false"): raise ValueError("cooling_hat_enabled must be true or false")
|
||||
elif section=="led" and key=="alert_mode":
|
||||
if value.lower() not in ("breathe","flash"): raise ValueError("alert_mode must be breathe or flash")
|
||||
elif section=="led" and key=="custom_min_hold_ms":
|
||||
if not 1500<=int(value)<=60000: raise ValueError("custom_min_hold_ms must be 1500..60000")
|
||||
elif RGB_KEY_RE.fullmatch(key):
|
||||
number=int(value)
|
||||
if not 0<=number<=255: raise ValueError(f"{key} must be 0..255")
|
||||
elif FLASH_KEY_RE.fullmatch(key):
|
||||
@@ -124,14 +168,14 @@ def validate_value(section,key,value,existing):
|
||||
|
||||
def replace_ini_value(section,key,value):
|
||||
text=CFG.read_text()
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||
if not section_match:
|
||||
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
return text
|
||||
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():])
|
||||
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
|
||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||
chunk=text[section_match.end():end]
|
||||
key_match=re.search(rf"(?mi)^\s*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||
if key_match:
|
||||
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
|
||||
return text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
|
||||
@@ -169,11 +213,11 @@ def update_ini(updates):
|
||||
if not value or len(value)>256 or any(c in value for c in "\r\n\x00"):
|
||||
raise ValueError(f"invalid value: {section}.{key}")
|
||||
validate_value(section,str(key),value,existing)
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||
if not section_match:
|
||||
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
continue
|
||||
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():])
|
||||
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
|
||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||
chunk=text[section_match.end():end]
|
||||
key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk)
|
||||
@@ -308,14 +352,14 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
|
||||
text=CFG.read_text()
|
||||
def change(section,key,value):
|
||||
nonlocal text
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||
if not section_match:
|
||||
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
return
|
||||
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():])
|
||||
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
|
||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||
chunk=text[section_match.end():end]
|
||||
key_match=re.search(rf"(?mi)^\s*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||
if key_match:
|
||||
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
|
||||
text=text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
|
||||
@@ -408,6 +452,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if parsed.path=="/api/v1/fleet/logs":
|
||||
self.send_json(200,fleet_journal_query(since,until,severity,event,search,value("machine"),sort,order,limit,offset)); return
|
||||
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return
|
||||
if parsed.path in {"/api/v1/plugins","/api/v1/plugin/config"}:
|
||||
query=parse_qs(parsed.query);host_id=query.get("host",[""])[0]
|
||||
plugin_id=query.get("id",[""])[0]
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
host=find_host(host_id)
|
||||
self.send_json(200,remote_json(host["url"],parsed.path+"?"+urlencode({"id":plugin_id})));return
|
||||
if parsed.path.endswith("/plugins"):
|
||||
self.send_json(200,{"plugins":plugin_inventory()});return
|
||||
item=find_plugin(plugin_id)
|
||||
self.send_json(200,plugin_request(item["socket"],"GET","/v1/config"));return
|
||||
if parsed.path=="/api/v1/config":
|
||||
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
@@ -424,10 +478,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_PUT(self):
|
||||
path=urlparse(self.path).path
|
||||
if path not in {"/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/config"}: self.send_json(404,{"error":"not found"}); return
|
||||
if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/config"}: self.send_json(404,{"error":"not found"}); return
|
||||
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
|
||||
try:
|
||||
body=self.read_json()
|
||||
if path in {"/api/v1/plugins","/api/v1/plugin/config"}:
|
||||
host_id=str(body.get("host",""))
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
host=find_host(host_id);token=str(body.get("target_token",""))
|
||||
if not token:raise ValueError("remote bearer token required")
|
||||
payload={k:v for k,v in body.items() if k not in {"host","target_token"}}
|
||||
self.send_json(200,remote_json(host["url"],path,"PUT",payload,token));return
|
||||
if path.endswith("/plugins"):self.send_json(200,plugin_operation(body));return
|
||||
item=find_plugin(str(body.get("id","")))
|
||||
self.send_json(200,plugin_request(item["socket"],"PUT","/v1/config",{"updates":body.get("updates")}));return
|
||||
if path=="/api/v1/hosts":
|
||||
backup=edit_host(str(body.get("operation","")),str(body.get("id","")),str(body.get("url","")))
|
||||
self.send_json(200,{"ok":True,"backup":backup,"hosts":host_registry()}); return
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Hardware-independent client for the versioned local plugin API."""
|
||||
import copy
|
||||
from contextlib import closing
|
||||
import http.client
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_ROOT=Path('/run/pigway-plugins')
|
||||
|
||||
|
||||
def socket_path(value):
|
||||
path=Path(value)
|
||||
if not path.is_absolute() or '..' in path.parts or path.name!='api.sock' or PLUGIN_ROOT not in path.parents:
|
||||
raise ValueError('plugin socket must be /run/pigway-plugins/<plugin>/api.sock')
|
||||
return str(path)
|
||||
|
||||
|
||||
class Connection(http.client.HTTPConnection):
|
||||
def __init__(self,path):
|
||||
super().__init__('localhost',timeout=.8);self.path=socket_path(path)
|
||||
|
||||
def connect(self):
|
||||
self.sock=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
|
||||
self.sock.settimeout(self.timeout)
|
||||
try:self.sock.connect(self.path)
|
||||
except Exception:self.sock.close();raise
|
||||
|
||||
|
||||
def request(path,method,endpoint,payload=None):
|
||||
with closing(Connection(path)) as conn:
|
||||
body=None if payload is None else json.dumps(payload,allow_nan=False).encode()
|
||||
conn.request(method,endpoint,body,{'Content-Type':'application/json'})
|
||||
response=conn.getresponse();raw=response.read(262145)
|
||||
if len(raw)>262144:raise ValueError('plugin response too large')
|
||||
value=json.loads(raw)
|
||||
if response.status>=400:raise ValueError(value.get('error','plugin request failed'))
|
||||
return value
|
||||
|
||||
|
||||
def registered(cfg):
|
||||
if not cfg.has_section('plugins'):return {}
|
||||
return {k:socket_path(v) for k,v in cfg.items('plugins') if v.strip()}
|
||||
|
||||
|
||||
def discover(cfg):
|
||||
linked=registered(cfg);paths={v for v in linked.values()}
|
||||
paths.update(str(p) for p in PLUGIN_ROOT.glob('*/api.sock'))
|
||||
result=[]
|
||||
for path in sorted(paths)[:32]:
|
||||
key=next((k for k,v in linked.items() if v==path),None)
|
||||
try:
|
||||
d=request(path,'GET','/v1/descriptor')
|
||||
if d.get('api_version')!=1:raise ValueError('incompatible plugin API version')
|
||||
status=request(path,'GET','/v1/status')
|
||||
result.append({'id':key or d['id'],'socket':path,'linked':key is not None,'online':True,'descriptor':d,'status':status})
|
||||
except Exception as exc:
|
||||
result.append({'id':key or Path(path).parent.name,'socket':path,'linked':key is not None,'online':False,'error':str(exc)})
|
||||
return result
|
||||
|
||||
|
||||
class Publisher:
|
||||
"""One replaceable snapshot, not an unbounded queue of animation commands."""
|
||||
def __init__(self,plugins,source,log):
|
||||
self.plugins=plugins;self.source=source;self.log=log
|
||||
self.session=str(uuid.uuid4());self.revision=0;self.snapshot=None
|
||||
self.lock=threading.Lock();self.stop_event=threading.Event();self.status={}
|
||||
self.threads=[threading.Thread(target=self.run,args=(key,path),name='plugin-'+key,daemon=True)
|
||||
for key,path in plugins.items()]
|
||||
for thread in self.threads:thread.start()
|
||||
|
||||
def publish(self,state):
|
||||
with self.lock:self.snapshot=copy.deepcopy(state)
|
||||
|
||||
def current_status(self):
|
||||
with self.lock:return copy.deepcopy(self.status)
|
||||
|
||||
def run(self,key,path):
|
||||
previous_error=None;revision=0;descriptor=None
|
||||
while not self.stop_event.is_set():
|
||||
with self.lock:state=self.snapshot
|
||||
if state is not None:
|
||||
revision+=1
|
||||
payload={'api_version':1,'source':self.source,'session':self.session,'revision':revision,
|
||||
'ttl_seconds':10,'system':state['system'],'network':state['network'],'alerts':state['display']['alerts']}
|
||||
try:
|
||||
if descriptor is None:
|
||||
descriptor=request(path,'GET','/v1/descriptor')
|
||||
if descriptor.get('api_version')!=1:raise ValueError('incompatible plugin API version')
|
||||
request(path,'PUT','/v1/state',payload)
|
||||
status=request(path,'GET','/v1/status')
|
||||
with self.lock:self.status[key]={'online':True,'status':status,'name':descriptor.get('name',key),'capabilities':descriptor.get('capabilities',{})}
|
||||
if previous_error is not None:self.log('INFO','PLUGIN_RECOVERED',plugin=key)
|
||||
previous_error=None
|
||||
except Exception as exc:
|
||||
descriptor=None
|
||||
error=str(exc)
|
||||
with self.lock:self.status[key]={'online':False,'error':error}
|
||||
if previous_error!=error:self.log('WARN','PLUGIN_UNAVAILABLE',plugin=key,error=error)
|
||||
previous_error=error
|
||||
self.stop_event.wait(1)
|
||||
try:request(path,'DELETE','/v1/state',{'source':self.source,'session':self.session})
|
||||
except Exception:pass # Server-side leases expire if transport is unavailable.
|
||||
|
||||
def close(self):
|
||||
self.stop_event.set()
|
||||
for thread in self.threads:thread.join()
|
||||
Reference in New Issue
Block a user