#!/usr/bin/env python3 import os,time,socket,subprocess,configparser,signal from pathlib import Path APP_VERSION="3.1.0" from smbus2 import SMBus from PIL import Image from shutil import disk_usage OLED=0x3C; MCU=0x0D; W,H=128,32 CFG="/etc/pigway-pi-control.conf" running=True def stop(*_): global running; running=False signal.signal(signal.SIGTERM,stop); signal.signal(signal.SIGINT,stop) bus=SMBus(1) FONT_PATH="/usr/local/share/pigway-pi-control/oled_font_5x7.bin" FONT5=Path(FONT_PATH).read_bytes() fan_level=0 prev_cpu=None page=0; page_since=time.monotonic() cpu_hi=mem_hi=0 net_ok=link_ok=True ip="NO IP" last_net=last_watch=0 watch_state=[] def cmd(c): bus.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<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<=3 => all three LEDs mcu(0x01,state[0]); mcu(0x02,state[1]); mcu(0x03,state[2]) _last_rgb=state def set_pi_led(name,off=True): base=Path("/sys/class/leds")/name try: if off: (base/"trigger").write_text("none") (base/"brightness").write_text("0") except Exception as e: print(f"board LED {name}: {e}",flush=True) 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 tfan_level: fan_level=want elif want=CPU_HIGH else 0 if cpu=MEM_HIGH else 0 if mem=CPU_HIGH_SEC:a.append(alert("CPU LOAD HIGH",f"LOAD {cpu:.1f}%",f"TEMP {temp:.1f}C","FOR >=30 SEC",1,(24,18,0))) if temp>=TEMP_CRIT:a.append(alert("TEMP CRITICAL",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING",3,(32,0,0),2)) elif temp>=TEMP_HIGH:a.append(alert("TEMP HIGH",f"TEMP {temp:.1f}C",f"FAN {FAN_NAME[fan_level]}","CHECK COOLING",1,(24,0,0))) if mem_hi>=MEM_HIGH_SEC:a.append(alert("MEMORY HIGH",f"USED {mem:.1f}%","CHECK PROCESS","",1,(18,0,24))) if disk>=DISK_CRIT:a.append(alert("DISK CRITICAL",f"USED {disk:.1f}%","FREE < 5%","CHECK STORAGE",2,(28,6,0),2)) elif disk>=DISK_HIGH:a.append(alert("DISK SPACE LOW",f"USED {disk:.1f}%","FREE < 10%","CHECK STORAGE",1,(28,8,0))) if not link_ok:a.append(alert("NETWORK DOWN","NO LAN / WIFI","NO IP ADDRESS","",2,(0,8,28),1)) elif not net_ok:a.append(alert("INTERNET DOWN","LOCAL LINK OK",ip,"",2,(0,8,28),1)) for w,ok in zip(watches,watch_state): if not ok:a.append(alert("SERVICE DOWN",w[1],"CHECK / RESTART","",2,(0,24,24),1)) a.sort(key=lambda x:x["sev"],reverse=True) return a def led_for(a): # Color = fault object. Blink frequency = severity. All alerts blink. if not a: rgb_off(); return if a["sev"]>=3: on_ms,off_ms=LED_EMERG_ON,LED_EMERG_OFF elif a["sev"]>=2: on_ms,off_ms=LED_CRIT_ON,LED_CRIT_OFF else: on_ms,off_ms=LED_WARN_ON,LED_WARN_OFF cycle=max(1,on_ms+off_ms) phase=int(time.monotonic()*1000)%cycle rgb(*a["color"]) if phase= SENSOR_INTERVAL: c=cpu_pct(); t=temp_c(); m=mem_pct(); d=disk_pct() latest=(c,t,m,d); update_fan(t); last_sensor=now if now-last_net >= NETWORK_INTERVAL: ip=get_ip(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False last_net=now if now-last_watch >= SERVICE_INTERVAL: watch_state=[check_watch(w) for w in watches] last_watch=now c,t,m,d=latest active=make_alerts(c,t,m,d) # If the currently displayed alert truly disappeared, skip it immediately. if cycle_pages[cycle_pos]!="HOME": key=cycle_pages[cycle_pos] if find_live_alert(key,active) is None: cycle_pos += 1 if cycle_pos>=len(cycle_pages): # End of cycle: capture a fresh snapshot. cycle_pages=["HOME"]+[alert_key(a) for a in active] cycle_pos=0 page_started=now # ONLY this timer performs normal page changes. if now-page_started >= PAGE_INTERVAL: cycle_pos += 1 if cycle_pos>=len(cycle_pages): # A complete cycle ended. Snapshot all currently active alerts. cycle_pages=["HOME"]+[alert_key(a) for a in active] cycle_pos=0 page_started=now # Special case: while idle on HOME with no alert pages queued, begin a new # alert cycle immediately when the first alert appears. HOME keeps its # current 10-second slot; alerts follow it and cannot be kicked out by refresh. if cycle_pages==["HOME"] and active: cycle_pages=["HOME"]+[alert_key(a) for a in active] # Render CURRENT page only. 1-second refresh does not affect cycle_pos. if now-last_render >= OLED_REFRESH: current=cycle_pages[cycle_pos] if current=="HOME": render_home(c,t,m,d) else: a=find_live_alert(current,active) if a: # Number refers to this cycle's alert slot, all using same 6x8 font. render_alert(a,cycle_pos,len(cycle_pages)-1) else: render_home(c,t,m,d) last_render=now # LED follows OLED page; HOME reflects highest currently active risk. current=cycle_pages[cycle_pos] if current=="HOME": led_for(active[0] if active else None) else: a=find_live_alert(current,active) led_for(a if a else (active[0] if active else None)) time.sleep(0.20) except Exception as e: print("loop error:",e,flush=True); time.sleep(1) rgb_off(); mcu(0x08,0x00); bus.close()