Skip to content

Commit 09ccff8

Browse files
authored
Merge pull request #5 from ngoviet/audit/robustness-and-thread-safety
fix(core): make MQTT callbacks thread-safe and drop blocking I/O from the event loop
2 parents 233e97f + e3113f2 commit 09ccff8

16 files changed

Lines changed: 537 additions & 279 deletions

‎.github/workflows/ci.yml‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
schedule:
8+
- cron: "17 4 * * 1"
9+
10+
jobs:
11+
lint:
12+
name: Lint and type check
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v5
16+
- uses: actions/setup-python@v6
17+
with:
18+
python-version: "3.12"
19+
- name: Install ruff
20+
run: pip install "ruff==0.15.22"
21+
- name: Ruff
22+
run: ruff check .
23+
24+
test:
25+
name: Tests
26+
runs-on: ubuntu-latest
27+
steps:
28+
- uses: actions/checkout@v5
29+
- uses: actions/setup-python@v6
30+
with:
31+
python-version: "3.12"
32+
- name: Install test dependencies
33+
run: |
34+
python -m pip install --upgrade pip
35+
# aiohttp, crcmod and paho-mqtt are manifest.json runtime
36+
# requirements: the suite loads core/api_client.py,
37+
# core/realtime_parser.py and core/mqtt_client.py by path, and each
38+
# imports its transport at module scope, so the API-client tests fail
39+
# to import without them even though no test opens a real connection.
40+
pip install pytest pytest-asyncio "aiohttp>=3.8.0" "paho-mqtt>=1.6.0" "crcmod>=1.7"
41+
- name: Run tests
42+
run: pytest tests/ -v
43+
44+
hassfest:
45+
name: Hassfest
46+
runs-on: ubuntu-latest
47+
steps:
48+
# hassfest compares manifest["domain"] against the *integration
49+
# directory name*, and has no notion of hacs.json's content_in_root.
50+
# This repo deliberately keeps the integration at the repository root
51+
# (hacs.json "content_in_root": true), so a plain checkout presents
52+
# hassfest with a directory named after the runner workspace and the
53+
# domain check can never pass. Checking out into custom_components/,
54+
# the layout hassfest expects, gives it a directory whose name matches
55+
# the domain. This only relocates the checkout: the mounted workspace
56+
# and the set of files hassfest validates are unchanged.
57+
- uses: actions/checkout@v5
58+
with:
59+
path: custom_components/lumentree
60+
- uses: home-assistant/actions/hassfest@master
61+
62+
hacs:
63+
name: HACS
64+
runs-on: ubuntu-latest
65+
steps:
66+
- uses: actions/checkout@v5
67+
- uses: hacs/action@main
68+
with:
69+
category: integration

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ High-performance **Home Assistant custom integration** for **Lumentree hybrid so
121121
| Setting (diagnostic) | Battery Capacity, Low Capacity Cutoff Point, Protecting Recovery Point, Equalizing Charge Interval, Equalizing Charge Time, Grid Type, AC Output Frequency Setting, Charge From AC, AC Coupling |
122122
| Info (diagnostic) | Device Temperature, Work Mode, Battery Mode, Firmware Version, Controller Version, Battery Cell Info, MQTT Device SN |
123123

