diff --git a/README.md b/README.md index ed5285a..f7a0ced 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,11 @@ journalctl -u pigway-pi-control --no-pager | grep 'event=POWER' sudo /usr/local/sbin/pigway-pi-control --diagnose ``` -用于快速查看 uptime、load、CPU频率、throttled、根文件系统、当前网络和 Internet 状态。后续排障优先使用该命令,不再反复手工拼大量 grep。 +输出 Agent 版本、系统 uptime、配置路径、load、CPU 频率、根文件系统、全部当前/历史 power 位、主网络接口、完整 IP 和 Internet 状态,以及所有 service/process watch 状态。 + +诊断使用与运行时相同的配置和检查函数;仅以 I²C receive-byte 读取探测 MCU 0x0D / OLED 0x3C,失败显示 MISSING/ERROR,不初始化或写入风扇、RGB、OLED。独立诊断进程无法获取正在运行的服务的内存计数,故 loop_errors / i2c_errors 明确标记 UNAVAILABLE;服务退出的 STOP 事件包含该次运行计数。 + +RC2 保留 v3.2.0 的 RGB MCU 寄存器写入顺序;独立闪烁时钟使用所有有效告警的最高 severity,不受 OLED 页面及网络/watch 检查等待影响。服务恢复后自动切换到下一有效告警,没有告警时 OFF。 ### 6. 关机收尾 @@ -153,7 +157,7 @@ sudo ./install.sh - 检查 I²C - 禁用旧 `temp-control.service` - 安装字体、程序、配置和 systemd unit -- 保留用户已有 `[services]` / `[processes]` +- 完整保留用户现有配置(包括所有 section、用户值和注释),新选项使用运行时默认值 - 启动服务 - 执行版本、OLED、UI、P0健康监控和诊断入口自检 diff --git a/SHA256SUMS b/SHA256SUMS index f4f13d3..c9e3df1 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,7 @@ -ef212ae05c0fedd3d8b9e83b6a7d0c7f2bff08a9292c306ce081f04c50fc1998 README.md +192d29003ffd867d440753c1903d194a51ec9c2cadbc5442d0e8d1977364217d README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -0cc9404165553e873e54980b2becd32fbe6dcf19460983aee98ec04fd67e4e88 app/pigway_pi_control.py -48eca3a160da8cb0dd3fdbbc66880ac61098ed2389b1c31954f5a98d8f914086 config/pigway-pi-control.conf -7ec3e507169ab82234897dcf39569a8c82dfa07089ba1a30cfbe0e4d07b7a38c install.sh +1445d390eef0a66c14d827f8468064f5a09ae2f005e04a636ae38f77dd0bb5d7 app/pigway_pi_control.py +7d64d7fcf2b17580b4565b99e290a85e4317dbf2cc71224adcacb56489fcf1b3 config/pigway-pi-control.conf +4c2816da08378f2927c746b70ecfa7d5312e1730c08727cd89e8c2801e707054 install.sh 81aea0a89b298b2512fcb9bf6418618ba7a66474d425390cf95a0128c4093474 systemd/pigway-pi-control.service a3d74973cafbabaf58a660a8c83d28347d1c06d9fd2dab3ecfce5b7607f6fee2 uninstall.sh diff --git a/app/pigway_pi_control.py b/app/pigway_pi_control.py index 7182490..7a095ee 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -import os,time,socket,subprocess,configparser,signal,re,sys,json +import time,socket,subprocess,configparser,signal,re,errno,threading,argparse from pathlib import Path APP_VERSION="3.3.0" from smbus2 import SMBus @@ -45,19 +45,22 @@ def root_readonly(): def throttled_state(): raw=_run(["vcgencmd","get_throttled"]) m=re.search(r"0x([0-9a-fA-F]+)",raw);v=int(m.group(1),16) if m else 0 - 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_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_occurred":bool(v&(1<<19))} + 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))} -def diagnose(): - print(f"Pigway Pi Control {APP_VERSION} diagnostic") - print("uptime_seconds=",uptime_seconds());print("load_average=",load_average());print("cpu_freq_mhz=",round(cpu_freq_mhz())) - print("root_readonly=",root_readonly());print("throttled=",json.dumps(throttled_state())) - print("network=",primary_network());print("internet=",internet()) - out=_run(["i2cdetect","-y","1"],4) if False else "" - return 0 - -bus=SMBus(1) +bus=None +bus_lock=threading.RLock() FONT_PATH="/usr/local/share/pigway-pi-control/oled_font_5x7.bin" -FONT5=Path(FONT_PATH).read_bytes() +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 prev_cpu=None page=0; page_since=time.monotonic() @@ -68,7 +71,7 @@ wifi_weak_since=None; wifi_weak_active=False last_net=last_watch=0 watch_state=[] -def cmd(c): bus.write_byte_data(OLED,0x00,c) +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, @@ -107,10 +110,10 @@ def oled_show(lines_lr, title_zh=False): 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_i2c_block_data(OLED,0x40,data[i:i+16]) + bus_write("write_i2c_block_data",OLED,0x40,data[i:i+16]) -def mcu(reg,val): bus.write_byte_data(MCU,reg,val) +def mcu(reg,val): bus_write("write_byte_data",MCU,reg,val) _last_rgb=None def rgb_off(): global _last_rgb @@ -200,7 +203,6 @@ def primary_network(): n=int(Path(f"/sys/class/net/{iface}/speed").read_text().strip()) metric=f"{n//1000}G" if n>=1000 and n%1000==0 else str(n) except Exception: pass - if label=="IP6" and len(ipval)>18: ipval=ipval[:7]+".."+ipval[-7:] return iface,kind,metric,label,(ipval or "NO IP") def get_ip(): @@ -263,11 +265,49 @@ LED_EMERG_ON=cf("led","emergency_on_ms",400,int); LED_EMERG_OFF=cf("led","emerge def check_watch(w): typ,label,target=w - if typ=="service": - r=subprocess.run(["systemctl","is-active","--quiet",target]) - else: - r=subprocess.run(["pgrep","-f",target],stdout=subprocess.DEVNULL) - return r.returncode==0 + args=["systemctl","is-active","--quiet",target] if typ=="service" else ["pgrep","-f","--",target] + try: + r=subprocess.run(args,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=2) + return r.returncode==0 + except (OSError,subprocess.TimeoutExpired): + return False + + +def diagnose(): + print("Agent:") + print(f"version={APP_VERSION} uptime_seconds={uptime_seconds()} config={CFG}") + print("loop_errors=UNAVAILABLE i2c_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:") + power=throttled_state() + print("throttled="+power["raw"]) + for key in ("under_voltage_now","freq_capped_now","throttled_now","soft_temp_limit_now", + "under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_limit_occurred"): + print(f"{key}={power.get(key,'UNKNOWN')}") + if "error" in power: print("error="+power["error"]) + iface,kind,metric,label,address=primary_network() + print("Network:") + print(f"primary_interface={iface or 'NONE'} kind={kind} metric={metric} 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("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 + def alert(title,l2="",l3="",l4="",sev=1,color=(20,16,0),blink=0): return {"title":title[:21],"l2":l2[:21],"l3":l3[:21],"l4":l4[:21], @@ -279,12 +319,12 @@ def transition(key,active,on_event,off_event,**fields): event_state[key]=active log("WARN" if active else "INFO",on_event if active else off_event,**fields) -def make_alerts(cpu,temp,mem,disk): +def make_alerts(cpu,temp,mem,disk,sample_elapsed=0.0): global wifi_weak_since,wifi_weak_active global cpu_hi,mem_hi a=[] - cpu_hi=cpu_hi+1 if cpu>=CPU_HIGH else 0 if cpu=MEM_HIGH else 0 if mem=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))) @@ -333,11 +373,12 @@ 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+" "+ip, + ip_label+" "+display_ip, ] right=[ f"MEM {mem:.1f}%", @@ -359,7 +400,7 @@ def render_home(cpu,temp,mem,disk): 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_i2c_block_data(OLED,0x40,data[i:i+16]) + bus_write("write_i2c_block_data",OLED,0x40,data[i:i+16]) def render_alert(a,idx,total): @@ -367,18 +408,55 @@ def render_alert(a,idx,total): (a["l2"],""),(a["l3"],""),(a["l4"],"")]) -if "--diagnose" in sys.argv: - raise SystemExit(diagnose()) +def alert_key(a): + # Sensor values change without changing the identity of a fault. + return a["title"]+("|"+a["l2"] if a["title"] in ("SERVICE DOWN","PROCESS DOWN") else "") -oled_init(); rgb_off(); apply_dark_mode(); log("INFO","START",version=APP_VERSION,uptime=uptime_seconds()) -watch_state=[check_watch(w) for w in watches] +def find_live_alert(key, live): + for a in live: + if alert_key(a)==key: return a + return None + +# RGB has its own clock so slow network/watch checks cannot stretch blink pulses. +led_stop=threading.Event() +led_alert=None + +def led_worker(): + previous_error=None + while not led_stop.is_set(): + try: + with bus_lock: led_for(led_alert) + 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) + led_stop.wait(0.02) + +def cleanup(): + log("INFO","STOP",loop_errors=agent["loop_errors"],i2c_errors=agent["i2c_errors"]) + led_stop.set() + if led_thread.is_alive(): led_thread.join() + if bus is None: return + # Attempt every step even when a previous device write fails. + for name,action in (("rgb",rgb_off),("fan",lambda:mcu(0x08,0x00)), + ("oled_clear",lambda:oled_show([("","")]*4)), + ("oled_off",lambda:cmd(0xAE)),("bus_close",bus.close)): + try: action() + except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",step=name,error=repr(e)) + +# Dispatch only after all shared checks and configuration have been initialized. +parser=argparse.ArgumentParser() +parser.add_argument("--diagnose",action="store_true") +args=parser.parse_args() +if args.diagnose: raise SystemExit(diagnose()) + +watch_state=[] last_sensor=last_render=0.0 -last_net=last_watch=last_health=time.monotonic() -health=throttled_state(); fs_readonly=root_readonly() +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} event_state={} -prev_net_identity=None -prev_watch_map={w[1]:ok for w,ok in zip(watches,watch_state)} history_logged=set() prev_alert_keys=set() latest=(0.0,0.0,0.0,0.0) @@ -391,119 +469,109 @@ cycle_pos=0 page_started=time.monotonic() current_alert_key=None -def alert_key(a): - # Stable identity: title + monitored object/primary detail. - return f"{a['title']}|{a['l2']}" - -def find_live_alert(key, live): - for a in live: - if alert_key(a)==key: return a - return None - -while running: - try: - now=time.monotonic() +led_thread=threading.Thread(target=led_worker,name="rgb",daemon=True) +try: + bus=SMBus(1) + FONT5=Path(FONT_PATH).read_bytes() + oled_init(); rgb_off(); apply_dark_mode() + log("INFO","START",version=APP_VERSION,uptime=uptime_seconds()) + led_thread.start() + while running: + try: + now=time.monotonic() + sample_elapsed=0.0 - # Independent clocks: update data/state only. - if now-last_sensor >= 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; agent["last_sensor_ok"]=now + # Independent clocks: update data/state only. + 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 - if now-last_net >= NETWORK_INTERVAL: - old_id=(net_iface,ip) - net_iface,net_kind,net_metric,ip_label,ip=primary_network(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False - new_id=(net_iface,ip) - if old_id!=("","NO IP") and new_id!=old_id: - log("INFO","NET_SWITCH",old_if=old_id[0],old_ip=old_id[1],new_if=net_iface,new_ip=ip) - transition("network_down",not link_ok,"NETWORK_DOWN","NETWORK_RECOVERED",iface=net_iface,ip=ip) - transition("internet_down",link_ok and not net_ok,"INTERNET_DOWN","INTERNET_RECOVERED",iface=net_iface,ip=ip) - last_net=now; agent["last_network_ok"]=now + if now-last_net >= NETWORK_INTERVAL: + old_id=(net_iface,ip) + net_iface,net_kind,net_metric,ip_label,ip=primary_network(); link_ok=ip!="NO IP"; net_ok=internet() if link_ok else False + new_id=(net_iface,ip) + if new_id!=old_id: + log("INFO","NET_SWITCH",old_if=old_id[0],old_ip=old_id[1],new_if=net_iface,new_ip=ip) + transition("network_down",not link_ok,"NETWORK_DOWN","NETWORK_RECOVERED",iface=net_iface,ip=ip) + transition("internet_down",link_ok and not net_ok,"INTERNET_DOWN","INTERNET_RECOVERED",iface=net_iface,ip=ip) + last_net=now; agent["last_network_ok"]=now - if now-last_watch >= SERVICE_INTERVAL: - watch_state=[check_watch(w) for w in watches] - for w,ok in zip(watches,watch_state): - transition("watch:"+w[1],not ok,"WATCH_DOWN","WATCH_RECOVERED",name=w[1],target=w[2]) - last_watch=now; agent["last_watch_ok"]=now + if now-last_watch >= SERVICE_INTERVAL: + watch_state=[check_watch(w) for w in watches] + for w,ok in zip(watches,watch_state): + transition("watch:"+w[0]+":"+w[1],not ok,"WATCH_DOWN","WATCH_RECOVERED",name=w[1],target=w[2]) + last_watch=now; agent["last_watch_ok"]=now - if now-last_health >= HEALTH_INTERVAL: - health=throttled_state(); fs_readonly=root_readonly() - transition("undervoltage",health.get("under_voltage_now",False),"POWER_LOW","POWER_RECOVERED",raw=health.get("raw")) - transition("throttled",health.get("throttled_now",False) or health.get("freq_capped_now",False),"THROTTLED","THROTTLE_RECOVERED",raw=health.get("raw"),freq_mhz=f"{cpu_freq_mhz():.0f}") - transition("root_ro",fs_readonly,"FILESYSTEM_RO","FILESYSTEM_RW") - for k in ("under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_occurred"): - if health.get(k) and k not in history_logged: - history_logged.add(k); log("WARN","HISTORY_FLAG",flag=k,raw=health.get("raw")) - la=load_average() - agent["last_health_ok"]=now; last_health=now + if now-last_health >= HEALTH_INTERVAL: + power_sample=throttled_state(); fs_readonly=root_readonly() + transition("power_check", "error" in power_sample,"POWER_CHECK_ERROR","POWER_CHECK_RECOVERED") + # An unavailable reading is not evidence that an existing fault recovered. + if "error" not in power_sample: health=power_sample + transition("undervoltage",health.get("under_voltage_now",False),"POWER_LOW","POWER_RECOVERED",raw=health.get("raw")) + transition("throttled",health.get("throttled_now",False) or health.get("freq_capped_now",False),"THROTTLED","THROTTLE_RECOVERED",raw=health.get("raw"),freq_mhz=f"{cpu_freq_mhz():.0f}") + transition("soft_temp",health.get("soft_temp_limit_now",False),"SOFT_TEMP_LIMIT","SOFT_TEMP_RECOVERED",raw=health.get("raw")) + transition("root_ro",fs_readonly,"FILESYSTEM_RO","FILESYSTEM_RW") + for k in ("under_voltage_occurred","freq_capped_occurred","throttled_occurred","soft_temp_limit_occurred"): + if health.get(k) and k not in history_logged: + history_logged.add(k); log("WARN","HISTORY_FLAG",flag=k,raw=health.get("raw")) + agent["last_health_ok"]=now; last_health=now - c,t,m,d=latest - active=make_alerts(c,t,m,d) - new_alert_keys={alert_key(a) for a in active} - for a in active: - k=alert_key(a) - if k not in prev_alert_keys: - log("WARN","ALERT_ACTIVE",alert=a["title"],detail=a["l2"],severity=a["sev"]) - for k in prev_alert_keys-new_alert_keys: - log("INFO","ALERT_RECOVERED",alert=k) - prev_alert_keys=new_alert_keys + c,t,m,d=latest + active=make_alerts(c,t,m,d,sample_elapsed) + led_alert=max(active,key=lambda a:a["sev"],default=None) + new_alert_keys={alert_key(a) for a in active} + for a in active: + k=alert_key(a) + if k not in prev_alert_keys: + log("WARN","ALERT_ACTIVE",alert=a["title"],detail=a["l2"],severity=a["sev"]) + for k in prev_alert_keys-new_alert_keys: + log("INFO","ALERT_RECOVERED",alert=k) + prev_alert_keys=new_alert_keys - # 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: + # 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): - # End of cycle: capture a fresh snapshot. + # 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 - # 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. + # 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] - 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 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) - last_render=now + 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: - agent["loop_errors"]+=1; log("ERROR","LOOP_ERROR",count=agent["loop_errors"],error=repr(e)); time.sleep(1) - -log("INFO","STOP",reason="signal") -try: - rgb_off(); mcu(0x08,0x00) - oled_show([("",""),("",""),("",""),("","")]); cmd(0xAE) -except Exception as e: log("ERROR","SHUTDOWN_CLEANUP",error=repr(e)) -try: bus.close() -except Exception: pass + time.sleep(0.20) + except Exception as e: + agent["loop_errors"]+=1; log("ERROR","LOOP_ERROR",count=agent["loop_errors"],error=repr(e)); time.sleep(1) +finally: + cleanup() diff --git a/install.sh b/install.sh index c6ba75e..a32369c 100755 --- a/install.sh +++ b/install.sh @@ -2,10 +2,17 @@ set -euo pipefail cd "$(dirname "$0")" -echo "[1/7] 检查 Raspberry Pi I2C..." +for required in app/pigway_pi_control.py app/oled_font_5x7.bin config/pigway-pi-control.conf systemd/pigway-pi-control.service; do + if [ ! -f "$required" ]; then + echo "ERROR: required file missing: $required" >&2 + exit 1 + fi +done + +echo "[1/8] 检查 Raspberry Pi I2C..." [ -e /dev/i2c-1 ] || { echo "错误:/dev/i2c-1 不存在。先 sudo raspi-config -> Interface Options -> I2C -> Enable,然后重启。"; exit 1; } -echo "[2/7] 等待 APT/DPKG..." +echo "[2/8] 等待 APT/DPKG..." for i in $(seq 1 60); do if ! fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; then break; fi [ "$i" = 60 ] && { echo "APT 锁等待超时,请稍后重试。"; exit 1; } @@ -13,17 +20,17 @@ for i in $(seq 1 60); do sleep 5 done -echo "[3/7] 安装依赖..." -apt update -apt install -y python3 python3-pil python3-smbus2 i2c-tools +echo "[3/8] 安装依赖..." +apt-get update +apt-get install -y python3 python3-pil python3-smbus2 i2c-tools -echo "[4/7] 检查硬件地址..." +echo "[4/8] 检查硬件地址..." SCAN="$(i2cdetect -y 1)"; echo "$SCAN" echo "$SCAN" | grep -qi '3c' || echo "警告:未扫描到 OLED 0x3C" echo "$SCAN" | grep -qi '0d' || echo "警告:未扫描到控制 MCU 0x0D" -echo "[5/7] 安装程序与配置..." -if systemctl list-unit-files 2>/dev/null | grep -q '^temp-control.service'; then +echo "[5/8] 安装程序与配置..." +if systemctl list-unit-files 2>/dev/null | grep '^temp-control.service' >/dev/null; then echo "检测到旧 temp-control.service,正在停止并禁用..." systemctl disable --now temp-control.service 2>/dev/null || true fi @@ -33,47 +40,36 @@ install -m 0644 app/oled_font_5x7.bin /usr/local/share/pigway-pi-control/oled_fo install -m 0755 app/pigway_pi_control.py /usr/local/sbin/pigway-pi-control -NEW=/tmp/pigway-pi-control.conf.new -cp config/pigway-pi-control.conf "$NEW" +# Keep the complete existing configuration, including comments and custom sections. +# New options use runtime defaults until the user chooses to add them. if [ -f /etc/pigway-pi-control.conf ]; then cp /etc/pigway-pi-control.conf "/etc/pigway-pi-control.conf.bak.$(date +%Y%m%d-%H%M%S)" - # Preserve user's [services] and [processes] sections; adopt new defaults elsewhere. - python3 - "$NEW" /etc/pigway-pi-control.conf <<'PY' -import configparser,sys -newf,oldf=sys.argv[1:] -n=configparser.ConfigParser(); n.optionxform=str; n.read(newf) -o=configparser.ConfigParser(); o.optionxform=str; o.read(oldf) -for sec in ("services","processes"): - if o.has_section(sec): - if n.has_section(sec): n.remove_section(sec) - n.add_section(sec) - for k,v in o.items(sec): n.set(sec,k,v) -with open(newf,"w") as f:n.write(f) -PY +else + install -m 0644 config/pigway-pi-control.conf /etc/pigway-pi-control.conf fi -install -m 0644 "$NEW" /etc/pigway-pi-control.conf -rm -f "$NEW" install -m 0644 systemd/pigway-pi-control.service /etc/systemd/system/pigway-pi-control.service -echo "[6/7] 启用/重启服务..." +echo "[6/8] 启用/重启服务..." systemctl daemon-reload systemctl enable pigway-pi-control.service >/dev/null systemctl restart pigway-pi-control.service echo "[7/8] 安装后自检..." FAIL=0 -check() { if eval "$2"; then printf " %-30s PASS\n" "$1"; else printf " %-30s FAIL\n" "$1"; FAIL=1; fi; } -check "Version 3.3.0" "grep -q 'APP_VERSION=\"3.3.0\"' /usr/local/sbin/pigway-pi-control" -check "OLED physical 128x32" "grep -q 'W,H=128,32' /usr/local/sbin/pigway-pi-control" -check "OLED MUX 0x1F" "grep -q '0xA8,0x1F' /usr/local/sbin/pigway-pi-control" -check "OLED pages 0..3" "grep -q 'cmd(0x22);cmd(0);cmd(3)' /usr/local/sbin/pigway-pi-control" -check "UI CPU/TMP/DSK" "grep -q 'f\"TMP {temp:.1f}C\"' /usr/local/sbin/pigway-pi-control" -check "UI IP4/IP6" "grep -q 'ip_label+" "+ip' /usr/local/sbin/pigway-pi-control" -check "UI right column x=72" "grep -q \"draw_text_5x7(pix,72,y,right\\[row\\])\" /usr/local/sbin/pigway-pi-control" -check "Legacy service stopped" "! systemctl is-active --quiet temp-control.service" -check "Service running" "systemctl is-active --quiet pigway-pi-control.service" -check "P0 health monitor" "grep -q 'throttled_state' /usr/local/sbin/pigway-pi-control" -check "Diagnostic command" "grep -q -- '--diagnose' /usr/local/sbin/pigway-pi-control" +check() { local label="$1"; shift; if "$@"; then printf " %-30s PASS\n" "$label"; else printf " %-30s FAIL\n" "$label"; FAIL=1; fi; } +APP=/usr/local/sbin/pigway-pi-control +check "Version 3.3.0" grep -Fq 'APP_VERSION="3.3.0"' "$APP" +check "OLED physical 128x32" grep -Fq 'W,H=128,32' "$APP" +check "OLED MUX 0x1F" grep -Fq '0xA8,0x1F' "$APP" +check "OLED pages 0..3" grep -Fq 'cmd(0x22);cmd(0);cmd(3)' "$APP" +check "UI CPU/TMP/DSK" grep -Fq 'f"TMP {temp:.1f}C"' "$APP" +check "UI IP4/IP6" grep -Fq 'ip_label' "$APP" +check "UI right column x=72" grep -Fq 'draw_text_5x7(pix,72,y,right[row])' "$APP" +legacy_stopped() { ! systemctl is-active --quiet temp-control.service; } +check "Legacy service stopped" legacy_stopped +check "Service running" systemctl is-active --quiet pigway-pi-control.service +check "P0 health monitor" grep -Fq 'throttled_state' "$APP" +check "Diagnostic command" grep -Fq -- '--diagnose' "$APP" if [ "$FAIL" -ne 0 ]; then echo "错误:安装后自检未全部通过。"; exit 1; fi echo "[8/8] 状态..."