From 812a1eb77d747943318c435e65388c6d252e5655 Mon Sep 17 00:00:00 2001 From: windyboy Date: Mon, 21 Sep 2026 19:57:35 +0800 Subject: [PATCH] =?UTF-8?q?docs(hass):=20Quick=20=E9=9D=A2=E6=9D=BF=20card?= =?UTF-8?q?-mod=20=E7=AD=89=E9=AB=98=20+=20=E9=A5=BC=E5=9B=BE=E5=9B=BE?= =?UTF-8?q?=E4=BE=8B=E5=8F=B3=E4=BE=A7=20+=20WS=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 安装 card-mod (HACS),用 CSS `ha-card { height: Npx }` 强制等高 - 饼图+人体感应 520px,温湿度双图 520px - 饼图图例从底部移到右侧 (legend position: right),高度 510→520 - 关键认知:apexcharts chart.height 含图例,timescale-plotly height 是纯绘图区 - 提交 scripts/ha-ws-client.py (纯 stdlib WS 客户端,文档已引用) --- hosts/hass.windy.lan.md | 187 +++++++++++++++++++++++++++++++++++++--- scripts/ha-ws-client.py | 172 ++++++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+), 14 deletions(-) create mode 100755 scripts/ha-ws-client.py diff --git a/hosts/hass.windy.lan.md b/hosts/hass.windy.lan.md index 3deb4d0..28dd1cf 100644 --- a/hosts/hass.windy.lan.md +++ b/hosts/hass.windy.lan.md @@ -305,14 +305,58 @@ supervisor 代理 WS 被拒(loop prevention),用 0.589 元/kWh、15 个 csg_* 数值齐全、回归值不变(14.09/198.65/331.22/ 304.99/181.89)、automations on、`ha core check` OK、日志无 template 错误。 -**CSG 长期归档(W1N-243, 2026-08-29):** scribe 库新增 `csg_history` -表(逐日 usage/cost/ladder/balance + 逐月累计;2026-07-01 起回填,永久), -由 TimescaleDB 每日任务 **1008** `csg_daily_snapshot()`(22:30 -Asia/Shanghai;**TS job 非 pg_cron**)upsert 维护。日费用在原生 -`latest_day_cost` 缺失时回退 = 昨日用电 × 当前档费率(模板 -`csg_current_ladder_tariff` 0.639);月费用回退模板 -`csg_this_month_ladder_cost`。**语义**:day 行 usage/cost 为该日值, -ladder/balance 为 22:30 快照值。详见 [hosts/pgdb.md](../hosts/pgdb.md)。 +**CSG 长期归档(W1N-243, 2026-08-29;任务改号 2026-09-21):** scribe 库新增 +`csg_history` 表(逐日 usage/cost/ladder/balance + 逐月累计;2026-07-01 起回填, +永久),由 TimescaleDB 每日任务 **1010**(原 1008,2026-09-21 重建) +`csg_daily_snapshot()`(**14:30 UTC = 22:30 Asia/Shanghai**;**TS job 非 pg_cron**) +upsert 维护;计费用 `csg_ladder_cost(kwh, month)`。日行 usage 取集成 +`yesterday_kwh`,日费用在原生 `latest_day_cost` 缺失时回退 = 昨日用电 × +当前档费率(模板 `csg_current_ladder_tariff`);月行由阶梯助手重算。**语义**: +day 行 usage/cost 为该日值,ladder/balance 为快照值。详见 +[hosts/pgdb.md](../hosts/pgdb.md) § Known issues 2026-09-21(含原 1008 从未成功 +的根因与回填)。 + +**CSG 预测值复核(2026-09-21,只读):** 对 +`sensor.csg_*` 全部 14 个派生值用独立 Python 实现按同一输入重算,逐个与 HA +live state 完全一致(`csg_30d_avg` 9.07 / `csg_predicted_usage` 268.45 / +`csg_predicted_cost_ladder` 158.54 / `csg_this_month_ladder_cost` 99.35 / +`csg_mom_change` -21.6 等)。同时把 9 月逐日口径对回 scribe:集成 +`this_month_by_day`(09-01..09-19,19 天,和 168.68)与 `states_raw` 里 +`yesterday_kwh` 观测值逐日一致(唯一差异 09-06 = 9.29 是 scribe 漏采、集成有值), +确认输入数据无缺口。**因此「预测本月电费」不是算错,而是估计器的窗口问题**: +`csg_predicted_usage = 本月累计 + avg × 剩余天数`,旧版 `avg = csg_30d_avg` +(`history_data[-30:]` 的**普通 30 日均值、跨月重叠**)→ 月初被上月(更热)拖高 +(09-02 实测 317.93 kWh / 190.16 元),随本月凉快日置换回落到 268.45 / 158.54, +一个月内摆动 **-17%**。**另注**:集成原生 +`latest_day_cost` / `this_month_total_cost` / `last_month_total_cost` 自 +2026-08-29 起**恒为 unknown**(`states_raw` 无一条有值),故面板上所有「元」 +均为模板阶梯估算值,从未与账单口径对账。 + +**`csg_sensors.yaml` 预测速率 v2(2026-09-21,已实施):** 只改 +`csg_predicted_usage` 的速率项,`csg_30d_avg` 语义不动(面板「近30日平均」照旧)。 +新公式:`avg = mtd × w + recent7 × (1 − w)`,其中 `mtd = 本月累计 / 已过天数`、 +`recent7` = `history_data` 末 7 条均值、`w = min(已过天数, 14) / 14`;两者都取不到 +才退回 `csg_30d_avg`。**动机**:本月至今速率按天数爬升可信度,避免月初被上月污染; +近 7 日速率兜底,因为月初唯一的新数据就是上月末。Block C 触发器加 +`sensor.csg_history`。**19 天回测**(基准 = 第 19 天本月至今速率外推,逐日重放 +集成 `this_month_by_day` + `last_month_by_day`): + +| 速率方案 | 预测电费摆动 | 最大偏差 | 平均绝对偏差 | +|---|---|---|---| +| 旧:30 日均值 | 21.1% | 35.99 元 | 14.04 元 | +| v2:K=7 | 14.6% | 16.81 元 | 5.65 元 | +| **v2:K=14(采用)** | **13.9%** | **15.33 元** | **4.94 元** | +| v2:K=14 但兜底用 30 日均值 | 23.3% | 33.12 元 | 6.28 元 | + +(末行证实病根就是那个跨月的 30 日均值兜底。)备份 +`/homeassistant/.csg-backups/csg_sensors.yaml.bak-20260921-pre-predictor` +(改前)与 `…-pre-k14`(K=7 中间态)。**验证**:`ha core check` OK;Core 重启两次 +(trigger 模板块不吃 `template.reload`,W1N-114 先例);15 个 `csg_*` 全部数值、 +无 template 错误;当天 `n=19 ≥ 14` 故两版同值,面板读回 +`csg_predicted_usage` 266.34 kWh / `csg_predicted_cost_ladder` 157.19 元 / +`csg_prediction_progress` 63.3%;新值已进 scribe 归档 +(`states_raw` 08:17:04Z 158.54 → 08:17:10Z 157.19)。**注意**:`w` 的爬升窗口 +(14 天)是回测选出来的单月参数,属弱证据,换季/换季后值得复看。 > **Seasonal gauge switch (W1N-240 已知事项):** 每年 **11-01** 把 > `power-monitor` 视图「本月累计」gauge 切到冬季 `max:450` / @@ -563,13 +607,128 @@ manual copies): reader `remmob/timescale_database_reader` **v1.1.0** (main 各 timescale 卡可选档上调——大功率电器/常驻负载 `…,24h` → `+3d,7d`; 环境 `6h,12h,24h,48h` → `+7d,14d,30d`;人体感应 `…,24h` → `+3d,7d`; 用电量(按插座) `energy_time_ranges` `today,week,month,custom` → `+3mo`。 - **默认档未改**(6h / 6h / today / 24h / 12h)。卡片 JS 只接受 - `m|h|d`(`parseDurationToMs` 正则 `/^(\d+)(m|h|d)$/`, - 仅 m/h/d,无 w)与命名档 `today|week|month|3mo|6mo|year|years|custom`; - `energy_mode` 卡必须用后者。**数据下界注意**:scribe `sensor_minute` - 目前最早只到 **2026-08-29**,所以 >15d 的档(14d 边缘、30d 明显)前半段 - 会是空白,等归档继续累积才好看。备份 + **默认档未改**(6h / 6h / today / 24h / 12h)。备份 `.lovelace-backups/dashboard-quick-20260913-190912-pre-timerange.json`。 + ⚠️ **本条的 `d` 档当天其实是坏的**——见下方 2026-09-21 修复;`energy_mode` + 命名档(today/week/month/3mo)当时就是好的。卡片命名档只能是 + `today|week|month|3mo|6mo|year|years|custom`。**数据下界注意**:scribe + `sensor_minute` 最早只到 **2026-08-29**,所以 >15d 的档(14d 边缘、30d 明显) + 前半段仍是空白,等归档继续累积才好看。 + +- **Quick 面板改造 + timescale 卡两个真 bug 修复 (2026-09-21)**:起因是用户 + 觉得「面板标题 + 统计方式」不对劲。只读审查查出三个问题并全部修掉: + + **① 卡 JS:`d` 档实际渲染成 n 小时(VPS-92 的扩容其实没生效)**。 + 非 energy 档走 `const hours = parseInt(this._selectedRange) || 24;`, + `parseInt("7d") === 7`。全卡唯一能解析 `d` 的 `parseDurationToMs()` + (正则 `/^(\d+)(m|h|d)$/`)**只被 `resolveEnergyWindow()` 调用一次**, + 即只服务 energy 卡。后果:`3d` 比 `24h` 还短(3h),`14d`→14h、`30d`→30h。 + **修复**:改用 `Math.max(1, parseDurationToMs(...)/3600000)`;顺带把采样密度 + 目标 80 → 160 点(7d 档从 2.1h/桶降到 1.05h/桶)。备份 + `.lovelace-backups/timescale-plotly-card.js.bak-20260921-pre-dayfix`。 + 实测(本地 node 跑卡里的 `parseDurationToMs`):`3d→72h / 7d→168h / + 14d→336h / 30d→720h`,各档均 ~160 点;`node --check` JS 语法 OK。 + **资源 URL 已加 `?v=20260921` 强制浏览器重取**(原来无 hacstag,缓存问题会 + 让修复「看不见」)。 + + **② 功率卡画的是瞬时采样而非统计量**。reader 对 `table=sensor_minute` + (时间列名 `minute`)默认 `downsample_method = "last"` + (`custom_components/timescale_database_reader/__init__.py` L385-389), + 即每桶取「末一分钟的瞬时值」。实测空调 1 小时桶:03:00 真实均值 241.6 W / + 峰值 1128 W,图上只画 157;19:00 真实均值 97.6 / 峰值 676,图上画 **13** + (看起来像停机)。因 `sensor_minute` 已是均匀 1 分钟 LOCF 序列,`avg()` + 才是正确时间均值。**修复**:两张功率卡加 `downsample_method: "avg"`。 + **用电量卡不用改**——累计计数器配 `last()` 就是桶末读数,卡片再 diff, + 本来就是对的;人体感应卡显式 `last` 对分类信号也对。 + + **③ 标题与分组名不副实**。按 7d 实测(峰值/均值)重分组并改名: + | 位置 | 改前 | 改后 | + |---|---|---| + | 分区 | 大功率电器 | **峰值负载**(厨房电器 1839 / 空调 1128 / 电水壶 957 / 咖啡机 882 / 电脑 363 W) | + | 分区 | 常驻负载 | **基础负载**(主网络 75 / 冰箱 137 / 大房 290 / 水设备 171 / 无线AP 976* W) | + | 分区 | 用电 | **今日用电** | + | 分区 | 灯 + 开关(分开在最顶和最底) | **灯与开关**(合并置顶,可操作项集中) | + | 分区(新) | — | **此刻耗电** | + | 徽标 | 总功率 | **插座合计**(只是 9 个插座之和,不含灯/换气) | + | 实体 | 迷你 / mini数显 / 数显 | **卫生间 / 客厅 / 卧室**(HA area 本来就有,之前用的是设备型号名) | + | 实体 | 电池(移动) | **厨房(电池)**(该设备 area 也是厨房) | + 删除 3 张与分区标题重复的卡 title(用电量(按插座) / 室内外温湿度 / 活动状态)。 + \* 无线AP 7 天里只有 2 分钟 >150 W 却记到 976 W —— 单点毛刺,会把 y 轴顶飞, + 待查。 + + **④ 新展示**:新增「此刻耗电」区 = `custom:apexcharts-card` **donut**,11 个 + 插座当前功率(`graph_span: 10min` + `update_interval: 1min`),回答「现在什么 + 在吃电」;环境区拆成**室内外温差**(新模板传感器 `sensor.quick_wen_cha` = + 3 个室内温度均值 − 室外,画零线上下 fill)+ **湿度**(4 条),4 个温度进徽标。 + 传感器加在 `templates/quick_sensors.yaml`(W1N-238 约定的落点,顶层列表, + `quick_` 前缀 + unique_id),非 trigger 型模板卡,**`template.reload` 即可生效, + 无需重启 Core**(实测 `POST /core/api/services/template/reload` → 200, + `sensor.quick_wen_cha` = 3.9 °C)。备份 + `templates/quick_sensors.yaml.bak-20260921-pre-wencha`。 + 最终分区顺序(按「可变性」):灯与开关 → 此刻耗电 → 今日用电 → + 峰值负载(2) → 基础负载(2) → 环境 → 人体感应。 + + **⑤ Quick 布局重构 (2026-09-21 晚)**:用户反馈饼图(apexcharts donut)撑满 + 整行(span 4)且不满、马桶换气开关 tile `columns: "full"` 占满整行太宽、整体 + 布局需要重新思考。改动: + + **布局重排**(阅读流:控制 → 此刻 → 趋势 → 今日 → 环境): + | 行 | 左 (span 2) | 右 (span 2) | 主题 | + |---|---|---|---| + | 1 | 灯与开关 (span 4) | | 可操作项置顶 | + | 2 | 实时功率 (donut) | 人体感应 | 「此刻状态」 | + | 3 | 峰值负载 | 基础负载 | 「功率趋势」(原配对保留) | + | 4 | 今日用电 (span 4) | | 11 条堆叠柱需要宽度 | + | 5 | 温湿度 (span 4) | | 温差+湿度两图并排 | + + **标题改名**:此刻耗电→**实时功率**(与峰值负载对仗)、环境→**温湿度** + (更具体)。删饼图卡内冗余标题 "各插座当前功率 (W)"。 + + **开关收窄**:马桶换气电源 tile 从独立 `columns: "full"`(48 列全宽)→ 移入 + 灯的 2 列嵌套网格第 5 格,与灯卡等宽(半行)。 + + **饼图高度**:`apex_config.chart.height` 280→350(11 项底部图例占 ~100px, + 280 时饼图本体太扁)。 + + **⑥ card-mod 等高 (2026-09-21 晚)**:不同卡类型(apexcharts vs timescale-plotly) + 的 `height` 渲染比例不同(apexcharts 含图例/内边距,timescale 是纯绘图区), + 直接对齐数字无法视觉等高。安装 **card-mod**(HACS → card-mod + card-mod-studio) + 后用 CSS 强制 `ha-card` 高度: + + ```yaml + card_mod: + style: "ha-card { height: 520px !important; }" + ``` + + **最终高度**:饼图+人体感应 = **520px**,温湿度双图 = **520px**。其余图表 + (峰值/基础负载 280、今日用电 320)保持各自原生高度,不强制统一——不同图表 + 类型适合不同高度,强行统一反而让简单图表显得太空。 + + **关键认知**:apexcharts `chart.height` 包含图例区域,timescale-plotly `height` + 是纯绘图区。同一数字渲染出来 apexcharts 视觉上更矮。card-mod 直接控制 + `ha-card` 容器高度,绕过内部渲染差异。 + + 备份:`.lovelace-backups/dashboard-quick-20260921-pre-titles.json`(同日早期 + 备份,覆盖标题+布局全部改动的回滚)。 + + **验证**:`ha core check` OK;WS 读回与写入 payload **逐字节相同**; + 新配置引用的 **40 个实体全部存在**且无 unknown/unavailable; + 卡 JS `node --check` 通过、时长解析实测正确。 + **改法(可复用)**:写 Lovelace 配置必须走 WS + `lovelace/config/save`(**勿直改 `.storage/`**)。本机 python/HA 容器内经 + supervisor 代理连 WS 会被 loop-prevention 拒;可行路径是 + `docker run --rm -i --network host -e SUPERVISOR_TOKEN --entrypoint python3 + r.hassbus.com/home-assistant/aarch64-hassio-supervisor: -c "..."` + 跑本仓库的 [`scripts/ha-ws-client.py`](../scripts/ha-ws-client.py)(纯 stdlib, + 不依赖 websockets/aiohttp,也不依赖 `-v` 挂载——`-v /tmp/...` 会落到 Docker 宿主 + 而不是 SSH add-on 的 `/tmp`)。读配置:`{"action":"get","url_path":"dashboard-quick"}`; + 写:`{"action":"save",...,"config":{...}}`。备份 + `.lovelace-backups/dashboard-quick-20260921-pre-titles.json`。 + + **回滚**:还原 `dashboard-quick-20260921-pre-titles.json`(WS save)+ + `timescale-plotly-card.js.bak-20260921-pre-dayfix` + 删 `quick_wen_cha` + (还原 `quick_sensors.yaml.bak-20260921-pre-wencha` + `template.reload`)。 + 改了卡 JS 就要把资源 URL 的 `?v=` 再 bump 一次。 ### 地图仪表盘:CARTO keyed tiles via `custom:map-card` (verified 2026-08-30, W1N-261) diff --git a/scripts/ha-ws-client.py b/scripts/ha-ws-client.py new file mode 100755 index 0000000..22fdb12 --- /dev/null +++ b/scripts/ha-ws-client.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Stdlib-only Home Assistant WebSocket client (via the Supervisor core proxy). + +Why this exists +--------------- +Some HA operations have **no REST route** and must go through the WebSocket API +-- writing Lovelace config (`lovelace/config/save`) is the main one, and the +`.storage/` files must not be hand-edited (HA may overwrite them, and edits skip +validation). The obvious paths are dead ends on this host: the HA host and the +core container have no usable WS client (the Supervisor proxy rejects core's own +loop), and the Supervisor image has neither `websockets` nor `aiohttp`. +HA's WS protocol is plain text frames, so ~90 lines of stdlib is enough. + +Run it from the HA host (see hosts/hass.windy.lan.md "改法(可复用)"): + + B64=$(base64 < scripts/ha-ws-client.py | tr -d '\n') + echo '{"action":"get","url_path":"dashboard-quick"}' | \ + docker run --rm -i --network host -e SUPERVISOR_TOKEN --entrypoint python3 \ + r.hassbus.com/home-assistant/aarch64-hassio-supervisor: \ + -c "import base64,sys;exec(base64.b64decode('$B64').decode())" - - + +Do NOT use `-v /tmp/...`: that path is resolved by the Docker daemon on the +HAOS host, not inside the SSH add-on, so the mount comes up empty. + +Payload / output +---------------- + {"action":"get", "url_path":"dashboard-quick"} + {"action":"save", "url_path":"dashboard-quick", "config": {...}} + {"action":"raw", "command": {"type":"lovelace/resources/list"}} + + python3 ha-ws-client.py + +"-" reads the payload from stdin / prints the result to stdout, wrapped in +<<>> / <<>> markers so it survives the add-on login banner. +""" +import base64 +import json +import os +import random +import socket +import struct +import sys + +HOST = os.environ.get("WS_HOST", "172.30.32.2") +PORT = int(os.environ.get("WS_PORT", "80")) +PATH = os.environ.get("WS_PATH", "/core/websocket") + + +class WS: + def __init__(self, host, port, path, extra_headers=None): + self.s = socket.create_connection((host, port), timeout=30) + key = base64.b64encode(bytes(random.getrandbits(8) for _ in range(16))).decode() + req = ( + f"GET {path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\n" + f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n" + f"Sec-WebSocket-Version: 13\r\n" + ) + for k, v in (extra_headers or {}).items(): + req += f"{k}: {v}\r\n" + self.s.sendall((req + "\r\n").encode()) + buf = b"" + while b"\r\n\r\n" not in buf: + d = self.s.recv(4096) + if not d: + raise RuntimeError("closed during handshake") + buf += d + head, _, rest = buf.partition(b"\r\n\r\n") + status = head.split(b"\r\n")[0] + if b"101" not in status: + raise RuntimeError("handshake failed: " + status.decode(errors="replace")) + self.buf = rest + + def _exact(self, n): + while len(self.buf) < n: + d = self.s.recv(65536) + if not d: + raise RuntimeError("socket closed") + self.buf += d + out, self.buf = self.buf[:n], self.buf[n:] + return out + + def send(self, text): + data = text.encode() + mask = bytes(random.getrandbits(8) for _ in range(4)) + n = len(data) + hdr = bytearray([0x81]) + if n < 126: + hdr.append(0x80 | n) + elif n < 65536: + hdr.append(0x80 | 126) + hdr += struct.pack(">H", n) + else: + hdr.append(0x80 | 127) + hdr += struct.pack(">Q", n) + hdr += mask + self.s.sendall(bytes(hdr) + bytes(b ^ mask[i % 4] for i, b in enumerate(data))) + + def recv(self): + while True: + b0, b1 = self._exact(2) + op = b0 & 0x0F + ln = b1 & 0x7F + if ln == 126: + ln = struct.unpack(">H", self._exact(2))[0] + elif ln == 127: + ln = struct.unpack(">Q", self._exact(8))[0] + payload = self._exact(ln) if ln else b"" + if op == 0x8: + raise RuntimeError("server closed") + if op in (0x9, 0xA): + continue + return payload.decode() + + +def main(): + token = os.environ["SUPERVISOR_TOKEN"] + src = sys.argv[1] + payload = json.load(sys.stdin if src == "-" else open(src, encoding="utf-8")) + action = payload.pop("action") + url_path = payload.pop("url_path", "dashboard-quick") + out_path = sys.argv[2] + + ws = WS(HOST, PORT, PATH, {"Authorization": f"Bearer {token}"}) + while True: + hello = json.loads(ws.recv()) + if hello.get("type") == "auth_required": + break + if hello.get("type") == "auth_ok": + break + ws.send(json.dumps({"type": "auth", "access_token": token})) + while True: + r = json.loads(ws.recv()) + if r.get("type") == "auth_ok": + break + if r.get("type") == "auth_invalid": + print("AUTH_INVALID:", json.dumps(r, ensure_ascii=False)[:300]) + return 1 + + cmd = { + "id": 1, + "type": "lovelace/config/save" if action == "save" else "lovelace/config", + "url_path": url_path, + } + if action == "save": + cmd["config"] = payload["config"] + elif action == "raw": + cmd = dict(payload["command"]) + cmd["id"] = 1 + ws.send(json.dumps(cmd)) + while True: + r = json.loads(ws.recv()) + if r.get("id") != 1: + continue + if not r.get("success"): + print("ERROR:", json.dumps(r, ensure_ascii=False)[:600]) + return 1 + result = r.get("result") + blob = json.dumps(result if result is not None else {"ok": True}, + ensure_ascii=False, indent=1) + if out_path == "-": + print("<<>>") + print(blob) + print("<<>>") + else: + with open(out_path, "w", encoding="utf-8") as f: + f.write(blob) + print("OK ->", out_path) + return 0 + + +if __name__ == "__main__": + sys.exit(main())