From 1e05ccd08d5786b245afab7dceabb0956f8d6985 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 20:24:15 +0800 Subject: [PATCH] feat: add standalone capability-based cooling plugin --- README.md | 45 ++++++ SHA256SUMS | 12 ++ app/drivers/yahboom.py | 127 ++++++++++++++++ app/migrate.py | 25 ++++ app/oled_font_5x7.bin | Bin 0 -> 1275 bytes app/runtime.py | 229 +++++++++++++++++++++++++++++ app/service.py | 117 +++++++++++++++ app/settings.py | 94 ++++++++++++ config/pigway-cooling-hat.conf | 101 +++++++++++++ docs/HARDWARE_PLUGIN_API.md | 78 ++++++++++ install.sh | 47 ++++++ systemd/pigway-cooling-hat.service | 17 +++ uninstall.sh | 7 + 13 files changed, 899 insertions(+) create mode 100644 README.md create mode 100644 SHA256SUMS create mode 100644 app/drivers/yahboom.py create mode 100644 app/migrate.py create mode 100644 app/oled_font_5x7.bin create mode 100644 app/runtime.py create mode 100644 app/service.py create mode 100644 app/settings.py create mode 100644 config/pigway-cooling-hat.conf create mode 100644 docs/HARDWARE_PLUGIN_API.md create mode 100755 install.sh create mode 100644 systemd/pigway-cooling-hat.service create mode 100755 uninstall.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..52ddb31 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# PIGWay Cooling HAT + +独立运行的本机硬件插件。当前驱动:Yahboom RGB Cooling HAT(MCU 0x0D、OLED 0x3C、128×32)。不需要安装 PIGWay Pi Control;也不会自动启动它。 + +## 安装与独立运行 + +```bash +sudo ./install.sh +# 只安装,不启动/启用新服务: +sudo ./install.sh --no-start +# 显式迁移旧集成版的硬件设置: +sudo ./install.sh --import-monitor-config /etc/pigway-pi-control.conf +``` + +启用 I2C 后安装;需要 python3-smbus2、python3-pil。安装器检查文件及总线占用,不停止其他进程。配置:`/etc/pigway-cooling-hat.conf`。 + +```bash +sudo systemctl start pigway-cooling-hat.service +sudo journalctl -u pigway-cooling-hat.service -f +sudo python3 /usr/local/lib/pigway-cooling-hat/service.py --check-config +curl --unix-socket /run/pigway-plugins/yahboom-cooling-hat/api.sock http://localhost/v1/descriptor +``` + +未接入监控时,OLED 显示独立运行与本机温度,风扇执行本地温控,RGB 采用正常灯效配置,默认全灭。 +温度传感器读取失败时请求风扇全速;停止插件时也请求全速并关闭 OLED/RGB,避免因退出显示程序而取消散热。硬件故障时不能保证执行成功,错误会记录日志。风扇无转速反馈,只报告设置档位,不伪造 RPM。 + +## 接入监控 + +监控 Web → 系统配置 → 硬件插件中发现并手动接入。协议是本机 Unix socket 上的 HTTP API,默认不开放 TCP 端口。目录 0750、socket 0660,只有 root 和被明确加入 `pigway-hardware` 组的账户能访问。不要随意授予组成员资格。 + +监控发送完整、带版本和有效期的状态;不发送动画帧或 MCU 指令。插件自己仲裁 OLED 页和 RGB 效果。告警页有页码并轮播主页,相同 RGB 效果不因切页重启。只有一个有效监控来源可以持有显示状态,重复/乱序版本会拒绝。状态超过 10 秒未更新(由客户端声明,允许3–30秒),旧告警失效,OLED 显示连接中断、RGB 请求关闭,风扇继续本地温控。主动断开则回独立运行。 + +详情:[API v1](docs/HARDWARE_PLUGIN_API.md)。未来其他厂商或 GPIO/PWM 风扇应实现相同能力接口及独立驱动,不复用本板 I2C 寄存器。板上 MCU 的唯一进程所有权通过固定的物理设备文件锁 `/run/lock/pigway-i2c-1-mcu-0d.lock` 保证(不随 API socket 路径变化);其他不遵守此锁的软件仍需自行避免运行。 + +## RGB 与已知限制 + +默认 MCU 内置呼吸:CPU红、电源黄、内存紫、存储白、网络蓝、服务青;严重等级使用速度档1/2/3,可配置。速度档不是精确秒数,预置色不支持任意 RGB 或橙色呼吸。相同 effect/speed/color 不重复下发,无周期性“预热”。 + +自定义颜色常亮和闪烁仍保留。闪烁是实验性兼容路径:全选→R→全选→G→全选→B,每条间隔5ms;亮灭均完整写三个通道,灭灯写 RGB=0;实际每段至少 `custom_min_hold_ms`(最低1500ms),从指令完成后计时,不追赶延迟边沿。既往实机曾出现异色/漏执行,未证明存在完全稳定的自定义协议,不宣称这些限制能修好 MCU。 + +OLED 与 RGB 各自一个线程,风扇有独立温控线程;访问同一总线时串行,完整 RGB 写入不能被风扇插入。风扇仅写0x08,不补写RGB。OLED字体逐字节从原项目迁入,未重新生成或替换。 + +## 卸载 + +`sudo ./uninstall.sh` 只移除此插件,保留配置,不修改监控项目或其他服务。先在监控 Web 断开插件可避免离线条目。 diff --git a/SHA256SUMS b/SHA256SUMS new file mode 100644 index 0000000..fa485bf --- /dev/null +++ b/SHA256SUMS @@ -0,0 +1,12 @@ +f335c4bce30fa88ee7a0a2748c997bd67388f691e3260db581e0707536fdbd60 README.md +fe61e7f12bfe36449a3a82969c8cdf3d79c2b5650cd292b5fc133fda0f6ed8d3 install.sh +cd6bb5137dd5922e48866f6daf4b949a16f8fad321e77addd5b4f75181bc4248 uninstall.sh +1c7836f80b0845fbc5c4b8570b12d736e2a3c442b1ceb5d35d48cdd518e4ad50 app/drivers/yahboom.py +215a62acc0a5c87303d6c52c0a2585b87dfb5cdbafbe9e9e733523d48a6a9812 app/migrate.py +72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin +93c8e57569d90cf34bc8844d8e7d951da0704189324eddcc65e4232487d362ce app/runtime.py +e5153007a3562dc48468c6cee74de55de35844fc1e0e4d0cf00feb696991e14f app/service.py +5b16bb49bca6d4a37730ff564143f0f4d5ee73ff391bd79c983c29bc92fdcf66 app/settings.py +743195ac2ca2b7656388ba0b461072fd91bad5492264a79c2466a40e839de36d config/pigway-cooling-hat.conf +c373cdde2b937bbc58d4b227f3a1d2923f0816c13f1e34548ed1f3b26c101ed5 systemd/pigway-cooling-hat.service +fa7ab991a7a39cce72b218e407dcefbcede99c8d8eb3d9c161a5296d506c41a9 docs/HARDWARE_PLUGIN_API.md diff --git a/app/drivers/yahboom.py b/app/drivers/yahboom.py new file mode 100644 index 0000000..dcd0eb5 --- /dev/null +++ b/app/drivers/yahboom.py @@ -0,0 +1,127 @@ +"""Optional Yahboom cooling HAT transport; importing it never opens I2C. + +Built-in effects avoid continuous host writes. Custom RGB remains experimental: +selecting all LEDs before each channel helped one slow calibration run, but did +not establish reliable operation across runs or prove any MCU timing guarantee. +""" +import time +import fcntl + + +class Yahboom: + def __init__(self, lock, on_error, font): + from smbus2 import SMBus + # Ownership belongs to the physical device, not to an API socket path. + self.ownership = open('/run/lock/pigway-i2c-1-mcu-0d.lock', 'a') + try: + fcntl.flock(self.ownership, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.bus = SMBus(1) + except Exception: + self.ownership.close() + raise + self.lock = lock + self.on_error = on_error + self.state = None + self.font = font + self.fan_level = None + + def write(self, method, *args): + with self.lock: + try: + return getattr(self.bus, method)(*args) + except OSError: + self.state = None + self.on_error() + raise + + def _mcu(self, register, value): + self.write("write_byte_data", 0x0D, register, value) + + def off(self): + with self.lock: + if self.state == ("off",): + return + self._mcu(0x07, 0) + self.state = ("off",) + + def effect(self, effect, speed, color, delay_ms=10): + state = ("effect", effect, speed, color) + with self.lock: + if self.state == state: + return + # Parameters first, then the documented effect selector. Never + # restart an unchanged effect when OLED pages or owners change. + self._mcu(0x05, speed) + time.sleep(delay_ms / 1000) + self._mcu(0x06, color) + time.sleep(delay_ms / 1000) + self._mcu(0x04, effect) + self.state = state + + def color(self, channels): + channels = tuple(channels) + if len(channels) != 3 or any(not 0 <= x <= 255 for x in channels): + raise ValueError("RGB channels must be 0..255") + state = ("color", channels) + with self.lock: + if self.state == state: + return + if self.state is None or self.state[0] != "color": + self._mcu(0x07, 0) + time.sleep(0.005) + # One complete RGB update, in fixed R/G/B order. No guessed block + # write, clear register, or MCU effect command on each flash edge. + for register, value in enumerate(channels, 1): + self._mcu(0x00, 0xFF) + time.sleep(0.005) + self._mcu(register, value) + time.sleep(0.005) + self.state = state + + def fan(self, level): + values = (0, 4, 6, 8, 9, 1) + with self.lock: + if level != self.fan_level: + self._mcu(0x08, values[level]) + self.fan_level = level + + def oled_init(self): + 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): + self.write("write_byte_data", 0x3C, 0, c) + + def oled(self, lines, home=False): + # Same 5x7 font and page layout as the verified 128x32 implementation. + from PIL import Image + im = Image.new("1", (128,32), 0) + pix = im.load() + def text(x,y,value): + for ch in value: + code=ord(ch) + if code*5+5>len(self.font): code=ord("?") + for cx in range(5): + col=self.font[code*5+cx] + for cy in range(7): + if col & (1<122: break + for row,(left,right) in enumerate(lines[:4]): + text(0,row*8,str(left)) + if right: text(72 if home else max(0,128-len(str(right))*6),row*8,str(right)) + data=[sum(pix[x,pg*8+bit]<l*O6n>-_`DmmLqY^L;uo9&(s>BRt)IgvNn!0l+gB0whF36^GP|Jyf4E0jD zLuHrZWo$PO=@6g=k|0@({(_=IfiC?IUAlSIV8lQnfPH76-|?RA-rf7&$HxZ&Ac98^ z9tFYSgTufKvcNR_+&9dJ(}$+H$M;Myd;^N}KhFy=Y_a8MMVJ@T#+SN5$qI73k0krnNZ#eL(dDtu!W|@y(CEtAh{|VZ{Pkr;Y3j; z5KbxTqwB!6R*2=qGG&-!l8I9h>&*?@HpH9@ABm8~R+N5d`;=OliTX^Ry3`L_$gp*K zdYXk#ldTX)7e5bthm&;)2zj#Kyw&upuU|i|tYPd43Q9-yG(Bc@l*{TVYPOQ}m|m}b zepKnDX*%j*woxxlk}m-U83TDR-2iEj(OKJW&t|RLtyz|&1M$9Dk}aw{?uhzzqA2Qg zhEe3MF;+iW+o0A2etGq3+QT>Mtz(teeY>#jD39+yyR$l9y;G>|_tqajdTh&_k{ss76G>)S; z8feHFq;PhY;=ocAfDK;&Hk4QuP-xmro8fkjzDqpDr_l8aWv=VXT1~^JhNm?VHp6&8 zmvD-$Ciuka3dPkH8y5-0wPk?0av@lj1>c1@xI`*}%d6G0{y+T%u26;utBD+<{pV=V zhaxo;q!)PQ>JXiUz)S#t@hVamHw3U>yfAZ|K+k2lT$Xbj2WiX>@W5ASZdEPelwZC( zfA`XKV#nM%l8(0A;r6!cMw7_>{mJg;dVpULa!HbMM<5KxVMup|A*JDPhhj3R^v`q5 pOUU6N=~lXSw|qmrf&UwZfl@RLTv|vH)Fi12D<3Uaz()v`@E=5ID%Ai0 literal 0 HcmV?d00001 diff --git a/app/runtime.py b/app/runtime.py new file mode 100644 index 0000000..db248c9 --- /dev/null +++ b/app/runtime.py @@ -0,0 +1,229 @@ +"""Local display arbitration and autonomous cooling, independent of the monitor.""" +import copy +import math +import threading +import time +from pathlib import Path +from settings import COLORS,EFFECTS,GROUPS + + +def log(event,**fields): + print('event='+event+' '+ ' '.join(f'{k}={str(v).replace(chr(32),"_")}' for k,v in fields.items()),flush=True) + + +def temperature(): + return float(Path('/sys/class/thermal/thermal_zone0/temp').read_text())/1000 + + +def finite(value,low,high): + if isinstance(value,bool) or not isinstance(value,(int,float)) or not math.isfinite(value) or not low<=value<=high: + raise ValueError('invalid numeric state') + return value + + +def validate_state(payload): + if payload.get('api_version')!=1:raise ValueError('unsupported API version') + for key in ('source','session'): + if not isinstance(payload.get(key),str) or not 1<=len(payload[key])<=128:raise ValueError('invalid '+key) + revision=payload.get('revision') + if type(revision) is not int or revision<1:raise ValueError('invalid revision') + ttl=finite(payload.get('ttl_seconds',10),3,30) + alerts=payload.get('alerts',[]) + if not isinstance(alerts,list) or len(alerts)>128:raise ValueError('too many alerts') + clean=[];ids=set() + for item in alerts: + if not isinstance(item,dict):raise ValueError('invalid alert') + a={} + for k in ('id','title','l2','l3','l4'): + v=item.get(k,'') + if not isinstance(v,str) or len(v)>128:raise ValueError('invalid alert text') + a[k]=v + if not a['id'] or a['id'] in ids:raise ValueError('duplicate or empty alert id') + ids.add(a['id']) + a['severity']=finite(item.get('severity'),1,3) + if int(a['severity'])!=a['severity']:raise ValueError('invalid severity') + a['priority']=finite(item.get('priority'),1,1000) + a['category']=item.get('category') + if a['category'] not in GROUPS:raise ValueError('invalid alert category') + clean.append(a) + system=payload.get('system',{});network=payload.get('network',{}) + if not isinstance(system,dict) or not isinstance(network,dict):raise ValueError('invalid home state') + system={k:finite(system.get(k,0),-50 if k=='temperature_c' else 0,150 if k=='temperature_c' else 100) + for k in ('cpu_percent','temperature_c','memory_percent','disk_percent')} + network={k:str(network.get(k,''))[:128] for k in ('kind','metric','ip_label','ip')} + return {"source":payload['source'],"session":payload['session'],"revision":revision,"ttl_seconds":ttl, + "alerts":clean,"system":system,"network":network} + + +class Controller: + def __init__(self,config,driver=None,clock=time.monotonic,temp_reader=temperature): + self.config=config;self.driver=driver;self.clock=clock;self.temp_reader=temp_reader + self.lock=threading.RLock();self.stop_event=threading.Event();self.threads=[] + self.state=None;self.expires=0;self.ever_connected=False;self.first_seen={} + self.owner='NORMAL_HOME';self.page='NORMAL_HOME';self.page_since=clock() + self.fan_level=0;self.temp=None;self.errors={};self.i2c_errors=0;self.rgb_mode='OFF' + + def accept(self,payload): + value=validate_state(payload);now=self.clock() + with self.lock: + old=self.state + if old and now1 and now-self.page_since>=self.config['oled']['page_seconds']: + self.page=pages[(pages.index(self.page)+1)%len(pages)];self.page_since=now + rgb=byid.get(self.page) or byid.get(owner) + return {'owner':owner,'page':self.page,'connected':connected,'alerts':alerts,'rgb':rgb, + 'system':data['system'],'network':data['network'],'config':self.config} + + def view(self): + with self.lock:return copy.deepcopy(self._view_locked()) + + def status(self): + v=self.view() + with self.lock: + return {'available':self.driver is not None,'connected':v['connected'],'owner':v['owner'], + 'oled_page':v['page'],'rgb_mode':self.rgb_mode,'fan_level':self.fan_level, + 'fan_name':('OFF','L1','L2','L3','L4','MAX')[self.fan_level] if self.driver else 'UNAVAILABLE', + 'temperature_c':self.temp,'errors':dict(self.errors),'i2c_errors':self.i2c_errors} + + def error(self,worker,exc): + with self.lock: + value=str(exc) + if self.errors.get(worker)!=value:log('HARDWARE_ERROR',worker=worker,error=value) + self.errors[worker]=value + + def recovered(self,worker): + with self.lock: + if self.errors.pop(worker,None) is not None:log('HARDWARE_RECOVERED',worker=worker) + + def start(self): + if self.driver is None:return + self.driver.off();self.driver.oled_init() + for name,work in (('fan',self.fan_loop),('oled',self.oled_loop),('rgb',self.rgb_loop)): + thread=threading.Thread(target=work,name=name,daemon=True);self.threads.append(thread);thread.start() + + def fan_loop(self): + initialized=False + while not self.stop_event.is_set(): + try: + temp=self.temp_reader() + if not math.isfinite(temp):raise ValueError('temperature unavailable') + self.recovered('temperature') + except Exception as exc: + self.error('temperature',exc);temp=None + with self.lock: + c=dict(self.config['fan']);old=self.fan_level + curve=[c[k] for k in ('start','level2','level3','level4','max')] + wanted=5 if temp is None else sum(temp>=x for x in curve) + if initialized and wanted18:ip=ip[:7]+'..'+ip[-7:] + metric=network.get('metric','');kind=network.get('kind','NET') + try:signal=max(0,min(100,round((float(metric)+100)*2))) + except ValueError:signal=None + lines=[(f"CPU {system['cpu_percent']:.1f}%",f"MEM {system['memory_percent']:.1f}%"), + (f"TMP {system['temperature_c']:.1f}C",'FAN '+('OFF','L1','L2','L3','L4','MAX')[self.fan_level]), + (f"DSK {system['disk_percent']:.1f}%",f'WIF {signal}%' if kind=='WIF' and signal is not None else f'{kind} {metric}'),(label+' '+ip,'')] + else: + a=next(a for a in v['alerts'] if a['id']==page);index=v['alerts'].index(a)+1 + lines=[(a['title'],f"{index}/{len(v['alerts'])}"),(a['l2'],''),(a['l3'],''),(a['l4'],'')] + state=(lines,home) + if state!=last or self.clock()-last_time>=v['config']['oled']['refresh_seconds']: + self.driver.oled(lines,home);last=state;last_time=self.clock() + self.recovered('oled') + except Exception as exc:self.error('oled',exc) + self.stop_event.wait(.1) + + @staticmethod + def profile(view): + c=view['config']['rgb'];a=view['rgb'] + if view['owner']=='DISCONNECTED':return ('off',),'DISCONNECTED_OFF' + if a: + group=a['category'];level=('warning','critical','emergency')[int(a['severity'])-1] + label=f"{group}_{level}_{c['alert_mode']}".upper() + if c['alert_mode']=='breathe':return ('effect',1,c[level+'_speed'],COLORS[GROUPS[group]],c['write_delay_ms']),label + mode='flash';prefix=level + else: + group='normal';prefix='normal';mode=c['normal_mode'];label='NORMAL_'+mode.upper() + if mode=='off':return ('off',),'OFF' + if mode in EFFECTS:return ('effect',EFFECTS[mode],c['normal_effect_speed'],COLORS[c['normal_effect_color']],c['write_delay_ms']),label + channels=tuple(c[group+'_'+k] for k in 'rgb') + if mode=='solid':return ('solid',channels),label + return ('flash',channels,max(c['custom_min_hold_ms'],c[prefix+'_flash_on_ms'])/1000, + max(c['custom_min_hold_ms'],c[prefix+'_flash_off_ms'])/1000),label + + def rgb_loop(self): + applied=None;next_edge=0;on=True + while not self.stop_event.is_set(): + try: + wanted,label=self.profile(self.view()) + if wanted!=applied: + if wanted[0]=='off':self.driver.off() + elif wanted[0]=='effect':self.driver.effect(*wanted[1:]) + else:self.driver.color(wanted[1]) + applied=wanted;on=True + if wanted[0]=='flash':next_edge=self.clock()+wanted[2] + log('RGB_MODE',old=self.rgb_mode,new=label) + self.rgb_mode=label + if applied[0]=='flash' and self.clock()>=next_edge: + self.driver.color((0,0,0) if on else applied[1]);on=not on + next_edge=self.clock()+applied[2 if on else 3] + self.recovered('rgb') + except Exception as exc: + self.error('rgb',exc) + # Do not spin writes at 20Hz on an I2C error. + self.stop_event.wait(1) + self.stop_event.wait(.05) + + def stop(self): + self.stop_event.set() + for thread in self.threads:thread.join() + if self.driver: + try:self.driver.fan(5) + except Exception as exc:self.error('shutdown_fan',exc) + for name,error in self.driver.close():self.error(name,error) diff --git a/app/service.py b/app/service.py new file mode 100644 index 0000000..ef8a7bb --- /dev/null +++ b/app/service.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Versioned HTTP API over a local Unix socket; no TCP listener.""" +import argparse +import fcntl +import json +import os +import signal +import socketserver +import threading +import time +from http.server import BaseHTTPRequestHandler +from pathlib import Path +from settings import descriptor,load,validated,check,save +from runtime import Controller,log + + +class Server(socketserver.ThreadingMixIn,socketserver.UnixStreamServer): + daemon_threads=True + + +class Handler(BaseHTTPRequestHandler): + def setup(self): + super().setup();self.connection.settimeout(3) + + def log_message(self,*args): + pass + + def send(self,code,value): + data=json.dumps(value,allow_nan=False).encode() + self.send_response(code);self.send_header('Content-Type','application/json') + self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data) + + def body(self): + size=int(self.headers.get('Content-Length','0')) + if not 0=b for a,b in zip(curve,curve[1:])):raise ValueError("fan thresholds must strictly increase") + +def save(path,config): + check(config) + parser=configparser.ConfigParser();parser.read_dict(config) + path=Path(path); temporary=path.with_suffix('.tmp') + with temporary.open('w') as f:parser.write(f) + temporary.chmod(0o640);temporary.replace(path) + +def descriptor(): + return {"api_version":1,"id":PLUGIN_ID,"name":"Yahboom RGB Cooling HAT","version":VERSION, + "capabilities":{ + "fan":{"control":"levels","levels":["OFF","L1","L2","L3","L4","MAX"],"rpm":False,"autonomous":True,"owner":"plugin"}, + "display":{"width":128,"height":32,"pages":True}, + "rgb":{"modes":list(dict.fromkeys(["off","breathe","flash","solid",*EFFECTS])),"preset_colors":list(COLORS),"custom_flash_experimental":True}}, + "notes":[{"zh":"内置呼吸使用预置颜色:CPU红、电源黄、内存紫、存储白、网络蓝、服务青。自定义闪烁仍可能异色。", + "en":"Built-in breathing uses presets: CPU red, power yellow, memory purple, storage white, network blue, services cyan. Custom flashing may still show incorrect colors."}, + {"zh":"自定义闪烁的实际亮灭保持时间不低于最小保持时间。停止插件会请求风扇全速;风扇没有RPM反馈。", + "en":"Custom flash phases last at least the minimum hold time. Stopping the plugin requests full fan speed; RPM feedback is unavailable."}], + "schema":SCHEMA} diff --git a/config/pigway-cooling-hat.conf b/config/pigway-cooling-hat.conf new file mode 100644 index 0000000..56b2e65 --- /dev/null +++ b/config/pigway-cooling-hat.conf @@ -0,0 +1,101 @@ +# Independent hardware plugin. / 独立硬件插件,不读取监控程序配置。 +# RGB flash is experimental; built-in breathing is the default. +# 自定义闪烁仍可能异色,实际每段至少 custom_min_hold_ms。 + +[fan] +# 启动温度 / start; 0..100 °C +start = 50 +# 二档温度 / level2; 0..100 °C +level2 = 53 +# 三档温度 / level3; 0..100 °C +level3 = 56 +# 四档温度 / level4; 0..100 °C +level4 = 59 +# 全速温度 / max; 0..100 °C +max = 62 +# 降档回差 / hysteresis; 0..20 °C +hysteresis = 1.5 + +[oled] +# 刷新周期 / Refresh interval; 0.2..10 s +refresh_seconds = 1 +# 轮播周期 / Page interval; 1..120 s +page_seconds = 10 + +[rgb] +# 告警模式 / Alert mode; breathe/flash +alert_mode = breathe +# 正常状态灯效 / Normal lighting; off/solid/flash/flow/breathe/marquee/rainbow/colorful +normal_mode = off +# 正常灯效颜色 / Normal effect color; red/green/blue/yellow/purple/cyan/white +normal_effect_color = green +# 正常灯效速度档 / Normal effect speed; 1..3 +normal_effect_speed = 2 +# 自定义闪烁最小保持 / Minimum custom flash hold; 1500..60000 ms +custom_min_hold_ms = 1500 +# 内置效果指令间隔 / Built-in effect command gap; 1..1000 ms +write_delay_ms = 10 +# 普通呼吸速度档 / Warning breathing speed; 1..3 +warning_speed = 1 +# 普通告警亮灯时间 / Warning flash on; 50..60000 ms +warning_flash_on_ms = 1500 +# 普通告警灭灯时间 / Warning flash off; 50..60000 ms +warning_flash_off_ms = 1500 +# 严重呼吸速度档 / Critical breathing speed; 1..3 +critical_speed = 2 +# 严重告警亮灯时间 / Critical flash on; 50..60000 ms +critical_flash_on_ms = 1500 +# 严重告警灭灯时间 / Critical flash off; 50..60000 ms +critical_flash_off_ms = 1500 +# 紧急呼吸速度档 / Emergency breathing speed; 1..3 +emergency_speed = 3 +# 紧急告警亮灯时间 / Emergency flash on; 50..60000 ms +emergency_flash_on_ms = 1500 +# 紧急告警灭灯时间 / Emergency flash off; 50..60000 ms +emergency_flash_off_ms = 1500 +# 正常闪烁亮灯时间 / Normal flash on; 50..60000 ms +normal_flash_on_ms = 1500 +# 正常闪烁灭灯时间 / Normal flash off; 50..60000 ms +normal_flash_off_ms = 1500 +# 处理器 · R / Cpu · R; 0..255 +cpu_r = 255 +# 处理器 · G / Cpu · G; 0..255 +cpu_g = 0 +# 处理器 · B / Cpu · B; 0..255 +cpu_b = 0 +# 电源 · R / Power · R; 0..255 +power_r = 255 +# 电源 · G / Power · G; 0..255 +power_g = 40 +# 电源 · B / Power · B; 0..255 +power_b = 0 +# 内存 · R / Memory · R; 0..255 +memory_r = 255 +# 内存 · G / Memory · G; 0..255 +memory_g = 0 +# 内存 · B / Memory · B; 0..255 +memory_b = 255 +# 存储 · R / Storage · R; 0..255 +storage_r = 255 +# 存储 · G / Storage · G; 0..255 +storage_g = 255 +# 存储 · B / Storage · B; 0..255 +storage_b = 255 +# 网络 · R / Network · R; 0..255 +network_r = 0 +# 网络 · G / Network · G; 0..255 +network_g = 0 +# 网络 · B / Network · B; 0..255 +network_b = 255 +# 服务 · R / Service · R; 0..255 +service_r = 0 +# 服务 · G / Service · G; 0..255 +service_g = 255 +# 服务 · B / Service · B; 0..255 +service_b = 96 +# 正常 · R / Normal · R; 0..255 +normal_r = 0 +# 正常 · G / Normal · G; 0..255 +normal_g = 0 +# 正常 · B / Normal · B; 0..255 +normal_b = 0 diff --git a/docs/HARDWARE_PLUGIN_API.md b/docs/HARDWARE_PLUGIN_API.md new file mode 100644 index 0000000..d0f9b89 --- /dev/null +++ b/docs/HARDWARE_PLUGIN_API.md @@ -0,0 +1,78 @@ +# Local hardware plugin API v1 + +The monitor and plugins are separate applications. The monitor has no hardware +library dependency. Plugins alone own their hardware. Installing or discovering +a plugin does not connect it or start another service. + +## Transport and authorization + +HTTP/1.0 or HTTP/1.1 over `/run/pigway-plugins//api.sock`. No TCP listener. +The parent directory is root-owned, mode 0750, and the socket is 0660 with group +`pigway-hardware`. Root and deliberately enrolled group members may control the +plugin. The monitor Web API authenticates mutations with its existing bearer +token before proxying local requests. Remote machine operations require that +machine's token too. API v1 clients reject incompatible descriptor versions. + +## Endpoints + +- `GET /v1/descriptor`: `api_version`, `id`, `name`, `version`, `capabilities`, `schema`. +- `GET /v1/status`: availability, connection state and capability-specific actual + or commanded state. A missing tachometer must never be presented as measured RPM. +- `GET /v1/config`: `config` plus `schema`. +- `PUT /v1/config`: `{"updates":{"section":{"key":"value"}}}`; validate the complete + candidate before persisting or applying anything. Does not restart the monitor. +- `PUT /v1/state`: replace the complete monitoring snapshot, never append commands. +- `DELETE /v1/state`: release a lease. Publisher passes `source` and `session`; + authorized local administration may use `{}` to detach the current source. + +Example snapshot: + +```json +{ + "api_version": 1, + "source": "raspberrypi", + "session": "a-new-uuid-for-each-monitor-process", + "revision": 1, + "ttl_seconds": 10, + "system": {"cpu_percent": 12, "temperature_c": 50, "memory_percent": 20, "disk_percent": 30}, + "network": {"kind": "WIF", "metric": "-60", "ip_label": "IP4", "ip": "192.168.1.2"}, + "alerts": [{"id":"SERVICE_DOWN|APP","category":"service","severity":2,"priority":60, + "title":"SERVICE DOWN","l2":"APP","l3":"CHECK SERVICE","l4":"CRITICAL"}] +} +``` + +Alert categories: `cpu`, `power`, `memory`, `storage`, `network`, `service`. +The producer sends only current alerts, never historical power flags. Empty +`alerts` clears all previous alerts. A source/session owns its lease until +release or expiry; a competing source and repeated/out-of-order revisions are +rejected. Leases use the receiving process's monotonic clock, 3–30 seconds. +A new monitor session retries until the old lease expires if the old process +could not release it. Each plugin has an independent bounded-time publisher and +one replaceable latest snapshot, so an offline plugin cannot queue stale frames +or block monitoring or other plugins. + +## Capability-driven UI + +`fan` declares levels or PWM, RPM availability, autonomous operation and ownership. +`display` declares dimensions and pages. `rgb` declares modes, preset colors and +experimental custom flashing. Absent capability means absent controls. A plugin +with only a fan does not imply a screen or lighting device. New hardware needs +its own driver; I2C, PWM GPIO and power-only fans are not interchangeable. + +`schema` groups fields by section. Each field provides bilingual `label`, `type` +(`number`/`enum`), numeric limits or choices, unit and optional visibility rules. +Optional `color_group` and `channel` (`r`/`g`/`b`) associate three numeric fields +with a color picker. Plugins remain authoritative for all validation. + +## Failures and independent cooling + +Monitoring continues without installed or reachable plugins. Hardware plugins +continue local temperature control without the monitor. Expired snapshots must +not keep showing stale alerts as live faults. The Yahboom implementation displays +MONITOR OFFLINE and requests RGB OFF; explicit detach returns to standalone mode. +Its thermal read failure and service shutdown request full fan speed as a cooling +fallback; actual execution still depends on functioning hardware. + +Stopping a monitor, pausing its link, or unlinking a plugin does not stop the +plugin service. No API/Web service has a systemd Wants dependency that starts the +other application. Do not run two drivers for the same MCU, GPIO or fan. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..1ba8dff --- /dev/null +++ b/install.sh @@ -0,0 +1,47 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" +NO_START=0 +IMPORT='' +while [ "$#" -gt 0 ]; do + case "$1" in + --no-start) NO_START=1; shift ;; + --import-monitor-config) [ "$#" -ge 2 ] || exit 2; IMPORT="$2"; shift 2 ;; + -h|--help) echo 'Usage: sudo ./install.sh [--no-start] [--import-monitor-config /etc/pigway-pi-control.conf]'; exit 0 ;; + *) echo "ERROR: unknown option: $1" >&2; exit 2 ;; + esac +done +for f in app/migrate.py app/service.py app/runtime.py app/settings.py app/drivers/yahboom.py app/oled_font_5x7.bin config/pigway-cooling-hat.conf systemd/pigway-cooling-hat.service; do + [ -f "$f" ] || { echo "ERROR: required file missing: $f" >&2; exit 1; } +done +[ "$(id -u)" -eq 0 ] || { echo 'ERROR: run as root' >&2; exit 1; } +[ -e /dev/i2c-1 ] || { echo 'ERROR: /dev/i2c-1 missing; configure I2C before installing this driver' >&2; exit 1; } +# Never stop unrelated I2C users. A previous plugin instance is allowed on upgrade. +owner="$(systemctl show -p MainPID --value pigway-cooling-hat.service 2>/dev/null || true)" +for pid in $(fuser /dev/i2c-1 2>/dev/null || true); do + [ "$pid" = "$owner" ] || { echo "ERROR: I2C already owned by PID $pid" >&2; exit 1; } +done +python3 -c 'import smbus2, PIL' 2>/dev/null || { apt-get update; apt-get install -y python3 python3-smbus2 python3-pil; } +# Validate a possible migration before changing installed files. +if [ -n "$IMPORT" ]; then PYTHONDONTWRITEBYTECODE=1 python3 app/migrate.py "$IMPORT" --check; fi +getent group pigway-hardware >/dev/null || groupadd --system pigway-hardware +mkdir -p /usr/local/lib/pigway-cooling-hat/drivers +install -m 0644 app/*.py app/oled_font_5x7.bin /usr/local/lib/pigway-cooling-hat/ +install -m 0644 app/drivers/*.py /usr/local/lib/pigway-cooling-hat/drivers/ +if [ ! -f /etc/pigway-cooling-hat.conf ]; then + install -m 0640 -g pigway-hardware config/pigway-cooling-hat.conf /etc/pigway-cooling-hat.conf +fi +if [ -n "$IMPORT" ]; then + cp /etc/pigway-cooling-hat.conf "/etc/pigway-cooling-hat.conf.bak.$(date +%Y%m%d-%H%M%S)" + PYTHONDONTWRITEBYTECODE=1 python3 app/migrate.py "$IMPORT" --output /etc/pigway-cooling-hat.conf +fi +install -m 0644 systemd/pigway-cooling-hat.service /etc/systemd/system/ +systemctl daemon-reload +if [ "$NO_START" -eq 0 ]; then + systemctl enable pigway-cooling-hat.service >/dev/null + systemctl restart pigway-cooling-hat.service + systemctl is-active --quiet pigway-cooling-hat.service +fi +echo 'Hardware plugin installed independently. No monitor service was changed.' +echo 'Start if needed: sudo systemctl start pigway-cooling-hat.service' +echo 'Discover and connect from the monitor Web hardware plugins page.' diff --git a/systemd/pigway-cooling-hat.service b/systemd/pigway-cooling-hat.service new file mode 100644 index 0000000..d289d59 --- /dev/null +++ b/systemd/pigway-cooling-hat.service @@ -0,0 +1,17 @@ +[Unit] +Description=PIGWay optional Yahboom cooling HAT plugin +After=local-fs.target systemd-modules-load.service + +[Service] +Type=simple +User=root +Group=pigway-hardware +ExecStart=/usr/bin/python3 /usr/local/lib/pigway-cooling-hat/service.py +Restart=on-failure +RestartSec=5 +RuntimeDirectory=pigway-plugins/yahboom-cooling-hat +RuntimeDirectoryMode=0750 +UMask=0027 + +[Install] +WantedBy=multi-user.target diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..c35f769 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail +systemctl disable --now pigway-cooling-hat.service || true +rm -f /etc/systemd/system/pigway-cooling-hat.service +rm -rf /usr/local/lib/pigway-cooling-hat +systemctl daemon-reload +echo 'Plugin removed; config preserved. Monitor installation is unchanged.'