fix: use calibrated MCU RGB effects
This commit is contained in:
@@ -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. 关机收尾
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+83
-50
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user