From 249af5a3e3c419a00ebc6d03669e29f6c33959c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 27 Sep 2026 09:25:19 +0800 Subject: [PATCH] fix: use calibrated MCU RGB effects --- README.md | 8 +- SHA256SUMS | 6 +- app/pigway_pi_control.py | 133 +++++++++++++++++++++------------- config/pigway-pi-control.conf | 41 ++++------- 4 files changed, 106 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index b1c39eb..d600781 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ v3.3.0 的目标不是增加 Web/API,而是先把 **P0 本地检测、事件 - Wi-Fi RSSI / Ethernet 链路速率 - 当前默认路由与 IP 同步切换 - 风扇自动温控、回差防抖 -- RGB:颜色表示故障对象,闪烁速度表示严重程度 +- RGB:内置灯效的颜色与速度表示严重程度 - systemd 服务与普通进程监控 - Raspberry Pi PWR / ACT 运行时 Dark Mode @@ -69,11 +69,11 @@ CHECK STORAGE ### 4. DisplayController、OLED 与 RGB -每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request,同一轮采样作为一个原子批次,全部更新后只仲裁一次。DisplayController 始终只选一个 owner,OLED 和 RGB 同步表达该 owner;`NORMAL_HOME` 是 priority 0 的常驻 request,有任何故障 owner 时不会抢回显示权。同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。历史 power flags 只进入 diagnose 和一次性 Journal 记录。 +每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request,同一轮采样作为一个原子批次,全部更新后只仲裁一次。DisplayController 始终只选一个 owner;同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。OLED 在主页和全部当前告警页之间轮播,告警页右上角显示 `当前页/告警总数`,新 owner 会立即抢占一次 OLED。OLED 页面游标不参与 RGB 决策,因此切回主页或其他告警页不会关闭、重启或改变灯效。历史 power flags 只进入 diagnose 和一次性 Journal 记录。 ### 5. RGB 告警引擎 -RGB 模式的唯一来源是 current Display Owner 的 severity。Warning 为黄色 3 秒呼吸,Critical 为橙红色 1.5 秒呼吸,Emergency 为纯红快速频闪;`NORMAL_HOME` 为 owner 时 OFF。呼吸最低亮度为 5%,默认每 80ms 更新。owner 变化但 RGB mode 相同时不关闭、不重置相位、不重写初始化序列。动画帧不写日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 +RGB 模式的唯一来源是 current Display Owner 的 severity。Warning 使用 MCU 内置黄色慢呼吸,Critical 使用 MCU 内置红色快呼吸,Emergency 为纯红快速频闪;`NORMAL_HOME` 为 owner 时 OFF。仅在 RGB mode 实际变化时按 `speed → color → effect` 写入。呼吸动画完全由 MCU 生成,不再使用软件 PWM 或逐帧 R/G/B 写入。owner 变化但 RGB mode 相同时不关闭、不重启灯效。动画不写日志,owner 变化记录 `DISPLAY_OWNER`,实际模式变化才记录 `RGB_MODE`。 ### 6. 结构化事件日志 @@ -122,7 +122,7 @@ sudo /usr/local/sbin/pigway-pi-control --diagnose 诊断使用与运行时相同的配置和检查函数;仅以 I²C receive-byte 读取探测 MCU 0x0D / OLED 0x3C,失败显示 MISSING/ERROR,不初始化或写入风扇、RGB、OLED。独立诊断进程无法获取正在运行的服务的内存计数,故 loop_errors / i2c_errors 明确标记 UNAVAILABLE;服务退出的 STOP 事件包含该次运行计数。 -RGB 保留 v3.2.0 已验证的 MCU 寄存器协议与 R/G/B 写入顺序。RC5 只在实际 RGB mode 切换时停止旧模式,动画帧仅完整写 selector/R/G/B,避免每帧反复触发 OFF 寄存器。 +RGB 保留卖家 MCU 0x0D 寄存器协议。实机校准确认呼吸应使用 MCU 内置效果,模式切换间隔默认 10ms,并由正常 OLED 刷新保持共享总线活动。旧版 `brightness`、软件呼吸周期、更新频率和 R/G/B 配置继续允许保留在用户配置中,但硬件灯效引擎会忽略它们。 ### 8. 关机收尾 diff --git a/SHA256SUMS b/SHA256SUMS index 0cb430c..18c8a71 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,7 +1,7 @@ -72e5eabe3093fde436d9bbef417660ada81a52164f9697486dcbd73f5322c205 README.md +e197d388988c8fa69adc1995de66d0dafc63d8ebf24326ad2e59743c201315d1 README.md 72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin -319ee4dbeed007df5ff3ce60f5a4b24b950f797f595848917542862bcbcbbd7d app/pigway_pi_control.py -dfb1d083c9d64a5e0d6ccfa60a4ef5f71e83ea137ebc53b169a69f6b1458883f config/pigway-pi-control.conf +e530efb5a08b0413836bb91dd08651cfe724b1cca616794cabd5b69ae7ec83cd app/pigway_pi_control.py +5c39712ddca8a9846e7cde81b91d3af2399a326c1fe437c66050d140b3f37fb6 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 e9a5b07..922ed6f 100755 --- a/app/pigway_pi_control.py +++ b/app/pigway_pi_control.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -import time,socket,subprocess,configparser,signal,re,errno,threading,argparse,math +import time,socket,subprocess,configparser,signal,re,errno,threading,argparse from pathlib import Path APP_VERSION="3.3.0" from smbus2 import SMBus @@ -121,19 +121,21 @@ def rgb_off(): mcu(0x07,0x00) # seller closeRGB(): true OFF _last_rgb=("off",) -def rgb_begin_mode(): +def rgb_mode(mode): global _last_rgb - mcu(0x07,0x00) # stop a previous mode once, never once per animation frame - _last_rgb=None - -def rgb_frame(r,g,b): - global _last_rgb - scale=max(0,min(100,LED_BRIGHT))/100.0 - state=(int(r*scale),int(g*scale),int(b*scale)) - if _last_rgb==state: return - mcu(0x00,0xff) # seller protocol: >=3 => all three LEDs - mcu(0x01,state[0]); mcu(0x02,state[1]); mcu(0x03,state[2]) - _last_rgb=state + settings={ + "WARNING_BREATHE":(RGB_WARNING_SPEED,RGB_WARNING_COLOR,0x01), + "CRITICAL_BREATHE":(RGB_CRITICAL_SPEED,RGB_CRITICAL_COLOR,0x01), + } + state=settings.get(mode) + if state is None: raise ValueError(f"unsupported RGB mode: {mode}") + if _last_rgb==(mode,): return + speed,color,effect=state + # Hardware-calibrated MCU order. The final effect write commits the mode. + mcu(0x05,speed); time.sleep(RGB_WRITE_DELAY_MS/1000.0) + mcu(0x06,color); time.sleep(RGB_WRITE_DELAY_MS/1000.0) + mcu(0x04,effect) + _last_rgb=(mode,) def set_pi_led(name,off=True): base=Path("/sys/class/leds")/name @@ -262,14 +264,12 @@ 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")) -LED_BRIGHT=cf("led","brightness",10,int) -WARNING_BREATHE_SECONDS=cf("led","warning_breathe_seconds",3.0) -CRITICAL_BREATHE_SECONDS=cf("led","critical_breathe_seconds",1.5) EMERGENCY_FLASH_MS=cf("led","emergency_flash_ms",300,int) -RGB_UPDATE_MS=cf("led","rgb_update_ms",80,int) -RGB_WARNING=(cf("led","warning_r",255,int),cf("led","warning_g",255,int),cf("led","warning_b",0,int)) -RGB_CRITICAL=(cf("led","critical_r",255,int),cf("led","critical_g",72,int),cf("led","critical_b",0,int)) -RGB_EMERGENCY=(cf("led","emergency_r",255,int),cf("led","emergency_g",0,int),cf("led","emergency_b",0,int)) +RGB_WRITE_DELAY_MS=max(1,cf("led","write_delay_ms",10,int)) +RGB_WARNING_SPEED=max(1,min(3,cf("led","warning_speed",1,int))) +RGB_WARNING_COLOR=max(0,min(6,cf("led","warning_color",3,int))) +RGB_CRITICAL_SPEED=max(1,min(3,cf("led","critical_speed",3,int))) +RGB_CRITICAL_COLOR=max(0,min(6,cf("led","critical_color",0,int))) def check_watch(w): typ,label,target=w @@ -419,22 +419,18 @@ def rgb_mode_for(a): if a["sev"]>=2: return "CRITICAL_BREATHE" return "WARNING_BREATHE" -def breathe(level_seconds,elapsed): - # A full sine-derived ease in/out cycle gives continuous light transitions. - phase=(elapsed/max(0.1,level_seconds))%1.0 - return 0.05+0.95*(0.5-0.5*math.cos(2.0*math.pi*phase)) - -def rgb_engine(mode,elapsed): - if mode=="WARNING_BREATHE": - level=breathe(WARNING_BREATHE_SECONDS,elapsed) - rgb_frame(*(channel*level for channel in RGB_WARNING)) - elif mode=="CRITICAL_BREATHE": - level=breathe(CRITICAL_BREATHE_SECONDS,elapsed) - rgb_frame(*(channel*level for channel in RGB_CRITICAL)) +def rgb_emergency(on): + global _last_rgb + state=("EMERGENCY_FLASH",bool(on)) + if _last_rgb==state: return + if not on: + mcu(0x07,0x00) else: - on=int(elapsed*1000/max(50,EMERGENCY_FLASH_MS))%2==0 - rgb_frame(RGB_EMERGENCY[0] if on else 0,0,0) - + mcu(0x00,0xff); time.sleep(RGB_WRITE_DELAY_MS/1000.0) + mcu(0x01,0xff); time.sleep(RGB_WRITE_DELAY_MS/1000.0) + mcu(0x02,0x00); time.sleep(RGB_WRITE_DELAY_MS/1000.0) + mcu(0x03,0x00) + _last_rgb=state def render_home(cpu,temp,mem,disk): # 128x32 fixed columns. Both columns are LEFT-aligned. @@ -470,8 +466,8 @@ def render_home(cpu,temp,mem,disk): bus_write("write_i2c_block_data",OLED,0x40,data[i:i+16]) -def render_alert(a): - oled_show([(a["title"],""), +def render_alert(a,index,total): + oled_show([(a["title"],f"{index}/{total}"), (a["l2"],""),(a["l3"],""),(a["l4"],"")]) @@ -488,12 +484,14 @@ class DisplayController: 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.rgb_thread=threading.Thread(target=self._rgb_worker,name="rgb",daemon=True) def start(self): - oled_init(); rgb_off(); self.refresh(); self.rgb_thread.start() + oled_init(); self.refresh(); rgb_off(); self.rgb_thread.start() def update_home(self,cpu,temp,mem,disk): with self.lock: @@ -526,7 +524,10 @@ class DisplayController: 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.batch_depth==0: + self._arbitrate_locked() + if self.current_oled_page_id not in self.requests: + self._show_page_locked(self.current_owner_id) def begin_update(self): with self.lock: self.batch_depth+=1 @@ -535,7 +536,10 @@ class DisplayController: 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.batch_depth==0: + self._arbitrate_locked() + if self.current_oled_page_id not in self.requests: + self._show_page_locked(self.current_owner_id) def _arbitrate_locked(self): current=self.requests.get(self.current_owner_id) @@ -552,14 +556,39 @@ class DisplayController: 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._render_locked(request) + self._show_page_locked(request["id"]) - def _render_locked(self,request): - if request["id"]=="NORMAL_HOME": render_home(*request["metadata"]["home"]) - else: render_alert(request) + 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 _show_page_locked(self,page_id): + alerts=self._ordered_alerts_locked() + request=self.requests.get(page_id,self.requests["NORMAL_HOME"]) + self.current_oled_page_id=request["id"] + self.page_started=time.monotonic() + if request["id"]=="NORMAL_HOME": + render_home(*request["metadata"]["home"]) + else: + alert_ids=[a["id"] for a in alerts] + render_alert(request,alert_ids.index(request["id"])+1,len(alerts)) def refresh(self): - with self.lock: self._render_locked(self.requests[self.current_owner_id]) + 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._show_page_locked(self.current_owner_id) + return + if len(pages)>1 and time.monotonic()-self.page_started>=PAGE_INTERVAL: + pos=pages.index(self.current_oled_page_id) + self._show_page_locked(pages[(pos+1)%len(pages)]) + return + request=self.requests[self.current_oled_page_id] + if request["id"]=="NORMAL_HOME": render_home(*request["metadata"]["home"]) + else: + alert_ids=[a["id"] for a in alerts] + 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]) @@ -572,17 +601,21 @@ class DisplayController: if wanted_mode!=applied_mode: with bus_lock: if wanted_mode=="OFF": rgb_off() - else: rgb_begin_mode() + elif wanted_mode=="EMERGENCY_FLASH": rgb_emergency(True) + else: rgb_mode(wanted_mode) log("INFO","RGB_MODE",old=applied_mode,new=wanted_mode) - applied_mode=wanted_mode - if applied_mode!="OFF": - with bus_lock: rgb_engine(applied_mode,time.monotonic()-animation_started) + applied_mode=wanted_mode; animation_started=time.monotonic() + if applied_mode=="EMERGENCY_FLASH": + elapsed=time.monotonic()-animation_started + on=int(elapsed*1000/max(50,EMERGENCY_FLASH_MS))%2==0 + with bus_lock: rgb_emergency(on) 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) - self.stop_event.wait(max(0.04,min(0.5,RGB_UPDATE_MS/1000.0))) + delay=0.04 if applied_mode=="EMERGENCY_FLASH" else 0.25 + self.stop_event.wait(delay) def shutdown(self): self.stop_event.set() diff --git a/config/pigway-pi-control.conf b/config/pigway-pi-control.conf index 6fc4496..9b43e61 100644 --- a/config/pigway-pi-control.conf +++ b/config/pigway-pi-control.conf @@ -36,8 +36,8 @@ sensor_interval = 1 # 推荐:1。 oled_refresh_interval = 1 -# 为兼容 v3.2.x/v3.3.0 RC4 旧配置保留。 -# RC5 使用单一 Display Owner,不再定时轮播 HOME/告警页,因此该值不参与运行时仲裁。 +# OLED 在主页和当前告警页之间轮播的周期,单位:秒。 +# 页面切换只影响 OLED;RGB 始终由当前 Display Owner 决定。 page_interval = 10 # 网络状态检查周期,单位:秒。 @@ -139,34 +139,25 @@ wifi_weak_seconds = 30 [led] -# OLED 和 RGB 由同一 Display Owner 同步决定。 -# DisplayController 按集中定义的 priority 仲裁 owner,RGB 再使用该 owner 的 severity: +# DisplayController 按集中定义的 priority 仲裁 owner,RGB 使用该 owner 的 severity。 +# OLED 独立轮播主页与全部当前告警页;切页不会关闭、重启或改变 RGB: # Warning (1) = 黄色慢呼吸 -# Critical (2) = 橙红色快呼吸 +# Critical (2) = 红色快呼吸 # Emergency (3) = 纯红快速频闪 -# NORMAL_HOME 为 owner 时 RGB OFF。每次写入明确设置 R/G/B,绝不沿用旧颜色。 +# NORMAL_HOME 为 owner 时 RGB OFF。 # -# brightness 是 RGB 最大输出百分比(0~100)。 -# 3颗 RGB 会一起亮,卧室建议 5~15。 -brightness = 10 - -# 旧 warning_on_ms / warning_off_ms / critical_on_ms / critical_off_ms / -# emergency_on_ms / emergency_off_ms 仍可保留,RC3 忽略它们以兼容旧配置。 -warning_breathe_seconds = 3.0 -critical_breathe_seconds = 1.5 +# 该旧版 MCU 由内置灯效产生呼吸。仅在模式变化时按 +# speed -> color -> effect 写入,禁止软件 PWM 和循环帧日志。 +# 卖家协议:speed 1=慢、2=中、3=快;color 0=红、3=黄。 +warning_speed = 1 +warning_color = 3 +critical_speed = 3 +critical_color = 0 +write_delay_ms = 10 emergency_flash_ms = 300 -rgb_update_ms = 80 -# RGB 基色(0~255)。安全默认值严格不含蓝色,避免紫色/蓝色残留。 -warning_r = 255 -warning_g = 255 -warning_b = 0 -critical_r = 255 -critical_g = 72 -critical_b = 0 -emergency_r = 255 -emergency_g = 0 -emergency_b = 0 +# v3.3.0 之前的 brightness、*_breathe_seconds、rgb_update_ms 和 *_r/g/b +# 可以继续留在现有配置中;硬件灯效引擎会安全忽略它们。 [dark_mode]