Compare commits
9
Commits
v1.6.2
..
f6ef99e34f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6ef99e34f | ||
|
|
876754be56 | ||
|
|
48071685f1 | ||
|
|
3cf3843af3 | ||
|
|
9e93ca0db4 | ||
|
|
35691e08eb | ||
|
|
7d46cb96d3 | ||
|
|
5e1545efad | ||
|
|
6e197d7808 |
No files matched your search
@@ -6,3 +6,4 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
data/
|
data/
|
||||||
review-notes/
|
review-notes/
|
||||||
|
development-notes/
|
||||||
@@ -43,26 +43,27 @@
|
|||||||
- **(b) 完成整个 milestone**:仅当用户在提示词里**显式要求启用 sub-agent**时,才起 implementer / reviewer / fixer sub-agent(按下方**『默认能力档位』**选择模型,用户人工指定则覆盖),按任务依赖顺序跑完整条链。
|
- **(b) 完成整个 milestone**:仅当用户在提示词里**显式要求启用 sub-agent**时,才起 implementer / reviewer / fixer sub-agent(按下方**『默认能力档位』**选择模型,用户人工指定则覆盖),按任务依赖顺序跑完整条链。
|
||||||
- **Sub-agent 纪律**:只在用户显式要求时才 spawn sub-agent;单步/小改动在主线内联完成。当前 harness 支持独立 sub-agent 时,使用其原生机制按下方**『默认能力档位』**派发;不支持时不得假装已创建 sub-agent,应明确说明限制,并仅在用户允许 fallback 时由主 agent 继续。
|
- **Sub-agent 纪律**:只在用户显式要求时才 spawn sub-agent;单步/小改动在主线内联完成。当前 harness 支持独立 sub-agent 时,使用其原生机制按下方**『默认能力档位』**派发;不支持时不得假装已创建 sub-agent,应明确说明限制,并仅在用户允许 fallback 时由主 agent 继续。
|
||||||
|
|
||||||
### 默认能力档位(实现模式 sub-agent;可被人工指定覆盖)
|
### 默认能力档位(编排器与实现模式 sub-agent;可被人工指定覆盖)
|
||||||
|
|
||||||
起 implementer / reviewer / fixer sub-agent 时,**默认**按下列能力档位选择当前 harness 支持的模型,无需用户每次人工指定:
|
每次开启编排模式时,**Orchestrator 使用开启时主线当前使用的模型**。起 implementer / reviewer / fixer sub-agent 时,**默认**按下列能力档位选择当前 harness 支持的模型,无需用户每次人工指定:
|
||||||
|
|
||||||
| 角色 | 通用模型要求 | 推理档位 | Harness 示例(非强制) |
|
| 角色 | 通用模型要求 | 推理档位 | Harness 示例(非强制) |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| **Implementer** | 平衡型代码实现模型 | `medium` 或等效档位 | Claude Code:Sonnet;Codex/OpenAI:GPT-5.6 Terra (`gpt-5.6-terra`) |
|
| **Orchestrator**(编排器) | 每次开启编排模式时主线当前使用的模型 | 沿用主线当前设置 | 开启编排模式时的当前模型 |
|
||||||
| **Fixer**(返工) | 平衡型代码实现模型 | `medium` 或等效档位 | Claude Code:Sonnet;Codex/OpenAI:GPT-5.6 Terra (`gpt-5.6-terra`) |
|
| **Implementer** | 平衡型代码实现模型 | `medium` 或等效档位 | Claude Code:Sonnet;Codex/OpenAI:GPT-6.1-Luna (`gpt-6.1-luna`) |
|
||||||
| **Reviewer** | 当前 harness 支持的最强通用推理 / 代码模型 | `extra-high` / `xhigh` 或等效档位 | Claude Code:Opus;Codex/OpenAI:GPT-5.6 Sol (`gpt-5.6-sol`) |
|
| **Fixer**(返工) | 平衡型代码实现模型 | `medium` 或等效档位 | Claude Code:Sonnet;Codex/OpenAI:GPT-6.1-Luna (`gpt-6.1-luna`) |
|
||||||
|
| **Reviewer** | 通用推理 / 代码评审模型 | `medium` 或等效档位 | Claude Code:Opus;Codex/OpenAI:GPT-6.1-Sol (`gpt-6.1-sol`) |
|
||||||
|
|
||||||
- **示例非强制**:示例模型只表示当前推荐映射,不构成跨 harness 的硬性模型 ID;当前 harness 不支持时,选择最符合「通用模型要求」的可用模型。
|
- **示例非强制**:示例模型只表示当前推荐映射,不构成跨 harness 的硬性模型 ID;当前 harness 不支持时,选择最符合「通用模型要求」的可用模型。
|
||||||
- **选择优先级**:用户显式指定 > 当前 harness 的原生角色配置 > 上表的 harness 示例 > 按通用模型要求自动选择。
|
- **选择优先级**:用户显式指定 > 当前 harness 的原生角色配置 > 上表的 harness 示例 > 按通用模型要求自动选择。
|
||||||
- **推理档位说明**:若 harness 提供独立的 reasoning-effort 设置,按上表设置;若不提供,在 spawn prompt 中明确 implementer/fixer 按平衡深度思考,reviewer 按对抗性外部审计强度复核。
|
- **推理档位说明**:若 harness 提供独立的 reasoning-effort 设置,implementer / fixer / reviewer 均设为 `medium`;若不提供,在 spawn prompt 中明确按 medium 或等效的平衡深度思考,reviewer 仍须独立、对抗性复核。
|
||||||
|
|
||||||
### 角色(Orchestrator → Implementer → Reviewer → Fixer)
|
### 角色(Orchestrator → Implementer → Reviewer → Fixer)
|
||||||
|
|
||||||
- 我(主线)= **Orchestrator**:挑依赖已满足的下一个任务、派发、转述结果、维护任务 `Status`。
|
- 我(主线)= **Orchestrator**(每次开启编排模式时主线当前使用的模型):挑依赖已满足的下一个任务、派发、转述结果、维护任务 `Status`。
|
||||||
- **Implementer**(平衡型代码实现模型,medium 或等效档位):一次一个任务,严格按任务卡,不扩范围。
|
- **Implementer**(默认 GPT-6.1-Luna,medium 或等效档位):一次一个任务,严格按任务卡,不扩范围。
|
||||||
- **Reviewer**(最强通用推理 / 代码模型,extra-high / xhigh 或等效档位):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动返工直到本轮 PASS。
|
- **Reviewer**(默认 GPT-6.1-Sol,medium 或等效档位):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动返工直到本轮 PASS。
|
||||||
- **Fixer**(平衡型代码实现模型,medium 或等效档位):按 reviewer 的编号返工清单返工;**每轮返工起一个干净的 Fixer**(与首次实现的 Implementer 分开冷启动),先读对应 `review-notes/<task>-review-<n>.md` 再改。
|
- **Fixer**(默认 GPT-6.1-Luna,medium 或等效档位):按 reviewer 的编号返工清单返工;**每轮返工起一个干净的 Fixer**(与首次实现的 Implementer 分开冷启动),先读对应 `review-notes/<task>-review-<n>.md` 再改。
|
||||||
|
|
||||||
#### Reviewer 盲审纪律(M1 教训)
|
#### Reviewer 盲审纪律(M1 教训)
|
||||||
|
|
||||||
|
|||||||
@@ -345,6 +345,11 @@ CLI 工具为受控手工验证而设(设备需接市电),仅暴露读功
|
|||||||
- 每次轮询成功后推 state topic;连接成功或勾选变更时重发 retained discovery config
|
- 每次轮询成功后推 state topic;连接成功或勾选变更时重发 retained discovery config
|
||||||
- `POST /api/config/mqtt/test`:试连 broker 并发布一条测试消息(可用 MQTT Explorer 验证链路)
|
- `POST /api/config/mqtt/test`:试连 broker 并发布一条测试消息(可用 MQTT Explorer 验证链路)
|
||||||
|
|
||||||
|
主 MQTT 客户端由应用 lifespan 持有。相同配置下重复调用 connect 不会创建新客户端;Paho
|
||||||
|
网络循环在短暂断线后负责自动重连,并在重连成功时恢复订阅。每分钟的 HA 发布任务会幂等地
|
||||||
|
确保客户端存在。配置变更或应用关闭时,旧客户端先从管理器解绑,再断开并停止网络循环;旧
|
||||||
|
客户端的迟到回调不会改变新连接状态或分发消息。DSMR source 使用各自独立的客户端生命周期。
|
||||||
|
|
||||||
**Config 页配置流程**:
|
**Config 页配置流程**:
|
||||||
1. 在 `/config` 的「MQTT」section 填写 broker host/port/username/password,启用 `MQTT_ENABLED`
|
1. 在 `/config` 的「MQTT」section 填写 broker host/port/username/password,启用 `MQTT_ENABLED`
|
||||||
2. 点「发送测试消息」确认 broker 链路通
|
2. 点「发送测试消息」确认 broker 链路通
|
||||||
@@ -585,10 +590,155 @@ docker compose -f docker-compose.yml pull
|
|||||||
docker compose -f docker-compose.yml up -d
|
docker compose -f docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker 日志轮转与生产应用
|
||||||
|
|
||||||
|
源码 Compose 为 `app` 和一次性 `migration` 服务配置 Docker `json-file` 日志轮转:单个
|
||||||
|
日志文件最多约 10 MiB,最多保留 3 个文件。开发 override 继承这项设置。Docker 管理这些
|
||||||
|
文件,通常位于宿主机
|
||||||
|
`/var/lib/docker/containers/<完整容器ID>/<完整容器ID>-json.log`;路径取决于 Docker data
|
||||||
|
root,`docker logs` / `docker compose logs` 是查看容器日志的接口。轮转会限制每个容器的
|
||||||
|
日志保留量,较旧内容会被 Docker 轮转淘汰。不要手动删除或编辑 Docker 管理的日志文件。
|
||||||
|
|
||||||
|
`docker restart` 只重启现有容器,不会更新容器创建时保存的 logging 配置;须重建容器。
|
||||||
|
本机开发可重新执行上面的 Compose 命令。生产设备的 Compose 位于
|
||||||
|
`/home/tianyu/.local/share/homeautomation`,与本仓库源码 Compose 分离。部署时只把两个
|
||||||
|
服务的 `logging` 块合并到生产 Compose 对应服务中,并保留生产端已有的 `.env`、设备映射、
|
||||||
|
`group_add`、volume、端口和其他部署设置;不要用完整源码 Compose 覆盖生产文件。
|
||||||
|
|
||||||
|
要同步的配置块如下,分别放在生产文件的 `services.app` 和 `services.migration` 下:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
```
|
||||||
|
|
||||||
|
生产变更只供人工维护窗口执行,需在生产目录操作。先备份生产 Compose(以及已有的环境文件),
|
||||||
|
并且只把 `app`、`migration` 的 `logging` 块同步到生产文件;不要复制或覆盖整份生产 Compose。
|
||||||
|
保留原有设备映射、`group_add`、volume、端口、环境变量和其他部署设置。编辑后用生产实际使用的
|
||||||
|
Compose 文件组合及合成环境值运行 `docker compose ... config --quiet` 检查;后续所有 Compose 命令都要带上相同生产文件组合和
|
||||||
|
`.mqtt-fix-image-pin.yml`,任何重建命令都带 `--pull never --no-build`;不要把展开后的配置
|
||||||
|
(可能含凭据)打印或贴入报告。以下命令在生产目录执行,示例假设生产基础文件名为
|
||||||
|
`docker-compose.yml`;若实际启动时显式使用其他 `-f` 文件,应在每条命令中原样保留该文件组合。
|
||||||
|
|
||||||
|
先确定本次要走的镜像路径,并创建仅用于固定镜像的持久 override。该文件只设置两个服务的
|
||||||
|
`image`,生产 Compose 仍负责其余设置。只应用日志配置时,不能假设本地 `latest` 标签仍指向
|
||||||
|
正在运行的镜像:先从运行中的 app 容器读取不可变 image ID,将其记录并加上受控本地标签,
|
||||||
|
然后固定这个标签。命令示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/tianyu/.local/share/homeautomation
|
||||||
|
MQTT_FIX_BACKUP="compose.before-mqtt-fix-$(date +%Y%m%d-%H%M%S).yml"
|
||||||
|
MQTT_FIX_ENV_BACKUP="env.before-mqtt-fix-$(date +%Y%m%d-%H%M%S).bak"
|
||||||
|
MQTT_FIX_RECORD="mqtt-fix-maintenance-record.txt"
|
||||||
|
MQTT_FIX_PIN_BACKUP="image-pin.before-mqtt-fix.yml"
|
||||||
|
MQTT_FIX_APP_ID="$(docker inspect -f '{{.Image}}' home-automation-app)"
|
||||||
|
MQTT_FIX_OLD_REPODIGESTS="$(docker image inspect -f '{{json .RepoDigests}}' "$MQTT_FIX_APP_ID")"
|
||||||
|
printf 'previous app image ID: %s\nprevious app RepoDigests: %s\n' "$MQTT_FIX_APP_ID" "$MQTT_FIX_OLD_REPODIGESTS" | tee -a "$MQTT_FIX_RECORD"
|
||||||
|
MQTT_FIX_APP_IMAGE="home-automation:prod-before-mqtt-fix"
|
||||||
|
docker image tag "$MQTT_FIX_APP_ID" "$MQTT_FIX_APP_IMAGE"
|
||||||
|
printf 'previous app local pin: %s\n' "$MQTT_FIX_APP_IMAGE" | tee -a "$MQTT_FIX_RECORD"
|
||||||
|
cp docker-compose.yml "$MQTT_FIX_BACKUP"
|
||||||
|
if [ -f .env ]; then cp .env "$MQTT_FIX_ENV_BACKUP"; fi
|
||||||
|
if [ -f .mqtt-fix-image-pin.yml ]; then cp .mqtt-fix-image-pin.yml "$MQTT_FIX_PIN_BACKUP"; fi
|
||||||
|
printf 'Compose backup: %s\n.env backup (if present): %s\nprevious image pin backup (if present): %s\n' "$MQTT_FIX_BACKUP" "$MQTT_FIX_ENV_BACKUP" "$MQTT_FIX_PIN_BACKUP" | tee -a "$MQTT_FIX_RECORD"
|
||||||
|
cat > .mqtt-fix-image-pin.yml <<YAML
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${MQTT_FIX_APP_IMAGE}
|
||||||
|
migration:
|
||||||
|
image: ${MQTT_FIX_APP_IMAGE}
|
||||||
|
YAML
|
||||||
|
```
|
||||||
|
|
||||||
|
`.mqtt-fix-image-pin.yml` 保存已核验的镜像选择;不要删除它或在后续 Compose 操作中省略它。
|
||||||
|
只有明确部署另一个已核验的镜像时才更新该文件。
|
||||||
|
|
||||||
|
在编辑生产文件的 `logging` 块后,用同一镜像 pin 和合成环境变量静态检查配置,并通过显式
|
||||||
|
override 固定镜像身份。`--env-file /dev/null` 可避免从部署 `.env` 取值;命令行变量只用于
|
||||||
|
Compose 插值。不要输出 `config` 展开结果:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WARMTELINK_DEVICE_PATH=/dev/synthetic-device WARMTELINK_SERIAL_GID=12345 TZ=Europe/Amsterdam \
|
||||||
|
docker compose --env-file /dev/null -f docker-compose.yml -f .mqtt-fix-image-pin.yml config --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
若同时部署 MQTT 源码修复,须先确认修复已合入并发布。显式拉取已发布的版本 tag(不要用
|
||||||
|
可变 `latest`),从本地镜像记录 registry digest,并在 override 中用 digest 固定目标;先核验
|
||||||
|
该 tag/digest 对应包含修复的发布,再继续。示例中的 `<published-tag>` 应替换为已核验的版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MQTT_FIX_RELEASE_TAG='code.wanderingbadger.dev/tliu93/home-automation:REPLACE_WITH_VERIFIED_TAG'
|
||||||
|
docker pull "$MQTT_FIX_RELEASE_TAG"
|
||||||
|
MQTT_FIX_NEW_REPODIGESTS="$(docker image inspect -f '{{json .RepoDigests}}' "$MQTT_FIX_RELEASE_TAG")"
|
||||||
|
printf 'target release tag: %s\ntarget RepoDigests: %s\n' "$MQTT_FIX_RELEASE_TAG" "$MQTT_FIX_NEW_REPODIGESTS" | tee -a "$MQTT_FIX_RECORD"
|
||||||
|
MQTT_FIX_APP_IMAGE='code.wanderingbadger.dev/tliu93/home-automation@sha256:REPLACE_WITH_VERIFIED_DIGEST'
|
||||||
|
cat > .mqtt-fix-image-pin.yml <<YAML
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${MQTT_FIX_APP_IMAGE}
|
||||||
|
migration:
|
||||||
|
image: ${MQTT_FIX_APP_IMAGE}
|
||||||
|
YAML
|
||||||
|
printf 'selected fixed app image: %s\n' "$MQTT_FIX_APP_IMAGE" | tee -a "$MQTT_FIX_RECORD"
|
||||||
|
```
|
||||||
|
|
||||||
|
将镜像 digest 与发布记录中的目标源码提交对应关系核验后,把确认过的
|
||||||
|
`MQTT_FIX_APP_IMAGE` 用于上述 override 和 `config --quiet` 检查。源码修复尚未发布时,
|
||||||
|
只走前述运行中 image ID 固定路径,不执行 `docker pull`。纯应用代码修复不需要启动 migration;
|
||||||
|
配置更新时可用 `create` 重建 migration 容器但不启动一次性迁移任务。
|
||||||
|
|
||||||
|
容器必须重建才能采用新日志策略。重建 app 会造成短暂采集与 API 中断;安排维护窗口并确认
|
||||||
|
设备采集可恢复。重建前如需留存旧 Docker 日志,可将 `docker logs --timestamps
|
||||||
|
home-automation-app` 输出重定向到受控备份文件,并确认文件可读;此步骤只导出日志,不删除
|
||||||
|
Docker 文件。Docker 可能在移除旧容器时一并移除其 json-file 日志,因此不要假设重建后旧
|
||||||
|
日志仍可从新容器读取。
|
||||||
|
|
||||||
|
确认配置、logging 块和固定镜像后,在生产目录应用容器配置(避免启动 migration)。每条重建
|
||||||
|
命令都显式使用 image pin 并禁止隐式拉取,且保留生产基础 Compose 与原有部署设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml create --pull never --no-build --force-recreate migration
|
||||||
|
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml up -d --no-deps --pull never --no-build --force-recreate app
|
||||||
|
docker inspect -f '{{.HostConfig.LogConfig.Type}} {{json .HostConfig.LogConfig.Config}}' home-automation-app
|
||||||
|
docker inspect -f '{{.Image}}' home-automation-app
|
||||||
|
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml ps
|
||||||
|
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml logs --tail=100 app
|
||||||
|
```
|
||||||
|
|
||||||
|
检查 `inspect` 结果为 `json-file`、`max-size` 为 `10m`、`max-file` 为 `3`,app 为运行状态,
|
||||||
|
近期日志无启动错误,之后确认 MQTT 与采集恢复。回退时恢复备份的生产 Compose(或恢复旧
|
||||||
|
logging 块)。两种镜像路径都可用维护记录中的旧 app image ID 回退:确认本地旧镜像仍在后,
|
||||||
|
将持久 pin 文件恢复为记录的旧本地 pin(或由记录的 image ID 重新打回该标签),并继续使用该文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker image inspect home-automation:prod-before-mqtt-fix >/dev/null
|
||||||
|
cat > .mqtt-fix-image-pin.yml <<'YAML'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: home-automation:prod-before-mqtt-fix
|
||||||
|
migration:
|
||||||
|
image: home-automation:prod-before-mqtt-fix
|
||||||
|
YAML
|
||||||
|
WARMTELINK_DEVICE_PATH=/dev/synthetic-device WARMTELINK_SERIAL_GID=12345 TZ=Europe/Amsterdam \
|
||||||
|
docker compose --env-file /dev/null -f docker-compose.yml -f .mqtt-fix-image-pin.yml config --quiet
|
||||||
|
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml create --pull never --no-build --force-recreate migration
|
||||||
|
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml up -d --no-deps --pull never --no-build --force-recreate app
|
||||||
|
```
|
||||||
|
|
||||||
|
源码更新路径也使用开头记录的旧 app image ID/pin 回退,无需依赖已变化的 `latest`。若本地
|
||||||
|
旧 image ID 已不存在,则用维护记录中保存的旧 RepoDigest 显式拉取旧镜像,核对身份后打回原 pin
|
||||||
|
标签,再按上面的回退步骤操作。记录本次和回退使用的 image ID/digest、固定 pin 文件内容、生产
|
||||||
|
Compose 及 `.env` 备份文件名、logging inspect 结果及验证时间。回退同样会重建容器、造成短暂
|
||||||
|
中断;重建前需另行导出想保留的当前容器日志。保留 `.mqtt-fix-image-pin.yml` 供后续 Compose
|
||||||
|
操作继续使用;不要删除 Docker 日志、备份或其他生产文件。
|
||||||
|
|
||||||
持续查看日志:
|
持续查看日志:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f app
|
docker compose -f docker-compose.yml -f .mqtt-fix-image-pin.yml logs -f app
|
||||||
```
|
```
|
||||||
|
|
||||||
## Container Image CI
|
## Container Image CI
|
||||||
|
|||||||
@@ -1028,7 +1028,7 @@ def _m8_energy_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
elif meter.commodity == "heating":
|
elif meter.commodity == "heating":
|
||||||
unit, device_class = "GJ", "energy"
|
unit, device_class = "GJ", "energy"
|
||||||
else:
|
else:
|
||||||
unit, device_class = "m³", "volume"
|
unit, device_class = "m³", "water"
|
||||||
for suffix, getter in (
|
for suffix, getter in (
|
||||||
("total", _meter_total_getter(binding.id, source.id, channel.id)),
|
("total", _meter_total_getter(binding.id, source.id, channel.id)),
|
||||||
("today", _meter_today_getter(binding.id, source.id, channel.id)),
|
("today", _meter_today_getter(binding.id, source.id, channel.id)),
|
||||||
@@ -1230,7 +1230,24 @@ def _thermal_cost_getter(metric: str, window: str | None) -> Callable[[Session],
|
|||||||
if metric == "water_tax":
|
if metric == "water_tax":
|
||||||
return result["breakdown"]["hot_water_tax"]
|
return result["breakdown"]["hot_water_tax"]
|
||||||
if metric == "hot_water_total":
|
if metric == "hot_water_total":
|
||||||
return result["breakdown"]["hot_water_heating"] + result["breakdown"]["hot_water"]
|
# Keep the existing component entities variable-only, while making
|
||||||
|
# the two consumer-facing totals partition the all-in summary.
|
||||||
|
# Standing costs are assigned by their contractual commodity:
|
||||||
|
# hot-water network belongs to hot water; the remaining named
|
||||||
|
# standing fees belong to heating.
|
||||||
|
return (
|
||||||
|
result["breakdown"]["hot_water_heating"]
|
||||||
|
+ result["breakdown"]["hot_water"]
|
||||||
|
+ result["breakdown"]["hot_water_tax"]
|
||||||
|
+ result["fixed_breakdown"]["hot_water_network"]
|
||||||
|
)
|
||||||
|
if metric == "heating":
|
||||||
|
return result["breakdown"]["heating"] + sum(
|
||||||
|
(result["fixed_breakdown"][key] for key in (
|
||||||
|
"heating_network", "metering", "delivery_set", "other"
|
||||||
|
)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
return result["breakdown"][metric]
|
return result["breakdown"][metric]
|
||||||
return _getter
|
return _getter
|
||||||
|
|
||||||
|
|||||||
+111
-23
@@ -94,6 +94,12 @@ class MqttManager:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._client: mqtt.Client | None = None
|
self._client: mqtt.Client | None = None
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
# Serialize ownership changes without holding the callback lock while
|
||||||
|
# loop_stop() joins Paho's network thread.
|
||||||
|
self._lifecycle_lock = threading.Lock()
|
||||||
|
self._client_generation = 0
|
||||||
|
self._client_settings: tuple[object, ...] | None = None
|
||||||
|
self._client_loop_started = False
|
||||||
# Source replacement/removal is serialized independently from callback
|
# Source replacement/removal is serialized independently from callback
|
||||||
# bookkeeping. In particular, paho's loop_stop() joins its network
|
# bookkeeping. In particular, paho's loop_stop() joins its network
|
||||||
# thread, whose callback completion also needs ``_lock``.
|
# thread, whose callback completion also needs ``_lock``.
|
||||||
@@ -121,6 +127,7 @@ class MqttManager:
|
|||||||
@property
|
@property
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
"""True if the underlying paho client is currently connected."""
|
"""True if the underlying paho client is currently connected."""
|
||||||
|
with self._lock:
|
||||||
return self._connected
|
return self._connected
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -141,15 +148,23 @@ class MqttManager:
|
|||||||
logger.debug("MQTT not configured or not enabled — skipping connect.")
|
logger.debug("MQTT not configured or not enabled — skipping connect.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
with self._lifecycle_lock:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._start_client(settings)
|
if self._client is not None and self._client_settings == self._settings_key(settings):
|
||||||
|
thread = getattr(self._client, "_thread", None)
|
||||||
|
is_alive = getattr(thread, "is_alive", None)
|
||||||
|
if thread is None and not self._client_loop_started:
|
||||||
|
return
|
||||||
|
if thread is not None and (not callable(is_alive) or is_alive()):
|
||||||
|
return
|
||||||
|
self._replace_client(settings)
|
||||||
|
|
||||||
def disconnect(self) -> None:
|
def disconnect(self) -> None:
|
||||||
"""Disconnect from the broker and stop the background loop thread.
|
"""Disconnect from the broker and stop the background loop thread.
|
||||||
|
|
||||||
No-op if no client is active.
|
No-op if no client is active.
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lifecycle_lock:
|
||||||
self._stop_client()
|
self._stop_client()
|
||||||
with self._source_lifecycle_lock:
|
with self._source_lifecycle_lock:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -163,9 +178,23 @@ class MqttManager:
|
|||||||
Call this after saving updated MQTT configuration values.
|
Call this after saving updated MQTT configuration values.
|
||||||
No-op if MQTT is not enabled or broker host is not set.
|
No-op if MQTT is not enabled or broker host is not set.
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
if not _is_configured(settings):
|
||||||
|
# A configuration update only owns the application-wide client.
|
||||||
|
# DSMR sources have independent settings and lifecycle; only app
|
||||||
|
# shutdown via disconnect() is allowed to stop them all.
|
||||||
|
with self._lifecycle_lock:
|
||||||
self._stop_client()
|
self._stop_client()
|
||||||
self.connect(settings)
|
return
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
with self._lock:
|
||||||
|
if self._client is not None and self._client_settings == self._settings_key(settings):
|
||||||
|
thread = getattr(self._client, "_thread", None)
|
||||||
|
is_alive = getattr(thread, "is_alive", None)
|
||||||
|
if thread is None and not self._client_loop_started:
|
||||||
|
return
|
||||||
|
if thread is not None and (not callable(is_alive) or is_alive()):
|
||||||
|
return
|
||||||
|
self._replace_client(settings)
|
||||||
|
|
||||||
def publish(
|
def publish(
|
||||||
self,
|
self,
|
||||||
@@ -183,8 +212,9 @@ class MqttManager:
|
|||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
client = self._client
|
client = self._client
|
||||||
|
connected = self._connected
|
||||||
|
|
||||||
if client is None or not self._connected:
|
if client is None or not connected:
|
||||||
logger.debug("MQTT publish skipped — not connected (topic=%s).", topic)
|
logger.debug("MQTT publish skipped — not connected (topic=%s).", topic)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -212,6 +242,7 @@ class MqttManager:
|
|||||||
Handler exceptions are swallowed by the ``on_message`` dispatcher so
|
Handler exceptions are swallowed by the ``on_message`` dispatcher so
|
||||||
that a buggy handler can never crash the paho loop thread.
|
that a buggy handler can never crash the paho loop thread.
|
||||||
"""
|
"""
|
||||||
|
with self._lock:
|
||||||
self._subscriptions[topic] = handler
|
self._subscriptions[topic] = handler
|
||||||
# Grab the client reference outside the lock to avoid holding the lock
|
# Grab the client reference outside the lock to avoid holding the lock
|
||||||
# while calling back into paho (which may itself acquire internal locks).
|
# while calling back into paho (which may itself acquire internal locks).
|
||||||
@@ -235,8 +266,8 @@ class MqttManager:
|
|||||||
immediately; either way the handler is removed from the registry so it
|
immediately; either way the handler is removed from the registry so it
|
||||||
will not be re-subscribed on the next ``_on_connect``.
|
will not be re-subscribed on the next ``_on_connect``.
|
||||||
"""
|
"""
|
||||||
self._subscriptions.pop(topic, None)
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._subscriptions.pop(topic, None)
|
||||||
client = self._client
|
client = self._client
|
||||||
connected = self._connected
|
connected = self._connected
|
||||||
|
|
||||||
@@ -388,12 +419,35 @@ class MqttManager:
|
|||||||
# Internal helpers
|
# Internal helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _settings_key(settings: Settings) -> tuple[object, ...]:
|
||||||
|
return (
|
||||||
|
settings.mqtt_enabled,
|
||||||
|
settings.mqtt_broker_host,
|
||||||
|
settings.mqtt_broker_port,
|
||||||
|
settings.mqtt_username,
|
||||||
|
settings.mqtt_password,
|
||||||
|
settings.mqtt_tls_enabled,
|
||||||
|
settings.mqtt_client_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _replace_client(self, settings: Settings) -> None:
|
||||||
|
self._stop_client()
|
||||||
|
self._start_client(settings)
|
||||||
|
|
||||||
def _start_client(self, settings: Settings) -> None:
|
def _start_client(self, settings: Settings) -> None:
|
||||||
"""Build a fresh paho Client, configure it, and call loop_start + connect."""
|
"""Install one generation before starting Paho's reconnecting loop."""
|
||||||
|
with self._lock:
|
||||||
|
self._client_generation += 1
|
||||||
|
generation = self._client_generation
|
||||||
|
try:
|
||||||
client = mqtt.Client(
|
client = mqtt.Client(
|
||||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
client_id=settings.mqtt_client_id,
|
client_id=settings.mqtt_client_id,
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("MQTT client initialization failed.")
|
||||||
|
return
|
||||||
|
|
||||||
# Callbacks — VERSION2 on_connect signature:
|
# Callbacks — VERSION2 on_connect signature:
|
||||||
# (client, userdata, connect_flags, reason_code, properties)
|
# (client, userdata, connect_flags, reason_code, properties)
|
||||||
@@ -404,21 +458,21 @@ class MqttManager:
|
|||||||
reason_code: mqtt.ReasonCode,
|
reason_code: mqtt.ReasonCode,
|
||||||
_properties: mqtt.Properties | None,
|
_properties: mqtt.Properties | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._client is not _client or self._client_generation != generation:
|
||||||
|
return
|
||||||
if reason_code.is_failure:
|
if reason_code.is_failure:
|
||||||
logger.warning("MQTT connection refused: %s", reason_code)
|
|
||||||
self._connected = False
|
self._connected = False
|
||||||
else:
|
logger.warning("MQTT connection refused: %s", reason_code)
|
||||||
logger.info("MQTT connected (reason_code=%s).", reason_code)
|
return
|
||||||
self._connected = True
|
self._connected = True
|
||||||
# Re-establish all registered subscriptions after (re-)connect.
|
topics = tuple(self._subscriptions)
|
||||||
for sub_topic in self._subscriptions:
|
logger.info("MQTT connected (reason_code=%s).", reason_code)
|
||||||
|
for sub_topic in topics:
|
||||||
try:
|
try:
|
||||||
_client.subscribe(sub_topic)
|
_client.subscribe(sub_topic)
|
||||||
logger.debug("MQTT re-subscribed to topic=%s.", sub_topic)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception("MQTT re-subscribe failed (topic=%s).", sub_topic)
|
||||||
"MQTT re-subscribe failed (topic=%s).", sub_topic
|
|
||||||
)
|
|
||||||
|
|
||||||
# VERSION2 on_disconnect signature:
|
# VERSION2 on_disconnect signature:
|
||||||
# (client, userdata, disconnect_flags, reason_code, properties)
|
# (client, userdata, disconnect_flags, reason_code, properties)
|
||||||
@@ -429,6 +483,9 @@ class MqttManager:
|
|||||||
reason_code: mqtt.ReasonCode,
|
reason_code: mqtt.ReasonCode,
|
||||||
_properties: mqtt.Properties | None,
|
_properties: mqtt.Properties | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._client is not _client or self._client_generation != generation:
|
||||||
|
return
|
||||||
self._connected = False
|
self._connected = False
|
||||||
if reason_code.is_failure:
|
if reason_code.is_failure:
|
||||||
logger.warning("MQTT disconnected unexpectedly (reason_code=%s).", reason_code)
|
logger.warning("MQTT disconnected unexpectedly (reason_code=%s).", reason_code)
|
||||||
@@ -441,6 +498,9 @@ class MqttManager:
|
|||||||
_userdata: object,
|
_userdata: object,
|
||||||
message: mqtt.MQTTMessage,
|
message: mqtt.MQTTMessage,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._client is not _client or self._client_generation != generation:
|
||||||
|
return
|
||||||
handler = self._subscriptions.get(message.topic)
|
handler = self._subscriptions.get(message.topic)
|
||||||
if handler is None:
|
if handler is None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -469,35 +529,57 @@ class MqttManager:
|
|||||||
|
|
||||||
# Credentials — never log the password
|
# Credentials — never log the password
|
||||||
if settings.mqtt_username:
|
if settings.mqtt_username:
|
||||||
|
try:
|
||||||
client.username_pw_set(
|
client.username_pw_set(
|
||||||
username=settings.mqtt_username,
|
username=settings.mqtt_username,
|
||||||
password=settings.mqtt_password or None,
|
password=settings.mqtt_password or None,
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("MQTT credentials setup failed.")
|
||||||
|
return
|
||||||
|
|
||||||
# Start background loop thread *before* connect so paho can handle
|
# Publish ownership before network processing/callbacks can begin.
|
||||||
# the connection handshake asynchronously.
|
with self._lock:
|
||||||
client.loop_start()
|
self._client = client
|
||||||
|
self._client_settings = self._settings_key(settings)
|
||||||
|
self._connected = False
|
||||||
|
self._client_loop_started = False
|
||||||
try:
|
try:
|
||||||
client.connect(
|
client.connect_async(
|
||||||
host=settings.mqtt_broker_host,
|
host=settings.mqtt_broker_host,
|
||||||
port=settings.mqtt_broker_port,
|
port=settings.mqtt_broker_port,
|
||||||
keepalive=60,
|
keepalive=60,
|
||||||
)
|
)
|
||||||
|
result = client.loop_start()
|
||||||
|
if isinstance(result, int) and result != mqtt.MQTT_ERR_SUCCESS:
|
||||||
|
raise RuntimeError(f"MQTT loop_start failed (rc={result})")
|
||||||
|
with self._lock:
|
||||||
|
if self._client is client and self._client_generation == generation:
|
||||||
|
self._client_loop_started = True
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# Remove ownership before joining the loop; callbacks from this
|
||||||
|
# generation become inert immediately.
|
||||||
|
with self._lock:
|
||||||
|
if self._client is client and self._client_generation == generation:
|
||||||
|
self._client = None
|
||||||
|
self._client_settings = None
|
||||||
|
self._connected = False
|
||||||
|
self._client_loop_started = False
|
||||||
# Log without leaking the password
|
# Log without leaking the password
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"MQTT connect failed (host=%s, port=%s). Stopping loop.",
|
"MQTT connect failed (host=%s, port=%s). Stopping loop.",
|
||||||
settings.mqtt_broker_host,
|
settings.mqtt_broker_host,
|
||||||
settings.mqtt_broker_port,
|
settings.mqtt_broker_port,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
client.loop_stop()
|
client.loop_stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
self._client = client
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"MQTT client started (host=%s, port=%s).",
|
"MQTT client started (host=%s, port=%s).",
|
||||||
settings.mqtt_broker_host,
|
settings.mqtt_broker_host,
|
||||||
@@ -506,11 +588,17 @@ class MqttManager:
|
|||||||
|
|
||||||
def _stop_client(self) -> None:
|
def _stop_client(self) -> None:
|
||||||
"""Disconnect and stop the background loop. Idempotent."""
|
"""Disconnect and stop the background loop. Idempotent."""
|
||||||
|
with self._lock:
|
||||||
client = self._client
|
client = self._client
|
||||||
if client is None:
|
if client is None:
|
||||||
|
self._connected = False
|
||||||
|
self._client_settings = None
|
||||||
return
|
return
|
||||||
self._client = None
|
self._client = None
|
||||||
|
self._client_settings = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
self._client_loop_started = False
|
||||||
|
self._client_generation += 1
|
||||||
try:
|
try:
|
||||||
client.disconnect()
|
client.disconnect()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+6
-3
@@ -169,9 +169,12 @@ def _run_scheduled_ha_state_publish() -> None:
|
|||||||
session: Session = session_local()
|
session: Session = session_local()
|
||||||
try:
|
try:
|
||||||
runtime_settings = build_runtime_settings(session, get_settings())
|
runtime_settings = build_runtime_settings(session, get_settings())
|
||||||
# Reconnect if MQTT is configured (in DB) but not yet connected.
|
# Ensure the configured MQTT client is installed; the manager avoids
|
||||||
if mqtt_manager.is_configured(runtime_settings) and not mqtt_manager.is_connected:
|
# replacing it while Paho is already retrying a transient disconnect.
|
||||||
logger.info("_run_scheduled_ha_state_publish: MQTT configured but not connected — attempting connect.")
|
if mqtt_manager.is_configured(runtime_settings):
|
||||||
|
# connect() is idempotent for the same settings and retains a Paho
|
||||||
|
# client while its own loop is retrying a transient disconnect.
|
||||||
|
logger.info("_run_scheduled_ha_state_publish: ensuring MQTT client is active.")
|
||||||
mqtt_manager.connect(runtime_settings)
|
mqtt_manager.connect(runtime_settings)
|
||||||
publish_discovery(session)
|
publish_discovery(session)
|
||||||
publish_states(session)
|
publish_states(session)
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ services:
|
|||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
restart: "no"
|
restart: "no"
|
||||||
init: true
|
init: true
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
command: ["python", "-m", "scripts.run_migrations"]
|
command: ["python", "-m", "scripts.run_migrations"]
|
||||||
environment:
|
environment:
|
||||||
TZ: "${TZ:-Europe/Amsterdam}"
|
TZ: "${TZ:-Europe/Amsterdam}"
|
||||||
@@ -19,6 +24,11 @@ services:
|
|||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
environment:
|
environment:
|
||||||
TZ: "${TZ:-Europe/Amsterdam}"
|
TZ: "${TZ:-Europe/Amsterdam}"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1214,9 +1214,25 @@ M8 收尾的前置条件。agent 不得执行、记录为已执行,或以 mock
|
|||||||
|
|
||||||
## 14. Post-M8 lifecycle repair(M8-R08~R10)
|
## 14. Post-M8 lifecycle repair(M8-R08~R10)
|
||||||
|
|
||||||
|
### M8-R16 — HA Water Energy Discovery metadata
|
||||||
|
|
||||||
|
active `hot_water` Meter 的 total/today MQTT Discovery metadata 从通用 `volume` 修正为 Home
|
||||||
|
Assistant Energy Water 所要求的 `water`;保留 `m³` 与 `total_increasing`。区域供暖继续使用合法的
|
||||||
|
`energy + GJ` 组合,thermal EUR 成本实体以及既有 key、identity、unique_id 和 topic 均不变。
|
||||||
|
|
||||||
### M8-R15 — HA Discovery identity/topic repair
|
### M8-R15 — HA Discovery identity/topic repair
|
||||||
|
|
||||||
修复 HA 将多值 `device.identifiers` 任一匹配合并的风险:Source、Meter、Modbus 与成本 epoch 均使用单一完整 namespaced identifier,内部 MQTT identity 独立保存。thermal 的既有点号 identity/key 保持兼容,node/object topic segment 单独规范化;仅对 v1.6.1 实际发布过的 dot topic 做可重试、成功后抑制的精确 retained cleanup。新增默认关闭的 `Thermal Hot Water Total/Today`,金额为 `hot_water_heating + hot_water`,不含 `hot_water_tax`。
|
修复 HA 将多值 `device.identifiers` 任一匹配合并的风险:Source、Meter、Modbus 与成本 epoch 均使用单一完整 namespaced identifier,内部 MQTT identity 独立保存。thermal 的既有点号 identity/key 保持兼容,node/object topic segment 单独规范化;仅对 v1.6.1 实际发布过的 dot topic 做可重试、成功后抑制的精确 retained cleanup。新增默认关闭的 `Thermal Hot Water Total/Today`;其后由 R17 明确为 hot-water 的 all-in 分配值。
|
||||||
|
|
||||||
|
### M8-R17 — Thermal HA standing-cost allocation
|
||||||
|
|
||||||
|
保持全部 thermal HA entity key、unique_id 与名称不变,只调整 `Thermal Heating Total/Today` 与
|
||||||
|
`Thermal Hot Water Total/Today` 的数值语义。Heating 为 `heating` variable 加上
|
||||||
|
`heating_network`、`metering`、`delivery_set`、`other` 四项 fixed breakdown;Hot Water 为
|
||||||
|
`hot_water_heating + hot_water + hot_water_tax` 加上 `hot_water_network`。因此两者之和精确等于
|
||||||
|
`Thermal All-in Total/Today`,每项 standing 只分配一次。component 实体(hot-water heating、water、
|
||||||
|
water tax)继续只显示 variable,`Fixed` 继续显示全部 standing;provider 只读取 `meter_cost.summarize`
|
||||||
|
在 01:05 结算后的结果,不改 15 分钟 ledger、API/schema 或数据库。
|
||||||
|
|
||||||
M8 交付后的 Meter lifecycle 修复链记录在本地 `review-notes/M8-meter-lifecycle-repair-plan.md`。
|
M8 交付后的 Meter lifecycle 修复链记录在本地 `review-notes/M8-meter-lifecycle-repair-plan.md`。
|
||||||
它不新增 ORM / **数据库** schema 或 Alembic revision,不做启动自动修复、一次性数据脚本或历史删除;R08 虽然
|
它不新增 ORM / **数据库** schema 或 Alembic revision,不做启动自动修复、一次性数据脚本或历史删除;R08 虽然
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# MQTT 可靠性修复 — 2026-10-11
|
||||||
|
|
||||||
|
本次范围为 MQTT-FIX-T01 → MQTT-FIX-T02,依赖顺序执行,两张卡自动继续。
|
||||||
|
按 [AGENTS.md](../../AGENTS.md) 与 [协作契约](README.md) 实现、独立审查及返工。
|
||||||
|
`done` 仅代表源码自动化技术验收完成;**源码修复完成后仍须人工部署与生产验证**。
|
||||||
|
中文实现、审查及返工简报存放于已忽略的 `review-notes/`,不入库。
|
||||||
|
|
||||||
|
## 证据与边界
|
||||||
|
|
||||||
|
排查日期 2026-10-11,Europe/Amsterdam。以下生产观察由用户提供,未在本轮访问生产确认:
|
||||||
|
|
||||||
|
1. 应用当天约 01:00 启动,故障前未自动重启、未发生 OOM。
|
||||||
|
2. 16:50:43 起出现 `MQTT disconnected unexpectedly (reason_code=Unspecified error).`。
|
||||||
|
3. 截至约 22:34 累计约 270 万条断线日志;22:34–22:35 一分钟内 15,283 次断线、101 次 publish rejected。
|
||||||
|
4. 一个 uvicorn 进程、284 个总线程;并非已经确认 284 个 MQTT 客户端。
|
||||||
|
5. 主 client ID 为 `home-automation`;配置最后更新时间为 2026-08-28,当天未观察到变更。
|
||||||
|
6. Modbus、WarmteLink 串口及独立 DSMR MQTT source 仍有新数据,不能描述为 broker 整体停止服务。
|
||||||
|
7. 生产镜像的 `app/main.py`、`app/integrations/mqtt.py` SHA256 与源码一致。
|
||||||
|
8. 用户在 broker 日志中看到大量新连接及 `session taken over`。
|
||||||
|
9. 22:37 用户授权重启应用后,观察超过一分钟未见新异常断线或发布失败,各采集源恢复;仅属临时恢复。
|
||||||
|
10. 首次断线触发原因未知,不能将网络故障或 broker 重启写成事实。
|
||||||
|
|
||||||
|
待独立核实的链路:60 秒 HA 发布任务在未连接时调用 `connect()`;它无条件创建同 ID Paho
|
||||||
|
client/线程、覆盖引用但未停旧 client;旧回调能覆盖共享连接状态。已有离线 fake 复现只是
|
||||||
|
排查线索,不能代替本轮回归测试。
|
||||||
|
|
||||||
|
部署背景:生产日志配置为 `{"Type":"json-file","Config":{}}`;约 276 万行日志,按内容
|
||||||
|
估算 JSON 文件约 353 MiB(非 stat 精确大小),当时约 666 GB 空闲,无即时容量危机。
|
||||||
|
生产 Compose 在 `/home/tianyu/.local/share/homeautomation`,与源码 Compose 是两个文件。
|
||||||
|
|
||||||
|
**本轮禁止**:push、tag、改写已推送历史、修改生产配置、重启/重建生产容器、连接真实
|
||||||
|
MQTT broker(包括 `10.238.75.82:1883`)、访问真实串口、改动生产数据库或日志;禁止
|
||||||
|
清空历史日志、删除 Docker 文件、prune、变更全局 Docker daemon。仅提供部署步骤。
|
||||||
|
允许源码/测试/部署文档修改、本地 commit、fixup 及整理本轮尚未推送提交。保留已有无关改动。
|
||||||
|
|
||||||
|
### MQTT-FIX-T01 — 修复主 MQTT 客户端生命周期
|
||||||
|
|
||||||
|
- **Status**: `done`
|
||||||
|
- **Depends**: none
|
||||||
|
- **Context**: 防止短暂断线后定时任务创建同 ID 客户端累积,避免旧回调污染新连接。
|
||||||
|
|
||||||
|
**Files**
|
||||||
|
- `modify app/integrations/mqtt.py`
|
||||||
|
- `modify app/main.py`
|
||||||
|
- `modify tests/test_mqtt_client.py`
|
||||||
|
- `modify tests/test_mqtt_subscribe.py`
|
||||||
|
- `create tests/test_mqtt_scheduler.py`(必要的调度任务回归)
|
||||||
|
- `modify README.md`(MQTT 生命周期说明)
|
||||||
|
- `modify .gitignore`(编排过程报告目录保护)
|
||||||
|
- `create docs/design/mqtt-reliability-fixes.md`(主线维护任务与 Status)
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. 独立核实当前生命周期、回调及调度代码,结合安装的 Paho 2.1.0 源码核对启动顺序和失败清理。
|
||||||
|
2. 使同配置 `connect()` 幂等,区分正在连接、等待自动重连、无客户端;现有 client 能自动重连时不再创建额外 client。
|
||||||
|
3. 初始连接失败、初始化失败、后台循环失效均须可恢复,失败对象/循环清理,不能永久跳过重试。
|
||||||
|
4. 配置变更安全停止、清理旧 client 后替换,受管理的主 client 只有一个。
|
||||||
|
5. connect/disconnect/message 回调核验身份或 generation,拒绝旧状态及过期消息。
|
||||||
|
6. 处理创建、替换、关闭与回调并发;不能持有回调需要的锁调用等待网络线程退出的 `loop_stop()`。
|
||||||
|
7. 保持独立 DSMR source、订阅恢复、MQTT 测试身份及现有 publish 行为兼容。
|
||||||
|
8. 增加离线故障与并发回归,更新生命周期说明,运行适用闸门并保存中文实现简报。
|
||||||
|
|
||||||
|
**Out of scope / 不要碰**
|
||||||
|
- 不使用随机 ID、单纯换 ID 或降低日志级别掩盖问题。
|
||||||
|
- 不改变 HTTP 路由/schema/docstring;如确有必要变更,须同步 OpenAPI 和前端类型。
|
||||||
|
- 不改变旧 milestone 完成状态;不访问生产或真实设备;不处理已有 Compose 串口改动。
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- [x] 同配置重复 connect 及断线后多轮定时任务不累积 client 或后台循环。
|
||||||
|
- [x] 初次连接/初始化失败后能重试,失败对象和循环被清理,循环失效也有恢复路径。
|
||||||
|
- [x] 配置重连后旧 client 停止,旧 connect/disconnect/message 回调不能影响新状态或处理过期消息。
|
||||||
|
- [x] 当前 client 重连成功后恢复订阅与发布。
|
||||||
|
- [x] shutdown、reconnect 与回调交错时能完成、不死锁。
|
||||||
|
- [x] 新测试使用 mock/fake 及合成配置,不连接生产 broker 或真实串口;覆盖行为与并发边界。
|
||||||
|
- [x] DSMR source、测试 client ID 及现有 publish 行为兼容。
|
||||||
|
- [x] pytest、ruff 全绿,独立 Reviewer 重跑;如适用同步契约/检查构建上下文。
|
||||||
|
|
||||||
|
**Reviewer checklist**
|
||||||
|
- 独立核查 Paho 2.1.0 生命周期顺序、线程泄漏、状态污染、死锁及失败恢复,不仅断言内部实现细节。
|
||||||
|
- 核查调度触发路径、多轮断线自动重连、配置替换与关闭交错,以及旧消息拒绝。
|
||||||
|
- 核查 DSMR 独立客户端及订阅/publish/test identity 回归风险。
|
||||||
|
- 先读对应实现/返工简报,独立重跑闸门;报告 PASS 或编号返工清单,不改实现或 Git。
|
||||||
|
|
||||||
|
### MQTT-FIX-T02 — 为 Docker 日志配置轮转
|
||||||
|
|
||||||
|
- **Status**: `done`
|
||||||
|
- **Depends**: MQTT-FIX-T01
|
||||||
|
- **Context**: 为本项目容器限定日志保留,明确源码修改与生产容器应用配置是两个操作。
|
||||||
|
|
||||||
|
**Files**
|
||||||
|
- `modify docker-compose.yml`
|
||||||
|
- `modify docker-compose.dev.yml`(仅必要时)
|
||||||
|
- `modify README.md`(部署说明)
|
||||||
|
- `modify tests/test_deployment.py`(必要的静态检查)
|
||||||
|
- `modify docs/design/mqtt-reliability-fixes.md`(主线维护 Status)
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. 为 app 与 migration 均配置 `logging.driver: json-file`,字符串 options `max-size: "10m"`、`max-file: "3"`。
|
||||||
|
2. 验证开发 override 合并后保留相同合理的轮转配置,不修改全局 daemon 或其他项目。
|
||||||
|
3. 用 `docker compose config --quiet` 或等效静态检查验证基础与合并配置;不输出含凭据的完整配置。
|
||||||
|
4. 说明宿主机日志路径 `/var/lib/docker/containers/<完整ID>/<完整ID>-json.log`、`docker logs`、保留策略。
|
||||||
|
5. 明确仅 `docker restart` 不应用 logging 配置,需要重建容器。
|
||||||
|
6. 给出生产目录 `/home/tianyu/.local/share/homeautomation` 中的最小配置同步、重建、验证与回退步骤。
|
||||||
|
7. 写清发布源码修复镜像与仅应用日志配置各自前提,以及短暂采集中断;不覆盖生产设备/volume/env 等配置。
|
||||||
|
8. 保存中文简报,跑 pytest / ruff 和配置验证,独立 Reviewer 核查可执行性。
|
||||||
|
|
||||||
|
**Out of scope / 不要碰**
|
||||||
|
- 不自动部署,不清空或删除日志,不 prune,不触碰设备历史数据。
|
||||||
|
- 不修改全局 Docker daemon;不把源码配置变更描述为生产已生效。
|
||||||
|
- 不纳入用户已有的串口映射/group_add 改动。
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- [x] app、migration 最终 Compose 均含指定字符串 logging options。
|
||||||
|
- [x] 开发 override 合并后日志配置正确。
|
||||||
|
- [x] Compose 经 config --quiet 或等效静态检查,不输出凭据。
|
||||||
|
- [x] 文档说明日志路径、docker logs、保留策略、restart 无效而须重建。
|
||||||
|
- [x] 文档明确生产路径、最小同步、镜像更新前提、采集中断、验证与回退;区分镜像发布和日志配置应用。
|
||||||
|
- [x] 无生产操作、数据/日志删除,已有无关改动保留。
|
||||||
|
- [x] pytest、ruff 全绿,独立 Reviewer 重跑并核查合并及部署可执行性。
|
||||||
|
|
||||||
|
**Reviewer checklist**
|
||||||
|
- 核查 logging options 类型及基础/开发配置合并;静态配置检查的范围和凭据保护。
|
||||||
|
- 核查生产/source Compose 分离,镜像构建发布前提与 logging 重建步骤是否明确。
|
||||||
|
- 核查只涉及 app/migration,不变更串口、volume、生产 DB、全局 daemon;说明旧日志在重建时的 Docker 生命周期影响及可回退方式。
|
||||||
|
- 先读对应实现/返工简报,独立重跑闸门,给 PASS 或编号返工清单。
|
||||||
@@ -77,7 +77,13 @@ docker compose -f docker-compose.yml run --rm migration
|
|||||||
|
|
||||||
成本页的 15 分钟 ledger 分开显示 heating 与 hot-water 三项 variable breakdown;fixed 费只在合同级 summary 按本地自然日计提一次,all-in = variable + fixed。用显式 recompute 来验证测试时间窗时,应手算并核对 Decimal 金额,保留原有 electricity 合同和数字不变。
|
成本页的 15 分钟 ledger 分开显示 heating 与 hot-water 三项 variable breakdown;fixed 费只在合同级 summary 按本地自然日计提一次,all-in = variable + fixed。用显式 recompute 来验证测试时间窗时,应手算并核对 Decimal 金额,保留原有 electricity 合同和数字不变。
|
||||||
|
|
||||||
在 Config 的 HA Expose 中只开启需要的 source、Meter 与 thermal entities。核对 unit、state class、availability、today reset 和换表后 identity;关闭 toggle 后应用会清理 retained discovery。不要把 source secret、设备 identity 或合同金额放进 HA entity 名称、日志或截图。
|
在 Config 的 HA Expose 中只开启需要的 source、Meter 与 thermal entities。`Thermal Heating Total/Today`
|
||||||
|
显示 heating variable 加上 heating network、metering、delivery set 与 other 固定费;`Thermal Hot Water
|
||||||
|
Total/Today` 显示 hot-water heating、water、water tax variable 加上 hot-water network 固定费。两条合计
|
||||||
|
恰好等于 `Thermal All-in Total/Today`;component entities 仍仅显示各自 variable,`Fixed` 仍显示全部
|
||||||
|
standing,避免重复计费。数值仍来自在 01:05 后结算的 summary。核对 unit、state class、availability、today
|
||||||
|
reset 和换表后 identity;关闭 toggle 后应用会清理 retained discovery。不要把 source secret、设备 identity
|
||||||
|
或合同金额放进 HA entity 名称、日志或截图。
|
||||||
|
|
||||||
## 安全回滚
|
## 安全回滚
|
||||||
|
|
||||||
|
|||||||
Generated
+99
-221
@@ -22,13 +22,14 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-feather": "^2.0.10",
|
"react-feather": "^2.0.10",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.4",
|
"react-router-dom": "^7.18.3",
|
||||||
"recharts": "^3.8.1"
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@testing-library/dom": "10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^14.3.1",
|
"@testing-library/react": "16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^18.3.31",
|
"@types/react": "^18.3.31",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
@@ -1390,9 +1391,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@redocly/openapi-core": {
|
"node_modules/@redocly/openapi-core": {
|
||||||
"version": "1.34.15",
|
"version": "1.34.19",
|
||||||
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz",
|
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.19.tgz",
|
||||||
"integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==",
|
"integrity": "sha512-o/0VgsBXgwcY1lyeqcVtSGdTQAPnVggo0fbFVPlxl5XVDKUcVH0OLRqt3CbkwByT5FU305E0iE0O7MzThjDblw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1401,7 +1402,7 @@
|
|||||||
"colorette": "1.4.0",
|
"colorette": "1.4.0",
|
||||||
"https-proxy-agent": "7.0.6",
|
"https-proxy-agent": "7.0.6",
|
||||||
"js-levenshtein": "1.1.6",
|
"js-levenshtein": "1.1.6",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.3.1",
|
||||||
"minimatch": "5.1.9",
|
"minimatch": "5.1.9",
|
||||||
"pluralize": "8.0.0",
|
"pluralize": "8.0.0",
|
||||||
"yaml-ast-parser": "0.0.43"
|
"yaml-ast-parser": "0.0.43"
|
||||||
@@ -1412,9 +1413,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1422,10 +1423,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@redocly/openapi-core/node_modules/js-yaml": {
|
"node_modules/@redocly/openapi-core/node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -1483,15 +1494,6 @@
|
|||||||
"url": "https://opencollective.com/immer"
|
"url": "https://opencollective.com/immer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/router": {
|
|
||||||
"version": "1.23.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
|
||||||
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1591,9 +1593,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1608,9 +1607,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1625,9 +1621,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1642,9 +1635,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1659,9 +1649,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1676,9 +1663,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1693,9 +1677,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1710,9 +1691,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1727,9 +1705,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1744,9 +1719,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1761,9 +1733,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1778,9 +1747,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1795,9 +1761,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1932,7 +1895,6 @@
|
|||||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.10.4",
|
"@babel/code-frame": "^7.10.4",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
@@ -1975,52 +1937,31 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/react": {
|
"node_modules/@testing-library/react": {
|
||||||
"version": "14.3.1",
|
"version": "16.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
|
||||||
"integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==",
|
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5"
|
||||||
"@testing-library/dom": "^9.0.0",
|
|
||||||
"@types/react-dom": "^18.0.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.0.0",
|
"@testing-library/dom": "^10.0.0",
|
||||||
"react-dom": "^18.0.0"
|
"@types/react": "^18.0.0 || ^19.0.0",
|
||||||
|
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/@testing-library/react/node_modules/@testing-library/dom": {
|
|
||||||
"version": "9.3.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
|
|
||||||
"integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/code-frame": "^7.10.4",
|
|
||||||
"@babel/runtime": "^7.12.5",
|
|
||||||
"@types/aria-query": "^5.0.1",
|
|
||||||
"aria-query": "5.1.3",
|
|
||||||
"chalk": "^4.1.0",
|
|
||||||
"dom-accessibility-api": "^0.5.9",
|
|
||||||
"lz-string": "^1.5.0",
|
|
||||||
"pretty-format": "^27.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testing-library/react/node_modules/aria-query": {
|
|
||||||
"version": "5.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
|
|
||||||
"integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"deep-equal": "^2.0.5"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/user-event": {
|
"node_modules/@testing-library/user-event": {
|
||||||
@@ -2441,16 +2382,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
@@ -2979,9 +2920,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3188,6 +3129,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3451,39 +3405,6 @@
|
|||||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/deep-equal": {
|
|
||||||
"version": "2.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
|
|
||||||
"integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"array-buffer-byte-length": "^1.0.0",
|
|
||||||
"call-bind": "^1.0.5",
|
|
||||||
"es-get-iterator": "^1.1.3",
|
|
||||||
"get-intrinsic": "^1.2.2",
|
|
||||||
"is-arguments": "^1.1.1",
|
|
||||||
"is-array-buffer": "^3.0.2",
|
|
||||||
"is-date-object": "^1.0.5",
|
|
||||||
"is-regex": "^1.1.4",
|
|
||||||
"is-shared-array-buffer": "^1.0.2",
|
|
||||||
"isarray": "^2.0.5",
|
|
||||||
"object-is": "^1.1.5",
|
|
||||||
"object-keys": "^1.1.1",
|
|
||||||
"object.assign": "^4.1.4",
|
|
||||||
"regexp.prototype.flags": "^1.5.1",
|
|
||||||
"side-channel": "^1.0.4",
|
|
||||||
"which-boxed-primitive": "^1.0.2",
|
|
||||||
"which-collection": "^1.0.1",
|
|
||||||
"which-typed-array": "^1.1.13"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -3687,27 +3608,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-get-iterator": {
|
|
||||||
"version": "1.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
|
|
||||||
"integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind": "^1.0.2",
|
|
||||||
"get-intrinsic": "^1.1.3",
|
|
||||||
"has-symbols": "^1.0.3",
|
|
||||||
"is-arguments": "^1.1.1",
|
|
||||||
"is-map": "^2.0.2",
|
|
||||||
"is-set": "^2.0.2",
|
|
||||||
"is-string": "^1.0.7",
|
|
||||||
"isarray": "^2.0.5",
|
|
||||||
"stop-iteration-iterator": "^1.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-iterator-helpers": {
|
"node_modules/es-iterator-helpers": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz",
|
||||||
@@ -4650,23 +4550,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-arguments": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"has-tostringtag": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -5121,9 +5004,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.2.0",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -5414,9 +5297,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -5490,23 +5373,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/object-is": {
|
|
||||||
"version": "1.1.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
|
||||||
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind": "^1.0.7",
|
|
||||||
"define-properties": "^1.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-keys": {
|
"node_modules/object-keys": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||||
@@ -5842,9 +5708,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -5862,7 +5728,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.17",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -6092,35 +5958,41 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "6.30.4",
|
"version": "7.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
|
||||||
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
|
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.3"
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=16.8"
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "6.30.4",
|
"version": "7.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
|
||||||
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
|
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.3",
|
"react-router": "7.18.3"
|
||||||
"react-router": "6.30.4"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=16.8",
|
"react": ">=18",
|
||||||
"react-dom": ">=16.8"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-style-singleton": {
|
"node_modules/react-style-singleton": {
|
||||||
@@ -6447,6 +6319,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -7080,9 +6958,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.27.2",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||||
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
|
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -27,13 +27,14 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-feather": "^2.0.10",
|
"react-feather": "^2.0.10",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.4",
|
"react-router-dom": "^7.18.3",
|
||||||
"recharts": "^3.8.1"
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@testing-library/dom": "10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^14.3.1",
|
"@testing-library/react": "16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^18.3.31",
|
"@types/react": "^18.3.31",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
|
|||||||
@@ -462,12 +462,17 @@ describe('CostView', () => {
|
|||||||
|
|
||||||
it('paginates the complete thermal ledger and resets offset when its range or scope changes', async () => {
|
it('paginates the complete thermal ledger and resets offset when its range or scope changes', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const lastRow = { ...THERMAL_ROW, period_start: '2026-06-22T12:00:00Z', quantity: '501' }
|
const firstPageRows = Array.from({ length: 500 }, (_, index) => {
|
||||||
|
const periodStart = new Date(Date.UTC(2026, 5, 22, 10, index * 15))
|
||||||
|
const periodEnd = new Date(Date.UTC(2026, 5, 22, 10, (index + 1) * 15))
|
||||||
|
return { ...THERMAL_ROW, period_start: periodStart.toISOString(), period_end: periodEnd.toISOString() }
|
||||||
|
})
|
||||||
|
const lastRow = { ...THERMAL_ROW, period_start: '2026-06-27T15:00:00Z', period_end: '2026-06-27T15:15:00Z', quantity: '501' }
|
||||||
mockGet.mockImplementation((path: string, options?: { params?: { query?: { offset?: number } } }) => {
|
mockGet.mockImplementation((path: string, options?: { params?: { query?: { offset?: number } } }) => {
|
||||||
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], total: 2 } })
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], total: 2 } })
|
||||||
if (path === '/api/energy/meter-costs') {
|
if (path === '/api/energy/meter-costs') {
|
||||||
const offset = options?.params?.query?.offset ?? 0
|
const offset = options?.params?.query?.offset ?? 0
|
||||||
return Promise.resolve({ data: offset === 0 ? { items: Array.from({ length: 500 }, () => THERMAL_ROW), total: 501 } : { items: [lastRow], total: 501 } })
|
return Promise.resolve({ data: offset === 0 ? { items: firstPageRows, total: 501 } : { items: [lastRow], total: 501 } })
|
||||||
}
|
}
|
||||||
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
|
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
|
||||||
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
|
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { screen, waitFor } from '@testing-library/react'
|
import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { renderWithProviders } from '../test-utils'
|
import { renderWithProviders } from '../test-utils'
|
||||||
import { MeterManager } from './MeterManager'
|
import { MeterManager } from './MeterManager'
|
||||||
@@ -1197,20 +1197,24 @@ describe('MeterManager — lifecycle modal submissions', () => {
|
|||||||
|
|
||||||
let input: HTMLInputElement
|
let input: HTMLInputElement
|
||||||
let submit: HTMLElement
|
let submit: HTMLElement
|
||||||
|
let modal: HTMLElement
|
||||||
if (entry === 'close') {
|
if (entry === 'close') {
|
||||||
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
|
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
|
||||||
input = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
modal = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`)
|
||||||
|
input = modal.querySelector('input[type="datetime-local"]')!
|
||||||
submit = screen.getAllByRole('button', { name: 'Close meter' })[1]
|
submit = screen.getAllByRole('button', { name: 'Close meter' })[1]
|
||||||
} else if (entry === 'unbind') {
|
} else if (entry === 'unbind') {
|
||||||
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
|
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
|
||||||
input = screen.getByTestId('unbind-modal-pending-source').querySelector('input[type="datetime-local"]')!
|
modal = screen.getByTestId('unbind-modal-pending-source')
|
||||||
|
input = modal.querySelector('input[type="datetime-local"]')!
|
||||||
submit = screen.getAllByRole('button', { name: 'Unbind' })[1]
|
submit = screen.getAllByRole('button', { name: 'Unbind' })[1]
|
||||||
} else {
|
} else {
|
||||||
await user.click(await screen.findByRole('button', { name: entry === 'direct bind' ? 'Bind source' : entry === 'same-meter transfer' ? 'Transfer source' : 'Recover binding' }))
|
await user.click(await screen.findByRole('button', { name: entry === 'direct bind' ? 'Bind source' : entry === 'same-meter transfer' ? 'Transfer source' : 'Recover binding' }))
|
||||||
await chooseChannel(user)
|
await chooseChannel(user)
|
||||||
input = entry === 'direct bind'
|
modal = entry === 'direct bind'
|
||||||
? screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
? screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`)
|
||||||
: screen.getByTestId('transfer-effective-at')
|
: screen.getByTestId('transfer-modal-pending-source')
|
||||||
|
input = entry === 'direct bind' ? modal.querySelector('input[type="datetime-local"]')! : screen.getByTestId('transfer-effective-at')
|
||||||
const submitButtons = screen.getAllByRole('button', { name: entry === 'direct bind' ? 'Bind source' : 'Transfer binding' })
|
const submitButtons = screen.getAllByRole('button', { name: entry === 'direct bind' ? 'Bind source' : 'Transfer binding' })
|
||||||
submit = submitButtons[submitButtons.length - 1]
|
submit = submitButtons[submitButtons.length - 1]
|
||||||
}
|
}
|
||||||
@@ -1224,6 +1228,7 @@ describe('MeterManager — lifecycle modal submissions', () => {
|
|||||||
await user.type(input, '{Enter}')
|
await user.type(input, '{Enter}')
|
||||||
expect(entry === 'unbind' ? mockPatch : mockPost).toHaveBeenCalledTimes(1)
|
expect(entry === 'unbind' ? mockPatch : mockPost).toHaveBeenCalledTimes(1)
|
||||||
release()
|
release()
|
||||||
|
await waitForElementToBeRemoved(modal)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
import { MantineProvider } from '@mantine/core'
|
import { MantineProvider } from '@mantine/core'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
@@ -199,7 +199,7 @@ describe('HomePage', () => {
|
|||||||
altitude: null,
|
altitude: null,
|
||||||
}
|
}
|
||||||
expect(capturedOnSelectLocation).toBeDefined()
|
expect(capturedOnSelectLocation).toBeDefined()
|
||||||
capturedOnSelectLocation!(record)
|
act(() => capturedOnSelectLocation!(record))
|
||||||
|
|
||||||
// EditLocationModal should appear
|
// EditLocationModal should appear
|
||||||
await waitFor(() => screen.getByTestId('edit-location-modal'))
|
await waitFor(() => screen.getByTestId('edit-location-modal'))
|
||||||
@@ -216,7 +216,7 @@ describe('HomePage', () => {
|
|||||||
longitude: 116.41,
|
longitude: 116.41,
|
||||||
}
|
}
|
||||||
expect(capturedOnSelectPoo).toBeDefined()
|
expect(capturedOnSelectPoo).toBeDefined()
|
||||||
capturedOnSelectPoo!(record)
|
act(() => capturedOnSelectPoo!(record))
|
||||||
|
|
||||||
await waitFor(() => screen.getByTestId('edit-poo-modal'))
|
await waitFor(() => screen.getByTestId('edit-poo-modal'))
|
||||||
expect(screen.getByTestId('edit-poo-modal')).toBeTruthy()
|
expect(screen.getByTestId('edit-poo-modal')).toBeTruthy()
|
||||||
@@ -232,7 +232,7 @@ describe('HomePage', () => {
|
|||||||
longitude: 116.4,
|
longitude: 116.4,
|
||||||
altitude: null,
|
altitude: null,
|
||||||
}
|
}
|
||||||
capturedOnSelectLocation!(record)
|
act(() => capturedOnSelectLocation!(record))
|
||||||
await waitFor(() => screen.getByTestId('edit-location-modal'))
|
await waitFor(() => screen.getByTestId('edit-location-modal'))
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('edit-location-cancel'))
|
fireEvent.click(screen.getByTestId('edit-location-cancel'))
|
||||||
@@ -248,7 +248,7 @@ describe('HomePage', () => {
|
|||||||
latitude: 39.91,
|
latitude: 39.91,
|
||||||
longitude: 116.41,
|
longitude: 116.41,
|
||||||
}
|
}
|
||||||
capturedOnSelectPoo!(record)
|
act(() => capturedOnSelectPoo!(record))
|
||||||
await waitFor(() => screen.getByTestId('edit-poo-modal'))
|
await waitFor(() => screen.getByTestId('edit-poo-modal'))
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('edit-poo-cancel'))
|
fireEvent.click(screen.getByTestId('edit-poo-cancel'))
|
||||||
|
|||||||
@@ -7,6 +7,28 @@
|
|||||||
* - ResizeObserver (Mantine uses it for responsive components)
|
* - ResizeObserver (Mantine uses it for responsive components)
|
||||||
*/
|
*/
|
||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
|
// Import RTL here so its automatic cleanup hook is registered before the
|
||||||
|
// warning assertion below; later test imports reuse the same module instance.
|
||||||
|
import { cleanup } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach } from 'vitest'
|
||||||
|
import { assertNoReactTestWarnings, createReactWarningGuard } from './test-warning-guard'
|
||||||
|
|
||||||
|
const originalConsoleError = console.error
|
||||||
|
const reactTestWarnings: unknown[][] = []
|
||||||
|
|
||||||
|
console.error = createReactWarningGuard(originalConsoleError, reactTestWarnings)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
reactTestWarnings.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Run teardown before checking the guard. RTL's automatic cleanup is also
|
||||||
|
// registered, but cleanup is idempotent and this ordering keeps a guard
|
||||||
|
// failure from preventing a later test from starting with stale portals.
|
||||||
|
cleanup()
|
||||||
|
assertNoReactTestWarnings(reactTestWarnings)
|
||||||
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// window.matchMedia polyfill (jsdom does not implement this)
|
// window.matchMedia polyfill (jsdom does not implement this)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import { useMantineEnv } from '@mantine/core'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { renderWithProviders } from './test-utils'
|
||||||
|
|
||||||
|
function MantineEnvironmentProbe() {
|
||||||
|
return <output>{useMantineEnv()}</output>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('renderWithProviders', () => {
|
||||||
|
it('uses Mantine test environment', () => {
|
||||||
|
renderWithProviders(<MantineEnvironmentProbe />)
|
||||||
|
|
||||||
|
expect(screen.getByText('test')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -51,7 +51,7 @@ export function renderWithProviders(ui: ReactNode, options: RenderOptions = {})
|
|||||||
|
|
||||||
function Wrapper() {
|
function Wrapper() {
|
||||||
return (
|
return (
|
||||||
<MantineProvider>
|
<MantineProvider env="test">
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter initialEntries={entries}>
|
<MemoryRouter initialEntries={entries}>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
assertNoReactTestWarnings,
|
||||||
|
createReactWarningGuard,
|
||||||
|
formatReactTestWarnings,
|
||||||
|
isReactTestWarning,
|
||||||
|
} from './test-warning-guard'
|
||||||
|
|
||||||
|
describe('React test warning guard', () => {
|
||||||
|
it('recognizes React act and duplicate-key warnings only', () => {
|
||||||
|
expect(isReactTestWarning(['Warning: An update to Example inside a test was not wrapped in act(...).'])).toBe(true)
|
||||||
|
expect(isReactTestWarning(['Warning: Each child in a list should have a unique "key" prop.'])).toBe(true)
|
||||||
|
expect(isReactTestWarning(['Warning: Encountered two children with the same key, `duplicate`.'])).toBe(true)
|
||||||
|
expect(isReactTestWarning(['network request failed'])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collects target warnings for one compact diagnostic and forwards unrelated errors', () => {
|
||||||
|
const originalConsoleError = vi.fn()
|
||||||
|
const warnings: unknown[][] = []
|
||||||
|
const guardedConsoleError = createReactWarningGuard(originalConsoleError, warnings)
|
||||||
|
|
||||||
|
guardedConsoleError('Warning: An update to Example inside a test was not wrapped in act(...)')
|
||||||
|
guardedConsoleError('Warning: An update to Other inside a test was not wrapped in act(...)')
|
||||||
|
guardedConsoleError('Warning: Encountered two children with the same key, `duplicate`.')
|
||||||
|
guardedConsoleError('network request failed', { status: 500 })
|
||||||
|
|
||||||
|
expect(originalConsoleError).toHaveBeenCalledTimes(1)
|
||||||
|
expect(originalConsoleError).toHaveBeenCalledWith('network request failed', { status: 500 })
|
||||||
|
expect(formatReactTestWarnings(warnings)).toBe(
|
||||||
|
'React act warning: 2, React duplicate-key warning: 1 (Example, Other)',
|
||||||
|
)
|
||||||
|
expect(() => assertNoReactTestWarnings(warnings)).toThrow(
|
||||||
|
'React test warning guard: React act warning: 2, React duplicate-key warning: 1 (Example, Other).',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes only when no target warnings were captured', () => {
|
||||||
|
expect(() => assertNoReactTestWarnings([])).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Fail tests that emit React warnings which otherwise only reach console.error.
|
||||||
|
*
|
||||||
|
* The guard intentionally forwards every unrelated console error unchanged.
|
||||||
|
* It collects only the two warnings that make React test results unreliable and
|
||||||
|
* reports one compact error per test instead of flooding CI logs with stacks.
|
||||||
|
*/
|
||||||
|
export function isReactTestWarning(args: unknown[]): boolean {
|
||||||
|
const message = args.map((arg) => String(arg)).join(' ')
|
||||||
|
return (
|
||||||
|
message.includes('not wrapped in act(...)') ||
|
||||||
|
message.includes('Each child in a list should have a unique "key"') ||
|
||||||
|
message.includes('Encountered two children with the same key')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReactWarningGuard(
|
||||||
|
originalConsoleError: (...args: unknown[]) => void,
|
||||||
|
warnings: unknown[][],
|
||||||
|
): (...args: unknown[]) => void {
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
if (isReactTestWarning(args)) {
|
||||||
|
warnings.push(args)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
originalConsoleError(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReactTestWarnings(warnings: unknown[][]): string {
|
||||||
|
const kinds = new Map<string, number>()
|
||||||
|
for (const args of warnings) {
|
||||||
|
const kind = args.map((arg) => String(arg)).join(' ').includes('not wrapped in act(...)')
|
||||||
|
? 'React act warning'
|
||||||
|
: 'React duplicate-key warning'
|
||||||
|
kinds.set(kind, (kinds.get(kind) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
const summaries = [...kinds.entries()].map(([kind, count]) => `${kind}: ${count}`).join(', ')
|
||||||
|
const components = [...new Set(warnings
|
||||||
|
.map((args) => {
|
||||||
|
const message = String(args[0])
|
||||||
|
return message.includes('An update to %s inside a test') ? String(args[1]) : message.match(/An update to (.+?) inside a test/)?.[1]
|
||||||
|
})
|
||||||
|
.filter((component): component is string => Boolean(component)))].slice(0, 3)
|
||||||
|
return components.length > 0 ? `${summaries} (${components.join(', ')})` : summaries
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertNoReactTestWarnings(warnings: unknown[][]): void {
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`React test warning guard: ${formatReactTestWarnings(warnings)}. ` +
|
||||||
|
'Await the observable update, close/removal, or query settlement that caused it.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,13 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
|
// Keep test output identical in interactive, agent, and CI environments.
|
||||||
|
// Vitest otherwise selects its minimal agent reporter when CODEX_CI is set,
|
||||||
|
// which hides console output from passing tests.
|
||||||
|
reporters: ['default'],
|
||||||
|
// Mantine-heavy UI tests allocate substantial jsdom resources. Keep the
|
||||||
|
// worker pool bounded so concurrent CI jobs remain reliable.
|
||||||
|
maxWorkers: 2,
|
||||||
setupFiles: ['./src/test-setup.ts'],
|
setupFiles: ['./src/test-setup.ts'],
|
||||||
env: {
|
env: {
|
||||||
// Lock the test timezone to UTC so that date-formatting assertions
|
// Lock the test timezone to UTC so that date-formatting assertions
|
||||||
|
|||||||
@@ -87,6 +87,23 @@ def test_compose_defaults_business_timezone_and_dev_inherits_it() -> None:
|
|||||||
assert "TZ" not in dev["services"].get(service_name, {}).get("environment", {})
|
assert "TZ" not in dev["services"].get(service_name, {}).get("environment", {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_log_rotation_is_string_typed_and_dev_inherits_it() -> None:
|
||||||
|
"""Both services use bounded json-file logs; dev layering keeps base policy."""
|
||||||
|
base = _read_yaml("docker-compose.yml")
|
||||||
|
dev = _read_yaml("docker-compose.dev.yml")
|
||||||
|
expected = {
|
||||||
|
"driver": "json-file",
|
||||||
|
"options": {"max-size": "10m", "max-file": "3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for service_name in ("migration", "app"):
|
||||||
|
base_logging = base["services"][service_name]["logging"]
|
||||||
|
assert base_logging == expected
|
||||||
|
assert all(isinstance(value, str) for value in base_logging["options"].values())
|
||||||
|
# The override does not define logging, so Compose retains the base value.
|
||||||
|
assert "logging" not in dev["services"].get(service_name, {})
|
||||||
|
|
||||||
|
|
||||||
def test_compose_keeps_app_non_root_and_maps_minimal_warmtelink_serial_access() -> None:
|
def test_compose_keeps_app_non_root_and_maps_minimal_warmtelink_serial_access() -> None:
|
||||||
"""Base Compose maps the configured device with only pyserial's required access.
|
"""Base Compose maps the configured device with only pyserial's required access.
|
||||||
|
|
||||||
|
|||||||
@@ -2788,7 +2788,11 @@ def test_m8_catalog_has_source_meter_and_thermal_entities_disabled(energy_db) ->
|
|||||||
assert entries[f"meter.{heating.uuid}.total"].entity.unit == "GJ"
|
assert entries[f"meter.{heating.uuid}.total"].entity.unit == "GJ"
|
||||||
assert entries[f"meter.{heating.uuid}.total"].entity.device_class == "energy"
|
assert entries[f"meter.{heating.uuid}.total"].entity.device_class == "energy"
|
||||||
assert entries[f"meter.{water.uuid}.total"].entity.unit == "m³"
|
assert entries[f"meter.{water.uuid}.total"].entity.unit == "m³"
|
||||||
assert entries[f"meter.{water.uuid}.total"].entity.device_class == "volume"
|
for suffix in ("total", "today"):
|
||||||
|
water_entity = entries[f"meter.{water.uuid}.{suffix}"].entity
|
||||||
|
assert water_entity.device_class == "water"
|
||||||
|
assert water_entity.unit == "m³"
|
||||||
|
assert water_entity.state_class == "total_increasing"
|
||||||
assert entries[f"meter.{electricity.uuid}.total"].entity.unit == "kWh"
|
assert entries[f"meter.{electricity.uuid}.total"].entity.unit == "kWh"
|
||||||
assert entries[f"meter.{electricity.uuid}.total"].entity.device_class == "energy"
|
assert entries[f"meter.{electricity.uuid}.total"].entity.device_class == "energy"
|
||||||
assert entries[f"meter.{electricity.uuid}.today"].entity.state_class == "total_increasing"
|
assert entries[f"meter.{electricity.uuid}.today"].entity.state_class == "total_increasing"
|
||||||
@@ -2812,6 +2816,31 @@ def test_m8_catalog_has_source_meter_and_thermal_entities_disabled(energy_db) ->
|
|||||||
assert all(entry.entity.unit == "EUR" and entry.entity.device_class == "monetary" for entry in thermal)
|
assert all(entry.entity.unit == "EUR" and entry.entity.device_class == "monetary" for entry in thermal)
|
||||||
|
|
||||||
|
|
||||||
|
def test_m8_hot_water_discovery_payload_is_ha_energy_compatible(energy_db) -> None:
|
||||||
|
"""Hot-water Meter discovery exposes the exact HA Energy Water metadata."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
from app.services.ha_discovery import build_discovery_payload
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
_source, _channel, meter = _make_thermal_source_and_meter(session, "hot_water", now)
|
||||||
|
session.commit()
|
||||||
|
entries = {item.entity.key: item.entity for item in build_catalog(session)}
|
||||||
|
|
||||||
|
for suffix in ("total", "today"):
|
||||||
|
entity = entries[f"meter.{meter.uuid}.{suffix}"]
|
||||||
|
topic, payload = build_discovery_payload(entity, "homeassistant")
|
||||||
|
assert payload["device_class"] == "water"
|
||||||
|
assert payload["unit_of_measurement"] == "m³"
|
||||||
|
assert payload["state_class"] == "total_increasing"
|
||||||
|
assert payload["unique_id"] == f"{meter.uuid}_meter_{meter.uuid}_{suffix}"
|
||||||
|
assert payload["device"]["identifiers"] == [f"home-automation:meter:{meter.uuid}"]
|
||||||
|
assert topic == (
|
||||||
|
f"homeassistant/sensor/{meter.uuid.replace('-', '_')}/"
|
||||||
|
f"meter_{meter.uuid.replace('-', '_')}_{suffix}/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_m8_meter_getter_hides_stale_or_offline_source(energy_db) -> None:
|
def test_m8_meter_getter_hides_stale_or_offline_source(energy_db) -> None:
|
||||||
from app.integrations.expose import build_catalog
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
@@ -2973,6 +3002,11 @@ def test_m8_thermal_today_summary_ends_at_frozen_now(energy_db) -> None:
|
|||||||
"heating": Decimal("0"), "hot_water_heating": Decimal("1.25"),
|
"heating": Decimal("0"), "hot_water_heating": Decimal("1.25"),
|
||||||
"hot_water": Decimal("2.75"), "hot_water_tax": Decimal("9.99"),
|
"hot_water": Decimal("2.75"), "hot_water_tax": Decimal("9.99"),
|
||||||
},
|
},
|
||||||
|
"fixed_breakdown": {
|
||||||
|
"heating_network": Decimal("0"), "metering": Decimal("0"),
|
||||||
|
"delivery_set": Decimal("0"), "hot_water_network": Decimal("0"),
|
||||||
|
"other": Decimal("0"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def summarize_spy(_session: Session, start: datetime, end: datetime, *, now: datetime) -> dict:
|
def summarize_spy(_session: Session, start: datetime, end: datetime, *, now: datetime) -> dict:
|
||||||
@@ -2994,5 +3028,53 @@ def test_m8_thermal_today_summary_ends_at_frozen_now(energy_db) -> None:
|
|||||||
assert entity.value_getter(session) == Decimal("0")
|
assert entity.value_getter(session) == Decimal("0")
|
||||||
hot_water_total = next(item.entity for item in build_catalog(session)
|
hot_water_total = next(item.entity for item in build_catalog(session)
|
||||||
if item.entity.key.endswith(".hot_water_total_today"))
|
if item.entity.key.endswith(".hot_water_total_today"))
|
||||||
assert hot_water_total.value_getter(session) == Decimal("4.00")
|
assert hot_water_total.value_getter(session) == Decimal("13.99")
|
||||||
assert captured["end"] == now
|
assert captured["end"] == now
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("suffix", ("total", "today"))
|
||||||
|
def test_m8_thermal_totals_allocate_standing_costs_and_match_all_in(energy_db, suffix: str) -> None:
|
||||||
|
"""Heating and hot water each receive their assigned costs, without overlap."""
|
||||||
|
from app.integrations.expose import build_catalog
|
||||||
|
from app.services import timezone as tz
|
||||||
|
|
||||||
|
now = datetime(2026, 1, 15, 10, tzinfo=timezone.utc)
|
||||||
|
summary = {
|
||||||
|
"period_count": 2,
|
||||||
|
"fixed_cost": Decimal("35"),
|
||||||
|
"total_cost": Decimal("54"),
|
||||||
|
"breakdown": {
|
||||||
|
"heating": Decimal("10"), "hot_water_heating": Decimal("2"),
|
||||||
|
"hot_water": Decimal("3"), "hot_water_tax": Decimal("4"),
|
||||||
|
},
|
||||||
|
"fixed_breakdown": {
|
||||||
|
"heating_network": Decimal("5"), "metering": Decimal("6"),
|
||||||
|
"delivery_set": Decimal("7"), "hot_water_network": Decimal("9"),
|
||||||
|
"other": Decimal("8"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with Session(energy_db) as session:
|
||||||
|
_make_thermal_source_and_meter(session, "heating", now)
|
||||||
|
_make_thermal_source_and_meter(session, "hot_water", now)
|
||||||
|
session.commit()
|
||||||
|
with (
|
||||||
|
patch("app.integrations.expose._utc_now", return_value=now),
|
||||||
|
patch.object(tz, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")),
|
||||||
|
patch.object(tz, "local_now", return_value=now.astimezone(ZoneInfo("Europe/Amsterdam"))),
|
||||||
|
patch("app.services.meter_cost.summarize", return_value=summary),
|
||||||
|
):
|
||||||
|
entities = {item.entity.key.rsplit(".", 1)[-1]: item.entity
|
||||||
|
for item in build_catalog(session) if item.entity.key.startswith("thermal_cost.")}
|
||||||
|
heating = entities[f"heating_{suffix}"].value_getter(session)
|
||||||
|
hot_water = entities[f"hot_water_total_{suffix}"].value_getter(session)
|
||||||
|
|
||||||
|
assert heating == Decimal("36") # 10 + heating_network + metering + delivery_set + other
|
||||||
|
assert hot_water == Decimal("18") # 2 + 3 + 4 + hot_water_network
|
||||||
|
assert entities[f"fixed_{suffix}"].value_getter(session) == Decimal("35")
|
||||||
|
assert entities[f"all_in_{suffix}"].value_getter(session) == Decimal("54")
|
||||||
|
assert heating + hot_water == entities[f"all_in_{suffix}"].value_getter(session)
|
||||||
|
# Component entities deliberately remain variable-only.
|
||||||
|
assert entities[f"hot_water_heating_{suffix}"].value_getter(session) == Decimal("2")
|
||||||
|
assert entities[f"water_{suffix}"].value_getter(session) == Decimal("3")
|
||||||
|
assert entities[f"water_tax_{suffix}"].value_getter(session) == Decimal("4")
|
||||||
+274
-10
@@ -4,6 +4,7 @@ All paho interaction is mocked — no real broker is required.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -103,7 +104,7 @@ def test_connect_creates_paho_client_with_version2() -> None:
|
|||||||
settings = _make_settings()
|
settings = _make_settings()
|
||||||
|
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.connect.return_value = None
|
mock_client.connect_async.return_value = None
|
||||||
|
|
||||||
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client) as mock_cls:
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client) as mock_cls:
|
||||||
with patch("app.integrations.mqtt.mqtt.CallbackAPIVersion"):
|
with patch("app.integrations.mqtt.mqtt.CallbackAPIVersion"):
|
||||||
@@ -117,7 +118,7 @@ def test_connect_creates_paho_client_with_version2() -> None:
|
|||||||
# loop_start must have been called
|
# loop_start must have been called
|
||||||
mock_client.loop_start.assert_called_once()
|
mock_client.loop_start.assert_called_once()
|
||||||
# connect must have been called with the right host/port
|
# connect must have been called with the right host/port
|
||||||
mock_client.connect.assert_called_once_with(
|
mock_client.connect_async.assert_called_once_with(
|
||||||
host="broker.test", port=1883, keepalive=60
|
host="broker.test", port=1883, keepalive=60
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,6 +134,220 @@ def test_connect_uses_configured_client_id() -> None:
|
|||||||
assert mock_cls.call_args.kwargs["client_id"] == "home-automation-dev"
|
assert mock_cls.call_args.kwargs["client_id"] == "home-automation-dev"
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_same_settings_is_idempotent_even_when_disconnected() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
client = MagicMock()
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=client) as cls:
|
||||||
|
manager.connect(settings)
|
||||||
|
manager.connect(settings)
|
||||||
|
cls.assert_called_once()
|
||||||
|
client.loop_start.assert_called_once()
|
||||||
|
client.connect_async.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_replaces_client_when_paho_loop_thread_has_exited() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
old_client, recovered_client = MagicMock(), MagicMock()
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", side_effect=[old_client, recovered_client]):
|
||||||
|
manager.connect(settings)
|
||||||
|
old_client._thread = None
|
||||||
|
manager._client_loop_started = True
|
||||||
|
manager.connect(settings)
|
||||||
|
|
||||||
|
old_client.disconnect.assert_called_once()
|
||||||
|
old_client.loop_stop.assert_called_once()
|
||||||
|
recovered_client.loop_start.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_loop_start_failure_cleans_installed_client_for_retry() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
failed_client = MagicMock()
|
||||||
|
failed_client.loop_start.return_value = -1
|
||||||
|
recovered_client = MagicMock()
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", side_effect=[failed_client, recovered_client]):
|
||||||
|
manager.connect(_make_settings())
|
||||||
|
assert manager._client is None
|
||||||
|
manager.connect(_make_settings())
|
||||||
|
|
||||||
|
failed_client.disconnect.assert_called_once()
|
||||||
|
failed_client.loop_stop.assert_called_once()
|
||||||
|
assert manager._client is recovered_client
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_same_config_connect_creates_one_client() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
client = MagicMock()
|
||||||
|
barrier = threading.Barrier(3)
|
||||||
|
|
||||||
|
def connect() -> None:
|
||||||
|
barrier.wait(timeout=1)
|
||||||
|
manager.connect(settings)
|
||||||
|
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=client) as cls:
|
||||||
|
workers = [threading.Thread(target=connect) for _ in range(2)]
|
||||||
|
for worker in workers:
|
||||||
|
worker.start()
|
||||||
|
barrier.wait(timeout=1)
|
||||||
|
for worker in workers:
|
||||||
|
worker.join(timeout=2)
|
||||||
|
|
||||||
|
assert all(not worker.is_alive() for worker in workers)
|
||||||
|
cls.assert_called_once()
|
||||||
|
client.loop_start.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("operation", ["disconnect", "reconnect"])
|
||||||
|
def test_teardown_can_join_loop_while_disconnect_callback_runs(operation: str) -> None:
|
||||||
|
"""A real callback thread must finish while teardown joins the fake loop."""
|
||||||
|
manager = MqttManager()
|
||||||
|
callback_go = threading.Event()
|
||||||
|
callback_entered = threading.Event()
|
||||||
|
callback_done = threading.Event()
|
||||||
|
clients: list[object] = []
|
||||||
|
|
||||||
|
class LoopClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self.on_disconnect = None
|
||||||
|
self.loop_joined = threading.Event()
|
||||||
|
|
||||||
|
def connect_async(self, **_kwargs: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def loop_start(self) -> int:
|
||||||
|
def network_callback() -> None:
|
||||||
|
callback_go.wait(timeout=2)
|
||||||
|
callback_entered.set()
|
||||||
|
try:
|
||||||
|
self.on_disconnect(self, None, None, MagicMock(is_failure=True), None)
|
||||||
|
finally:
|
||||||
|
callback_done.set()
|
||||||
|
|
||||||
|
self._thread = threading.Thread(target=network_callback, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
callback_go.set()
|
||||||
|
|
||||||
|
def loop_stop(self) -> None:
|
||||||
|
assert callback_entered.wait(timeout=1), "callback thread did not start"
|
||||||
|
assert self._thread is not None
|
||||||
|
self._thread.join(timeout=1)
|
||||||
|
assert not self._thread.is_alive(), "callback thread did not exit before loop_stop returned"
|
||||||
|
self.loop_joined.set()
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
callback_go.set()
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join(timeout=1)
|
||||||
|
|
||||||
|
def make_client(**_kwargs: object) -> LoopClient:
|
||||||
|
client = LoopClient()
|
||||||
|
clients.append(client)
|
||||||
|
return client
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", side_effect=make_client):
|
||||||
|
manager.connect(_make_settings())
|
||||||
|
old_client = clients[0]
|
||||||
|
if operation == "disconnect":
|
||||||
|
manager.disconnect()
|
||||||
|
assert manager._client is None
|
||||||
|
else:
|
||||||
|
manager.reconnect(_make_settings(mqtt_client_id="replacement"))
|
||||||
|
assert manager._client is clients[1]
|
||||||
|
assert callback_done.is_set()
|
||||||
|
assert old_client.loop_joined.is_set()
|
||||||
|
assert old_client._thread is not None and not old_client._thread.is_alive()
|
||||||
|
finally:
|
||||||
|
for client in clients:
|
||||||
|
client.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failure", ["constructor", "tls", "credentials", "connect_async", "loop_start"])
|
||||||
|
def test_initialization_failure_is_cleaned_and_next_connect_recovers(failure: str) -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
broken, recovered = MagicMock(), MagicMock()
|
||||||
|
broken.connect_async.return_value = None
|
||||||
|
if failure == "tls":
|
||||||
|
broken.tls_set.side_effect = OSError("synthetic TLS failure")
|
||||||
|
elif failure == "credentials":
|
||||||
|
broken.username_pw_set.side_effect = OSError("synthetic credential failure")
|
||||||
|
elif failure == "connect_async":
|
||||||
|
broken.connect_async.side_effect = OSError("synthetic connect setup failure")
|
||||||
|
elif failure == "loop_start":
|
||||||
|
broken.loop_start.side_effect = OSError("synthetic loop failure")
|
||||||
|
|
||||||
|
settings = _make_settings(
|
||||||
|
mqtt_tls_enabled=failure == "tls",
|
||||||
|
mqtt_username="user" if failure == "credentials" else "",
|
||||||
|
mqtt_password="synthetic",
|
||||||
|
)
|
||||||
|
first = (
|
||||||
|
[RuntimeError("synthetic constructor failure"), recovered]
|
||||||
|
if failure == "constructor"
|
||||||
|
else [broken, recovered]
|
||||||
|
)
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", side_effect=first):
|
||||||
|
manager.connect(settings)
|
||||||
|
assert manager._client is None
|
||||||
|
manager.connect(settings)
|
||||||
|
|
||||||
|
assert manager._client is recovered
|
||||||
|
recovered.loop_start.assert_called_once()
|
||||||
|
recovered.connect_async.assert_called_once()
|
||||||
|
if failure in {"connect_async", "loop_start"}:
|
||||||
|
broken.disconnect.assert_called_once()
|
||||||
|
broken.loop_stop.assert_called_once()
|
||||||
|
elif failure in {"tls", "credentials"}:
|
||||||
|
broken.loop_start.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("old_is_failure", [False, True])
|
||||||
|
def test_old_connect_success_and_failure_callbacks_cannot_change_replacement(
|
||||||
|
old_is_failure: bool,
|
||||||
|
) -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
old_client, new_client = MagicMock(), MagicMock()
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", side_effect=[old_client, new_client]):
|
||||||
|
manager.connect(_make_settings())
|
||||||
|
manager.reconnect(_make_settings(mqtt_client_id="replacement"))
|
||||||
|
|
||||||
|
manager._connected = True
|
||||||
|
old_client.on_connect(
|
||||||
|
old_client, None, None, MagicMock(is_failure=old_is_failure), None
|
||||||
|
)
|
||||||
|
assert manager._client is new_client
|
||||||
|
assert manager.is_connected
|
||||||
|
new_client.subscribe.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_client_callbacks_cannot_change_replacement_state_or_dispatch() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings()
|
||||||
|
old_client, new_client = MagicMock(), MagicMock()
|
||||||
|
received: list[bytes] = []
|
||||||
|
manager.subscribe("sensor/state", received.append)
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", side_effect=[old_client, new_client]):
|
||||||
|
manager.connect(settings)
|
||||||
|
manager.reconnect(_make_settings(mqtt_client_id="replacement"))
|
||||||
|
|
||||||
|
manager._connected = True
|
||||||
|
failed = MagicMock()
|
||||||
|
failed.is_failure = True
|
||||||
|
old_client.on_disconnect(old_client, None, None, failed, None)
|
||||||
|
old_client.on_message(old_client, None, MagicMock(topic="sensor/state", payload=b"stale"))
|
||||||
|
assert manager.is_connected
|
||||||
|
assert received == []
|
||||||
|
|
||||||
|
new_client.on_message(new_client, None, MagicMock(topic="sensor/state", payload=b"fresh"))
|
||||||
|
assert received == [b"fresh"]
|
||||||
|
|
||||||
|
|
||||||
def test_deployment_client_id_variants_are_distinct() -> None:
|
def test_deployment_client_id_variants_are_distinct() -> None:
|
||||||
production_ids = {
|
production_ids = {
|
||||||
"home-automation",
|
"home-automation",
|
||||||
@@ -155,7 +370,7 @@ def test_connect_sets_credentials_when_username_provided() -> None:
|
|||||||
settings = _make_settings(mqtt_username="user", mqtt_password="s3cr3t")
|
settings = _make_settings(mqtt_username="user", mqtt_password="s3cr3t")
|
||||||
|
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.connect.return_value = None
|
mock_client.connect_async.return_value = None
|
||||||
|
|
||||||
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
manager.connect(settings)
|
manager.connect(settings)
|
||||||
@@ -170,7 +385,7 @@ def test_connect_skips_credentials_when_username_empty() -> None:
|
|||||||
settings = _make_settings(mqtt_username="", mqtt_password="")
|
settings = _make_settings(mqtt_username="", mqtt_password="")
|
||||||
|
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.connect.return_value = None
|
mock_client.connect_async.return_value = None
|
||||||
|
|
||||||
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
manager.connect(settings)
|
manager.connect(settings)
|
||||||
@@ -183,7 +398,7 @@ def test_connect_calls_tls_set_when_tls_enabled() -> None:
|
|||||||
settings = _make_settings(mqtt_tls_enabled=True)
|
settings = _make_settings(mqtt_tls_enabled=True)
|
||||||
|
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.connect.return_value = None
|
mock_client.connect_async.return_value = None
|
||||||
|
|
||||||
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
manager.connect(settings)
|
manager.connect(settings)
|
||||||
@@ -200,7 +415,7 @@ def test_connect_does_not_raise_on_connection_failure() -> None:
|
|||||||
settings = _make_settings()
|
settings = _make_settings()
|
||||||
|
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.connect.side_effect = OSError("Connection refused")
|
mock_client.connect_async.side_effect = OSError("Connection refused")
|
||||||
|
|
||||||
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
# Must not raise — failures are caught and logged
|
# Must not raise — failures are caught and logged
|
||||||
@@ -221,7 +436,7 @@ def test_disconnect_stops_loop_and_clears_client() -> None:
|
|||||||
settings = _make_settings()
|
settings = _make_settings()
|
||||||
|
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.connect.return_value = None
|
mock_client.connect_async.return_value = None
|
||||||
|
|
||||||
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
manager.connect(settings)
|
manager.connect(settings)
|
||||||
@@ -249,7 +464,7 @@ def test_publish_passes_topic_payload_retain_to_paho() -> None:
|
|||||||
settings = _make_settings()
|
settings = _make_settings()
|
||||||
|
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.connect.return_value = None
|
mock_client.connect_async.return_value = None
|
||||||
|
|
||||||
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
|
||||||
manager.connect(settings)
|
manager.connect(settings)
|
||||||
@@ -306,7 +521,7 @@ def test_reconnect_stops_old_client_and_starts_new_one() -> None:
|
|||||||
manager._connected = True
|
manager._connected = True
|
||||||
|
|
||||||
new_client = MagicMock()
|
new_client = MagicMock()
|
||||||
new_client.connect.return_value = None
|
new_client.connect_async.return_value = None
|
||||||
|
|
||||||
with patch("app.integrations.mqtt.mqtt.Client", return_value=new_client):
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=new_client):
|
||||||
manager.reconnect(settings)
|
manager.reconnect(settings)
|
||||||
@@ -317,11 +532,60 @@ def test_reconnect_stops_old_client_and_starts_new_one() -> None:
|
|||||||
|
|
||||||
# New client must have been connected
|
# New client must have been connected
|
||||||
new_client.loop_start.assert_called_once()
|
new_client.loop_start.assert_called_once()
|
||||||
new_client.connect.assert_called_once_with(
|
new_client.connect_async.assert_called_once_with(
|
||||||
host="broker.test", port=1883, keepalive=60
|
host="broker.test", port=1883, keepalive=60
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"settings",
|
||||||
|
[
|
||||||
|
_make_settings(mqtt_enabled=False),
|
||||||
|
_make_settings(mqtt_broker_host=""),
|
||||||
|
],
|
||||||
|
ids=["disabled", "empty-host"],
|
||||||
|
)
|
||||||
|
def test_unconfigured_reconnect_stops_main_client_but_keeps_dsmr_source(settings) -> None:
|
||||||
|
"""Changing main MQTT config must not interrupt independent DSMR clients."""
|
||||||
|
manager = MqttManager()
|
||||||
|
main_client = MagicMock()
|
||||||
|
source_client = MagicMock()
|
||||||
|
received: list[bytes] = []
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.integrations.mqtt.mqtt.Client",
|
||||||
|
side_effect=[main_client, source_client],
|
||||||
|
):
|
||||||
|
manager.connect(_make_settings())
|
||||||
|
assert manager.replace_source(
|
||||||
|
17,
|
||||||
|
host="source.test",
|
||||||
|
port=1883,
|
||||||
|
username="",
|
||||||
|
password="",
|
||||||
|
tls_enabled=False,
|
||||||
|
subscriptions={"meter/17": received.append},
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.reconnect(settings)
|
||||||
|
|
||||||
|
assert manager._client is None
|
||||||
|
main_client.disconnect.assert_called_once()
|
||||||
|
main_client.loop_stop.assert_called_once()
|
||||||
|
assert manager._source_clients[17] is source_client
|
||||||
|
assert manager.source_is_active(17)
|
||||||
|
source_client.disconnect.assert_not_called()
|
||||||
|
source_client.loop_stop.assert_not_called()
|
||||||
|
|
||||||
|
message = MagicMock()
|
||||||
|
message.topic = "meter/17"
|
||||||
|
message.payload = b"reading"
|
||||||
|
source_client.on_message(source_client, None, message)
|
||||||
|
assert received == [b"reading"]
|
||||||
|
|
||||||
|
manager.disconnect()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /api/config/mqtt/test — session + CSRF guards
|
# POST /api/config/mqtt/test — session + CSRF guards
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Offline regression coverage for scheduler-driven MQTT recovery."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from app.integrations.mqtt import MqttManager
|
||||||
|
from app.main import _run_scheduled_ha_state_publish
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_reuses_manager_across_disconnects_then_subscribes_and_publishes() -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
session_local = MagicMock(return_value=session)
|
||||||
|
settings = MagicMock(
|
||||||
|
mqtt_enabled=True,
|
||||||
|
mqtt_broker_host="synthetic-broker",
|
||||||
|
mqtt_broker_port=1883,
|
||||||
|
mqtt_username="",
|
||||||
|
mqtt_password="",
|
||||||
|
mqtt_tls_enabled=False,
|
||||||
|
mqtt_client_id="offline-test",
|
||||||
|
)
|
||||||
|
manager = MqttManager()
|
||||||
|
clients: list[MagicMock] = []
|
||||||
|
|
||||||
|
def make_client(*_args: object, **_kwargs: object) -> MagicMock:
|
||||||
|
client = MagicMock()
|
||||||
|
clients.append(client)
|
||||||
|
return client
|
||||||
|
|
||||||
|
manager.subscribe("homeassistant/status", lambda _payload: None)
|
||||||
|
|
||||||
|
def publish_states(_session: object) -> None:
|
||||||
|
manager.publish("homeassistant/state", b"state", retain=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.main.get_session_local", return_value=session_local),
|
||||||
|
patch("app.main.build_runtime_settings", return_value=settings),
|
||||||
|
patch("app.main.get_settings", return_value=MagicMock()),
|
||||||
|
patch("app.main.mqtt_manager", manager),
|
||||||
|
patch("app.main.publish_discovery") as publish_discovery,
|
||||||
|
patch("app.main.publish_states", side_effect=publish_states),
|
||||||
|
patch("app.integrations.mqtt.mqtt.Client", side_effect=make_client),
|
||||||
|
):
|
||||||
|
for _ in range(3):
|
||||||
|
_run_scheduled_ha_state_publish()
|
||||||
|
assert len(clients) == 1
|
||||||
|
assert clients[0].loop_start.call_count == 1
|
||||||
|
clients[0].on_disconnect(
|
||||||
|
clients[0], None, None, MagicMock(is_failure=True), None
|
||||||
|
)
|
||||||
|
assert not manager.is_connected
|
||||||
|
|
||||||
|
# A subsequent scheduler turn sees Paho's existing loop retrying; it
|
||||||
|
# must not replace that client. Simulate the successful CONNACK.
|
||||||
|
_run_scheduled_ha_state_publish()
|
||||||
|
assert len(clients) == 1
|
||||||
|
clients[0].on_connect(clients[0], None, None, MagicMock(is_failure=False), None)
|
||||||
|
|
||||||
|
assert manager.is_connected
|
||||||
|
clients[0].subscribe.assert_called_with("homeassistant/status")
|
||||||
|
_run_scheduled_ha_state_publish()
|
||||||
|
assert len(clients) == 1
|
||||||
|
clients[0].publish.assert_called_once_with(
|
||||||
|
"homeassistant/state", payload=b"state", qos=0, retain=True
|
||||||
|
)
|
||||||
|
assert publish_discovery.call_count == 5
|
||||||
|
assert session.close.call_count == 5
|
||||||
|
manager.disconnect()
|
||||||
@@ -143,7 +143,7 @@ def test_unsubscribe_unknown_topic_is_noop() -> None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _extract_on_message(manager: MqttManager, settings) -> object:
|
def _extract_on_message(manager: MqttManager, settings) -> tuple[object, MagicMock]:
|
||||||
"""Connect with a mocked paho client and return the on_message callback that
|
"""Connect with a mocked paho client and return the on_message callback that
|
||||||
was registered on the client mock."""
|
was registered on the client mock."""
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
@@ -153,7 +153,7 @@ def _extract_on_message(manager: MqttManager, settings) -> object:
|
|||||||
manager.connect(settings)
|
manager.connect(settings)
|
||||||
|
|
||||||
# The callback was assigned as: client.on_message = _on_message
|
# The callback was assigned as: client.on_message = _on_message
|
||||||
return mock_client.on_message
|
return mock_client.on_message, mock_client
|
||||||
|
|
||||||
|
|
||||||
def test_on_message_dispatches_to_registered_handler() -> None:
|
def test_on_message_dispatches_to_registered_handler() -> None:
|
||||||
@@ -168,10 +168,10 @@ def test_on_message_dispatches_to_registered_handler() -> None:
|
|||||||
|
|
||||||
manager.subscribe("dsmr/json", handler)
|
manager.subscribe("dsmr/json", handler)
|
||||||
|
|
||||||
on_message = _extract_on_message(manager, settings)
|
on_message, client = _extract_on_message(manager, settings)
|
||||||
|
|
||||||
msg = _make_mqtt_message("dsmr/json", b'{"id": 1}')
|
msg = _make_mqtt_message("dsmr/json", b'{"id": 1}')
|
||||||
on_message(MagicMock(), None, msg) # simulate paho calling the callback
|
on_message(client, None, msg) # simulate paho calling the callback
|
||||||
|
|
||||||
assert received_payloads == [b'{"id": 1}']
|
assert received_payloads == [b'{"id": 1}']
|
||||||
|
|
||||||
@@ -183,11 +183,11 @@ def test_on_message_ignores_unknown_topic() -> None:
|
|||||||
handler = MagicMock()
|
handler = MagicMock()
|
||||||
manager.subscribe("other/topic", handler)
|
manager.subscribe("other/topic", handler)
|
||||||
|
|
||||||
on_message = _extract_on_message(manager, settings)
|
on_message, client = _extract_on_message(manager, settings)
|
||||||
|
|
||||||
msg = _make_mqtt_message("unknown/topic", b"data")
|
msg = _make_mqtt_message("unknown/topic", b"data")
|
||||||
# Must not raise
|
# Must not raise
|
||||||
on_message(MagicMock(), None, msg)
|
on_message(client, None, msg)
|
||||||
|
|
||||||
handler.assert_not_called()
|
handler.assert_not_called()
|
||||||
|
|
||||||
@@ -201,11 +201,11 @@ def test_on_message_swallows_handler_exception() -> None:
|
|||||||
raise RuntimeError("handler exploded")
|
raise RuntimeError("handler exploded")
|
||||||
|
|
||||||
manager.subscribe("dsmr/json", bad_handler)
|
manager.subscribe("dsmr/json", bad_handler)
|
||||||
on_message = _extract_on_message(manager, settings)
|
on_message, client = _extract_on_message(manager, settings)
|
||||||
|
|
||||||
msg = _make_mqtt_message("dsmr/json", b"{}")
|
msg = _make_mqtt_message("dsmr/json", b"{}")
|
||||||
# This must not raise
|
# This must not raise
|
||||||
on_message(MagicMock(), None, msg)
|
on_message(client, None, msg)
|
||||||
|
|
||||||
|
|
||||||
def test_on_message_does_not_crash_on_handler_exception_multiple_calls() -> None:
|
def test_on_message_does_not_crash_on_handler_exception_multiple_calls() -> None:
|
||||||
@@ -221,13 +221,13 @@ def test_on_message_does_not_crash_on_handler_exception_multiple_calls() -> None
|
|||||||
raise ValueError("first call fails")
|
raise ValueError("first call fails")
|
||||||
|
|
||||||
manager.subscribe("dsmr/json", flaky_handler)
|
manager.subscribe("dsmr/json", flaky_handler)
|
||||||
on_message = _extract_on_message(manager, settings)
|
on_message, client = _extract_on_message(manager, settings)
|
||||||
|
|
||||||
msg1 = _make_mqtt_message("dsmr/json", b"first")
|
msg1 = _make_mqtt_message("dsmr/json", b"first")
|
||||||
msg2 = _make_mqtt_message("dsmr/json", b"second")
|
msg2 = _make_mqtt_message("dsmr/json", b"second")
|
||||||
|
|
||||||
on_message(MagicMock(), None, msg1) # should not raise despite exception in handler
|
on_message(client, None, msg1) # should not raise despite exception in handler
|
||||||
on_message(MagicMock(), None, msg2) # handler called again
|
on_message(client, None, msg2) # handler called again
|
||||||
|
|
||||||
assert call_count[0] == 2
|
assert call_count[0] == 2
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user