feat: add standalone capability-based cooling plugin
This commit is contained in:
@@ -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 断开插件可避免离线条目。
|
||||||
+12
@@ -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
|
||||||
@@ -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<<cy) and 0<=x+cx<128 and 0<=y+cy<32:
|
||||||
|
pix[x+cx,y+cy]=1
|
||||||
|
x+=6
|
||||||
|
if x>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]<<bit for bit in range(8)) for pg in range(4) for x in range(128)]
|
||||||
|
with self.lock:
|
||||||
|
for c in (0x21,0,127,0x22,0,3): self.write("write_byte_data",0x3C,0,c)
|
||||||
|
for i in range(0,len(data),16): self.write("write_i2c_block_data",0x3C,0x40,data[i:i+16])
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
errors=[]
|
||||||
|
# No fan OFF on shutdown: keep the last cooling level rather than
|
||||||
|
# removing cooling merely because the display service is stopping.
|
||||||
|
for name,action in (("rgb",self.off),("oled_clear",lambda:self.oled([])),
|
||||||
|
("oled_off",lambda:self.write("write_byte_data",0x3C,0,0xAE)),
|
||||||
|
("bus",self.bus.close),("ownership",self.ownership.close)):
|
||||||
|
try: action()
|
||||||
|
except Exception as exc: errors.append((name,str(exc)))
|
||||||
|
return errors
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Explicit one-time migration; runtime never reads the monitor configuration."""
|
||||||
|
import argparse
|
||||||
|
import configparser
|
||||||
|
from pathlib import Path
|
||||||
|
from settings import SCHEMA,load,validated,check,save
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(source):
|
||||||
|
c=configparser.ConfigParser()
|
||||||
|
if not c.read(source):raise ValueError('monitor config missing')
|
||||||
|
result={s:{k:v['default'] for k,v in spec.items()} for s,spec in SCHEMA.items()}
|
||||||
|
for section,old in (('fan','fan'),('rgb','led')):
|
||||||
|
if c.has_section(old):
|
||||||
|
values={k:v for k,v in c.items(old) if k in SCHEMA[section]}
|
||||||
|
result[section].update(validated({section:values})[section])
|
||||||
|
for old,key in (('oled_refresh_interval','refresh_seconds'),('page_interval','page_seconds')):
|
||||||
|
if c.has_option('timing',old):result['oled'].update(validated({'oled':{key:c.get('timing',old)}})['oled'])
|
||||||
|
check(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
p=argparse.ArgumentParser();p.add_argument('source');p.add_argument('--check',action='store_true');p.add_argument('--output');a=p.parse_args()
|
||||||
|
result=migrate(a.source)
|
||||||
|
if a.output:save(a.output,result)
|
||||||
|
else:print('migration valid; no files changed')
|
||||||
Binary file not shown.
+229
@@ -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 now<self.expires:
|
||||||
|
if (old['source'],old['session'])!=(value['source'],value['session']):raise ValueError('another source holds the display lease')
|
||||||
|
if value['revision']<=old['revision']:raise ValueError('stale revision')
|
||||||
|
ids={a['id'] for a in value['alerts']}
|
||||||
|
self.first_seen={i:self.first_seen.get(i,now) for i in ids}
|
||||||
|
self.state=value;self.expires=now+value['ttl_seconds'];self.ever_connected=True
|
||||||
|
self._view_locked()
|
||||||
|
|
||||||
|
def release(self,source=None,session=None):
|
||||||
|
with self.lock:
|
||||||
|
if source is not None and self.state and (source,session)!=(self.state['source'],self.state['session']):
|
||||||
|
raise ValueError('display lease belongs to another source')
|
||||||
|
self.state=None;self.expires=0;self.first_seen={};self.ever_connected=False
|
||||||
|
self._view_locked()
|
||||||
|
|
||||||
|
def _view_locked(self):
|
||||||
|
now=self.clock();connected=self.state is not None and now<self.expires
|
||||||
|
data=self.state if connected else {'alerts':[],'system':{},'network':{}}
|
||||||
|
alerts=sorted(data['alerts'],key=lambda a:(-a['priority'],self.first_seen.get(a['id'],now),a['id']))
|
||||||
|
byid={a['id']:a for a in alerts};owner=alerts[0]['id'] if alerts else 'NORMAL_HOME'
|
||||||
|
if self.owner in byid and byid[self.owner]['priority']==byid[owner]['priority']:owner=self.owner
|
||||||
|
if not connected and self.ever_connected:owner='DISCONNECTED'
|
||||||
|
if owner!=self.owner:
|
||||||
|
log('DISPLAY_OWNER',old=self.owner,new=owner)
|
||||||
|
self.owner=owner;self.page=owner;self.page_since=now
|
||||||
|
pages=['NORMAL_HOME']+[a['id'] for a in alerts] if connected else [owner]
|
||||||
|
if self.page not in pages:self.page=owner;self.page_since=now
|
||||||
|
if len(pages)>1 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 wanted<old:
|
||||||
|
wanted=old-1 if temp<curve[max(0,old-1)]-c['hysteresis'] else old
|
||||||
|
try:
|
||||||
|
self.driver.fan(wanted)
|
||||||
|
with self.lock:self.fan_level=wanted;self.temp=temp
|
||||||
|
if not initialized or wanted!=old:log('FAN_LEVEL',old=old,new=wanted)
|
||||||
|
initialized=True;self.recovered('fan')
|
||||||
|
except Exception as exc:self.error('fan',exc)
|
||||||
|
self.stop_event.wait(1)
|
||||||
|
|
||||||
|
def oled_loop(self):
|
||||||
|
last=None;last_time=0
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
try:
|
||||||
|
v=self.view();page=v['page'];system=v['system'];network=v['network'];home=False
|
||||||
|
if page=='DISCONNECTED':lines=[('MONITOR OFFLINE',''),('STATE EXPIRED',''),('LOCAL FAN ACTIVE',''),('CHECK MONITOR','')]
|
||||||
|
elif page=='NORMAL_HOME':
|
||||||
|
home=True
|
||||||
|
if not v['connected']:
|
||||||
|
lines=[('STANDALONE',''),(f"TMP {self.temp:.1f}C" if self.temp is not None else 'TMP UNKNOWN',''),('FAN LOCAL',''),('NO MONITOR','')]
|
||||||
|
else:
|
||||||
|
ip=network.get('ip','NO IP');label=network.get('ip_label','IP4')
|
||||||
|
if label=='IP6' and len(ip)>18: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)
|
||||||
+117
@@ -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<size<=65536:raise ValueError('body must be 1..65536 bytes')
|
||||||
|
data=json.loads(self.rfile.read(size))
|
||||||
|
if not isinstance(data,dict):raise ValueError('JSON object required')
|
||||||
|
return data
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
c=self.server.controller
|
||||||
|
if self.path=='/v1/descriptor':self.send(200,descriptor())
|
||||||
|
elif self.path=='/v1/status':self.send(200,c.status())
|
||||||
|
elif self.path=='/v1/config':
|
||||||
|
with c.lock:self.send(200,{'config':c.config,'schema':descriptor()['schema'],'notes':descriptor()['notes']})
|
||||||
|
else:self.send(404,{'error':'not found'})
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
try:
|
||||||
|
body=self.body();c=self.server.controller
|
||||||
|
if self.path=='/v1/state':
|
||||||
|
c.accept(body);self.send(200,{'ok':True,'revision':body['revision']})
|
||||||
|
elif self.path=='/v1/config':
|
||||||
|
updates=validated(body.get('updates',{}))
|
||||||
|
if not updates:raise ValueError('no configuration updates')
|
||||||
|
with c.lock:
|
||||||
|
value={s:dict(v) for s,v in c.config.items()}
|
||||||
|
for section,fields in updates.items():value[section].update(fields)
|
||||||
|
check(value)
|
||||||
|
path=self.server.config_path
|
||||||
|
if path.exists():
|
||||||
|
backup=path.with_name(path.name+'.bak.'+str(time.time_ns()))
|
||||||
|
backup.write_bytes(path.read_bytes());backup.chmod(0o600)
|
||||||
|
save(path,value);c.config=value
|
||||||
|
log('CONFIG_UPDATED',sections=','.join(updates));self.send(200,{'ok':True})
|
||||||
|
else:self.send(404,{'error':'not found'})
|
||||||
|
except (ValueError,TypeError,KeyError) as exc:self.send(400,{'error':str(exc)})
|
||||||
|
except Exception as exc:self.send(500,{'error':str(exc)})
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
try:
|
||||||
|
if self.path!='/v1/state':self.send(404,{'error':'not found'});return
|
||||||
|
body=self.body()
|
||||||
|
self.server.controller.release(body.get('source'),body.get('session'))
|
||||||
|
self.send(200,{'ok':True})
|
||||||
|
except ValueError as exc:self.send(409,{'error':str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser=argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--config',default='/etc/pigway-cooling-hat.conf')
|
||||||
|
parser.add_argument('--socket',default='/run/pigway-plugins/yahboom-cooling-hat/api.sock')
|
||||||
|
parser.add_argument('--describe',action='store_true')
|
||||||
|
parser.add_argument('--check-config',action='store_true')
|
||||||
|
args=parser.parse_args()
|
||||||
|
if args.describe:print(json.dumps(descriptor(),ensure_ascii=False));return
|
||||||
|
config=load(args.config)
|
||||||
|
if args.check_config:print('configuration OK');return
|
||||||
|
path=Path(args.socket);path.parent.mkdir(parents=True,exist_ok=True,mode=0o750)
|
||||||
|
# Retain the fd for the lifetime of the daemon; prevents a second writer.
|
||||||
|
with (path.parent/'owner.lock').open('w') as ownership:
|
||||||
|
fcntl.flock(ownership,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
||||||
|
controller=Controller(config)
|
||||||
|
try:
|
||||||
|
from drivers.yahboom import Yahboom
|
||||||
|
font=(Path(__file__).parent/'oled_font_5x7.bin').read_bytes()
|
||||||
|
if len(font)!=1275:raise ValueError('invalid font length')
|
||||||
|
def error():
|
||||||
|
with controller.lock:controller.i2c_errors+=1
|
||||||
|
controller.driver=Yahboom(threading.RLock(),error,font)
|
||||||
|
controller.start()
|
||||||
|
except Exception as exc:
|
||||||
|
controller.error('initialization',exc)
|
||||||
|
controller.stop()
|
||||||
|
controller.driver=None
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
try:
|
||||||
|
with Server(str(path),Handler) as server:
|
||||||
|
path.chmod(0o660);server.controller=controller;server.config_path=Path(args.config)
|
||||||
|
def stop(*_):threading.Thread(target=server.shutdown,daemon=True).start()
|
||||||
|
signal.signal(signal.SIGTERM,stop);signal.signal(signal.SIGINT,stop)
|
||||||
|
log('START',plugin=descriptor()['id'],available=controller.driver is not None)
|
||||||
|
try:server.serve_forever(poll_interval=.2)
|
||||||
|
finally:controller.stop();log('STOP',i2c_errors=controller.i2c_errors)
|
||||||
|
finally:path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
if __name__=='__main__':main()
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Configuration and public capability schema; no hardware imports."""
|
||||||
|
import configparser
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
VERSION = "1.0.0"
|
||||||
|
PLUGIN_ID = "yahboom-cooling-hat"
|
||||||
|
COLORS = {"red":0,"green":1,"blue":2,"yellow":3,"purple":4,"cyan":5,"white":6}
|
||||||
|
EFFECTS = {"flow":0,"breathe":1,"marquee":2,"rainbow":3,"colorful":4}
|
||||||
|
GROUPS = {"cpu":"red","power":"yellow","memory":"purple","storage":"white","network":"blue","service":"cyan"}
|
||||||
|
DEFAULT_RGB = {"cpu":(255,0,0),"power":(255,40,0),"memory":(255,0,255),"storage":(255,255,255),"network":(0,0,255),"service":(0,255,96),"normal":(0,0,0)}
|
||||||
|
SCHEMA = {}
|
||||||
|
|
||||||
|
def option(section,key,default,zh,en,kind="number",minimum=None,maximum=None,choices=None,unit="",visible=None):
|
||||||
|
SCHEMA.setdefault(section,{})[key] = dict(default=default,label={"zh":zh,"en":en},type=kind,
|
||||||
|
min=minimum,max=maximum,choices=choices,unit=unit,visible=visible)
|
||||||
|
|
||||||
|
for key,value,label in (("start",50,"启动温度"),("level2",53,"二档温度"),("level3",56,"三档温度"),("level4",59,"四档温度"),("max",62,"全速温度"),("hysteresis",1.5,"降档回差")):
|
||||||
|
option("fan",key,value,label,key.replace('_',' '),minimum=0,maximum=100 if key!="hysteresis" else 20,unit="°C")
|
||||||
|
option("oled","refresh_seconds",1,"刷新周期","Refresh interval",minimum=.2,maximum=10,unit="s")
|
||||||
|
option("oled","page_seconds",10,"轮播周期","Page interval",minimum=1,maximum=120,unit="s")
|
||||||
|
option("rgb","alert_mode","breathe","告警模式","Alert mode","enum",choices=["breathe","flash"])
|
||||||
|
option("rgb","normal_mode","off","正常状态灯效","Normal lighting","enum",choices=["off","solid","flash",*EFFECTS])
|
||||||
|
option("rgb","normal_effect_color","green","正常灯效颜色","Normal effect color","enum",choices=list(COLORS),visible={"normal_mode":["flow","breathe"]})
|
||||||
|
option("rgb","normal_effect_speed",2,"正常灯效速度档","Normal effect speed",minimum=1,maximum=3,visible={"normal_mode":list(EFFECTS)})
|
||||||
|
option("rgb","custom_min_hold_ms",1500,"自定义闪烁最小保持","Minimum custom flash hold",minimum=1500,maximum=60000,unit="ms")
|
||||||
|
option("rgb","write_delay_ms",10,"内置效果指令间隔","Built-in effect command gap",minimum=1,maximum=1000,unit="ms")
|
||||||
|
for level,value in (("warning",1),("critical",2),("emergency",3)):
|
||||||
|
option("rgb",level+"_speed",value,{"warning":"普通","critical":"严重","emergency":"紧急"}[level]+"呼吸速度档",level.title()+" breathing speed",minimum=1,maximum=3,visible={"alert_mode":["breathe"]})
|
||||||
|
for edge in ("on","off"):
|
||||||
|
option("rgb",f"{level}_flash_{edge}_ms",1500,f"{ {'warning':'普通','critical':'严重','emergency':'紧急'}[level]}告警{'亮灯' if edge=='on' else '灭灯'}时间",f"{level.title()} flash {edge}",minimum=50,maximum=60000,unit="ms",visible={"alert_mode":["flash"]})
|
||||||
|
for edge in ("on","off"):
|
||||||
|
option("rgb",f"normal_flash_{edge}_ms",1500,"正常闪烁"+("亮灯" if edge=="on" else "灭灯")+"时间",f"Normal flash {edge}",minimum=50,maximum=60000,unit="ms",visible={"normal_mode":["flash"]})
|
||||||
|
for group,values in DEFAULT_RGB.items():
|
||||||
|
for channel,value in zip("rgb",values):
|
||||||
|
title={"cpu":"处理器","power":"电源","memory":"内存","storage":"存储","network":"网络","service":"服务","normal":"正常"}[group]
|
||||||
|
option("rgb",f"{group}_{channel}",value,title+" · "+channel.upper(),group.title()+" · "+channel.upper(),minimum=0,maximum=255,
|
||||||
|
visible={"normal_mode":["solid","flash"]} if group=="normal" else {"alert_mode":["flash"]})
|
||||||
|
|
||||||
|
for group in DEFAULT_RGB:
|
||||||
|
for channel in "rgb":
|
||||||
|
SCHEMA["rgb"][group+"_"+channel].update(color_group=group,channel=channel)
|
||||||
|
|
||||||
|
def validated(data):
|
||||||
|
if not isinstance(data,dict):raise ValueError("configuration object required")
|
||||||
|
result={}
|
||||||
|
for section,values in data.items():
|
||||||
|
if section not in SCHEMA or not isinstance(values,dict): raise ValueError("unknown configuration section")
|
||||||
|
result[section]={}
|
||||||
|
for key,value in values.items():
|
||||||
|
if key not in SCHEMA[section]: raise ValueError("unknown option: "+key)
|
||||||
|
spec=SCHEMA[section][key]
|
||||||
|
if spec["type"]=="enum":
|
||||||
|
if value not in spec["choices"]: raise ValueError("invalid choice: "+key)
|
||||||
|
else:
|
||||||
|
if isinstance(value,bool): raise ValueError("invalid number: "+key)
|
||||||
|
value=float(value)
|
||||||
|
if not math.isfinite(value) or not spec["min"]<=value<=spec["max"]: raise ValueError("out of range: "+key)
|
||||||
|
if section=="rgb":
|
||||||
|
if not value.is_integer(): raise ValueError("integer required: "+key)
|
||||||
|
value=int(value)
|
||||||
|
result[section][key]=value
|
||||||
|
return result
|
||||||
|
|
||||||
|
def load(path):
|
||||||
|
result={s:{k:v["default"] for k,v in options.items()} for s,options in SCHEMA.items()}
|
||||||
|
cfg=configparser.ConfigParser();cfg.read(path)
|
||||||
|
raw={s:dict(cfg.items(s)) for s in cfg.sections()}
|
||||||
|
for section,values in validated(raw).items():result[section].update(values)
|
||||||
|
check(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def check(config):
|
||||||
|
curve=[config["fan"][k] for k in ("start","level2","level3","level4","max")]
|
||||||
|
if any(a>=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}
|
||||||
@@ -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
|
||||||
@@ -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/<id>/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.
|
||||||
Executable
+47
@@ -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.'
|
||||||
@@ -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
|
||||||
Executable
+7
@@ -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.'
|
||||||
Reference in New Issue
Block a user