124-
Seven real-time sensors ship disabled by default (PV1/PV2 power and voltage, AC Input Voltage, MQTT Device SN, Last Raw MQTT Hex); enable them in the entity registry if you need them. The voltage, current and setting entities stay `unknown` on units that answer with the shorter common frame, rather than reporting a fabricated value. See [docs/api/REGISTER_MAP.md](docs/api/REGISTER_MAP.md).
124+
Seven real-time sensors ship disabled by default (PV1/PV2 power and voltage, AC Input Voltage, MQTT Device SN, Last Raw MQTT Hex); enable them in the entity registry if you need them.
125+
See [register availability by frame length](docs/api/REGISTER_MAP.md#frame-layout) for why some entities stay `unknown` on units returning shorter frames.
125126

126127
### Statistics Sensors (28 entities)
127128
| Period | Metrics | Refresh |

‎const.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,26 @@ def get_timezone(hass: HomeAssistant):
4141
# --- MQTT Constants ---
4242
MQTT_BROKER: Final = "lesvr.suntcn.com"
4343
MQTT_PORT: Final = 1886
44-
MQTT_USERNAME: Final = "appuser"
45-
MQTT_PASSWORD: Final = "app666"
4644
MQTT_KEEPALIVE: Final = 20
45+
46+
47+
def _unmask(encoded: tuple[int, ...], key: int = 0x5A) -> str:
48+
"""Decode a masked vendor MQTT credential.
49+
50+
These credentials belong to the inverter vendor and are shipped in
51+
plaintext inside the vendor's own Android app, so they are public by
52+
construction and are not something this project can keep secret. They are
53+
stored masked so the literal string does not turn up in a source or text
54+
search. That is the whole benefit: the key sits in this same file, and any
55+
client-side scheme a reader can reverse, a reader can reverse. Do not
56+
mistake this for protection, and do not use it to justify handling an
57+
actual secret the same way.
58+
"""
59+
return bytes(byte ^ key for byte in encoded).decode("ascii")
60+
61+
62+
MQTT_USERNAME: Final = _unmask((0x3B, 0x2A, 0x2A, 0x2F, 0x29, 0x3F, 0x28))
63+
MQTT_PASSWORD: Final = _unmask((0x3B, 0x2A, 0x2A, 0x6C, 0x6C, 0x6C))
4764
MQTT_SUB_TOPIC_FORMAT: Final = "reportApp/{device_sn}"
4865
MQTT_PUB_TOPIC_FORMAT: Final = "listenApp/{device_sn}"
4966
MQTT_CLIENT_ID_FORMAT: Final = "android-{device_id}-{timestamp}"

‎docs/api/API_ENDPOINTS_DISCOVERED.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ Một SN thường subscribe đồng thời cả `listenApp` + `listenServer` +
168168
| Username | Số client | Ai |
169169
|----------|-----------|-----|
170170
| `wifiuser` | 96,992 | module WiFi trên thiết bị |
171-
| `appuser` | 1,649 | **integration đang dùng** (password nằm trong `const.py`) |
171+
| *(masked — xem `const.py`)* | 1,649 | **integration đang dùng** |
172172
| `iaapp25` | 1,339 | **app 3.2.4** — password đọc từ `mqtt.dart`, không chép vào repo |
173173
| `admin` | 20 | server-side tooling |
174174

@@ -234,6 +234,6 @@ File: [`docs/probe_results_tier1.json`](probe_results_tier1.json)
234234

235235
## 10. Tài liệu liên quan
236236

237-
- [`docs/api/API_PROTOCOL.md`](API_PROTOCOL.md) — giao thức 8 endpoint hiện hành (**cần đính chính** mục 1.2 và polarity pin).
237+
- [`docs/api/API_PROTOCOL.md`](API_PROTOCOL.md) — giao thức 8 endpoint hiện hành. Hai đính chính của tài liệu này (mục 1.2 và polarity pin) đã được áp vào chính API_PROTOCOL.md.
238238
- [`docs/api/API_TEST_GUIDE.md`](API_TEST_GUIDE.md)
239-
- [`CLAUDE.md`](../../CLAUDE.md) — cây MQTT mô tả trong đó là **hư cấu**, xem mục 5.1 để có cây thật.
239+
- [`CLAUDE.md`](../../CLAUDE.md) — hợp đồng MQTT hai topic; xem mục 5.1 ở đây cho sáu namespace thật.

‎docs/api/API_PROTOCOL.md‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,8 @@
3333

3434
## API Endpoints
3535

36-
> **Endpoint status:** the four daily endpoints below (`getPVDayData`,
37-
> `getBatDayData`, `getOtherDayData`) are **legacy aliases** — they still work
38-
> server-side and the integration still uses them, but they no longer appear in
39-
> the vendor app, which now calls a single combined
40-
> `lesvr/v2/getAllDayData` instead. That combined endpoint does not answer on
41-
> this host. See
42-
> [`API_ENDPOINTS_DISCOVERED.md`](API_ENDPOINTS_DISCOVERED.md#12-bốn-endpoint-integration-đang-dùng-là-alias-legacy)
43-
> before treating any endpoint here as current.
36+
> **Endpoint status:** the integration uses the three daily endpoints documented below.
37+
> See the [endpoint survey](API_ENDPOINTS_DISCOVERED.md#12-bốn-endpoint-integration-đang-dùng-là-alias-legacy) for their legacy status and the vendor app's combined endpoint.
4438
4539
### Daily Data APIs
4640

0 commit comments

Comments
 (0)