diff --git a/AGENTS.md b/AGENTS.md index 785b5b83..5a15fca1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ For user-facing work, contribute **exactly one bullet under `## Next`** that sum Do **not** expand `CHANGELOG.md` with every internal or tooling-only follow-up. If the change's bullet already states the high-level theme, leave it unless the **user-visible** story changes. -Write each bullet for the **user**, not the implementer: describe what changed for them and why it matters, and keep it **compact and clear**. Leave out implementation details — internal symbol/function/class/file names, config knob mechanics, data structures, parity-mirror notes, and the like — unless a user genuinely needs them (e.g. a config option or env var they set). Prefer one tight sentence over an exhaustive list of everything touched. +Write each bullet for the **user**, not the implementer: describe what changed for them and why it matters, and keep it **compact and clear**. **No implementation details in the changelog** — leave out internal symbol/function/class/file names, config knob mechanics, data structures, parity-mirror notes, and the like, unless a user genuinely needs them (e.g. a config option or env var they set). State the user-visible problem and outcome, not *how* it was fixed. Prefer one tight sentence over an exhaustive list of everything touched. **Link the bullet to its PR once the number is known** — append a `([#](https://github.com/tomquist/astrameter/pull/))` reference (alongside any issue links already cited) so the changelog points back to the change. The PR number usually isn't known when you first write the bullet, so add the link on the follow-up iteration after the PR exists. **Always do this as soon as you learn the PR number** (e.g. the moment a PR is opened for the branch, or a number is shared with you) — don't wait to be asked: add the reference and push it in your next commit. diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e64192..5e872926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next +- **Fixed** the generated ESPHome config for HTTP-polled meters (notably the **Fronius Smart Meter**) not reading the grid, which left the battery uncontrolled until the config was hand-edited. It now works as generated ([#534](https://github.com/tomquist/astrameter/discussions/534), [#535](https://github.com/tomquist/astrameter/pull/535)). + ## 2.2.2 diff --git a/docs/esphome-powermeters.md b/docs/esphome-powermeters.md index e8f8c6ec..974fc3f0 100644 --- a/docs/esphome-powermeters.md +++ b/docs/esphome-powermeters.md @@ -887,6 +887,7 @@ external_components: http_request: useragent: esphome/astrameter timeout: 5s + buffer_size_rx: 4096 sensor: - platform: template @@ -900,6 +901,7 @@ interval: - http_request.get: url: http://192.168.1.130/solar_api/v1/GetMeterRealtimeData.cgi?Scope=Device&DeviceId=0 capture_response: true + max_response_buffer_size: 4096 on_response: then: - lambda: |- @@ -913,6 +915,10 @@ ct002: power_sensor_l1: grid_l1 ``` +The Solar API returns a multi-KB JSON document, so the `buffer_size_rx` / +`max_response_buffer_size` above are required — with ESPHome's small default the +response is truncated and `json::parse_json` silently fails. + If your readings have the wrong sign, add a `multiply: -1` filter on the sensor. For three-phase, add `grid_l2` / `grid_l3` template sensors and publish the diff --git a/web/ts/generate.test.ts b/web/ts/generate.test.ts index bd74d82b..a5871110 100644 --- a/web/ts/generate.test.ts +++ b/web/ts/generate.test.ts @@ -243,6 +243,25 @@ has(eyJsonHeaders, " - http_request.get:\n url: http://x/api", "es has(eyJsonHeaders, " capture_response: true", "esp/json_http: capture_response nested under action"); has(eyJsonHeaders, " on_response:", "esp/json_http: on_response nested under action"); has(eyJsonHeaders, " headers:", "esp/json_http: headers nested under action"); +// Large JSON responses (e.g. the Fronius Solar API) overflow ESPHome's small +// default receive buffer and truncate the body, so json::parse_json fails. +// Enlarge the buffer on both the client and the per-request action (issue #534). +has(eyJsonHeaders, "buffer_size_rx: 4096", "esp/http: enlarged http_request rx buffer"); +has(eyJsonHeaders, " max_response_buffer_size: 4096", "esp/http: enlarged per-request response buffer"); + +// An HTTP-polled meter plus cloud_reporting both need http_request:, but ESPHome +// allows only one. They must merge into a single block that keeps the RX buffer +// and uses the longer 20s timeout — not two blocks that drop one or the other. +const eyHttpPlusCloud = generateEsphome({ + target: "esphome", + esphome: {}, + meters: [{ type: "fronius", phases: 1, fields: { IP: "10.0.0.9" }, tuning: {} }], + ct: { fields: { CLOUD_REPORTING: "True", CLOUD_REPORTING_HOST: "eu.hamedata.com" } }, +}); +ok((eyHttpPlusCloud.match(/^http_request:/gm) || []).length === 1, "esp/http+cloud: exactly one http_request block"); +has(eyHttpPlusCloud, "buffer_size_rx: 4096", "esp/http+cloud: rx buffer survives merge"); +has(eyHttpPlusCloud, "timeout: 20s", "esp/http+cloud: timeout bumped to 20s"); +lacks(eyHttpPlusCloud, "timeout: 5s", "esp/http+cloud: meter's 5s timeout replaced, not duplicated"); has(eyJsonHeaders, " Authorization: Bearer t", "esp/json_http: header entry nested under headers"); const eyModbusTcp = generateEsphome({ diff --git a/web/ts/generate.ts b/web/ts/generate.ts index e7d5c357..d2055891 100644 --- a/web/ts/generate.ts +++ b/web/ts/generate.ts @@ -294,7 +294,14 @@ function esphomeSensor(state: State) { } if (esp.kind === "http") { - topBlocks.push(`http_request:\n${IND}useragent: esphome/astrameter\n${IND}timeout: 5s`); + // Some meters (notably the Fronius Solar API) return a multi-KB JSON + // document. ESPHome's default receive buffer is far smaller, so the body is + // truncated and json::parse_json silently fails. Enlarge it on both the + // client and the per-request action so the generated config works as-is. + const RX_BUFFER = 4096; + topBlocks.push( + `http_request:\n${IND}useragent: esphome/astrameter\n${IND}timeout: 5s\n${IND}buffer_size_rx: ${RX_BUFFER}`, + ); const use3 = phases === 3 && esp.url3; const sensors = (use3 ? ids : ["grid_l1"]).map((id, i) => templateSensor(id) + phaseFilterBlock(i)); const url = use3 ? esp.url3!(f) : (esp.url1 ? esp.url1(f) : "http://example.com/api"); @@ -322,7 +329,7 @@ function esphomeSensor(state: State) { const interval = `interval:\n${IND}- interval: 1s\n${IND}${IND}then:\n${IND}${IND}${IND}- http_request.get:\n` + `${IND}${IND}${IND}${IND}${IND}url: ${url}${headerLines}\n` + - `${IND}${IND}${IND}${IND}${IND}capture_response: true\n${IND}${IND}${IND}${IND}${IND}on_response:\n${IND}${IND}${IND}${IND}${IND}${IND}then:\n` + + `${IND}${IND}${IND}${IND}${IND}capture_response: true\n${IND}${IND}${IND}${IND}${IND}max_response_buffer_size: ${RX_BUFFER}\n${IND}${IND}${IND}${IND}${IND}on_response:\n${IND}${IND}${IND}${IND}${IND}${IND}then:\n` + `${IND}${IND}${IND}${IND}${IND}${IND}${IND}- lambda: |-\n` + `${IND}${IND}${IND}${IND}${IND}${IND}${IND}${IND}${IND}json::parse_json(body, [](${jsonRoot}) -> bool {\n` + `${IND}${IND}${IND}${IND}${IND}${IND}${IND}${IND}${IND}${IND}${IND}${lambdaBody}\n` + @@ -520,9 +527,18 @@ export function generateEsphome(state: State): string { const port = (useMeter && mFields.PORT) || mf.PORT || "1883"; out.push(`mqtt:\n${IND}broker: ${broker}\n${IND}port: ${port}`); } - // Both marstek_registration and cloud_reporting need a single http_request:. + // ESPHome allows only one top-level http_request:. An HTTP-polled meter + // already emits one (with useragent + RX buffer); marstek_registration and + // cloud_reporting also need one (with a longer 20s timeout). When both apply, + // merge into the meter's single block — bump its timeout to 20s rather than + // emitting a second block that would drop either the timeout or the buffer. + const meterHttpIdx = topBlocks.findIndex((b) => b.startsWith("http_request:")); if (wantMarstek || wantCloud) { - out.push(`http_request:\n${IND}timeout: 20s`); + if (meterHttpIdx >= 0) { + topBlocks[meterHttpIdx] = topBlocks[meterHttpIdx].replace(/timeout: \d+s/, "timeout: 20s"); + } else { + out.push(`http_request:\n${IND}timeout: 20s`); + } } // top blocks from the sensor (api/mqtt/uart/etc.) — de-dup api/mqtt if already added