diff --git a/CHANGELOG.md b/CHANGELOG.md index cc6f1a1..3af496d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to this project will be documented in this file. ## 2026-10 +- Add an LG KCHH020LDGC chiller bridge with nine managed Virtual Components, a persistent Modbus address selector, FC05/FC06 commands, confirmed readback, installation documentation and deterministic RTU/UI fault tests; contributor-reported physical test on 2026-10-19 - Add a hardware-tested Midea/Clivet R32 Modbus RTU bridge with exactly nine Virtual Components, guarded zone-1/DHW/Silent Mode control, fresh masked writes, physical readback, setup documentation and 33 RPC/Modbus tests - Add a Local Network Messaging starter collection for firmware 2.0+: message diagnostics, remote switch mirroring, received button control, input-driven multicast group commands, and a TV power follower with configurable hysteresis and delayed OFF control, plus complete setup and troubleshooting documentation - Add the Shelly Plus Plug S LED power visualization from upstream PR #204, with fixed threshold states or a green-to-red gradient, repository-standard headers, and manifest/README integration diff --git a/examples-manifest.json b/examples-manifest.json index 0359cd1..7fe29d2 100644 --- a/examples-manifest.json +++ b/examples-manifest.json @@ -1202,6 +1202,20 @@ "pv" ] }, + { + "fname": "modbus/LG/lg-kchh020ldgc-pro-rs485-vc.shelly.js", + "title": "LG KCHH020LDGC chiller with nine managed Virtual Components", + "description": "Local RS485/Modbus RTU chiller control with FC05 enable, FC06 mode/setpoints, confirmed readback and a persisted Modbus address.", + "doc": "modbus/LG/KCHH020LDGC.md", + "tags": [ + "modbus", + "lg", + "kchh020ldgc", + "hvac", + "chiller", + "virtual-components" + ] + }, { "fname": "modbus/LG/lg-therma-v-pro-em50_vc.shelly.js", "title": "LG THERMA V Modbus bridge for Shelly Pro EM-50", diff --git a/modbus/LG/KCHH020LDGC.md b/modbus/LG/KCHH020LDGC.md new file mode 100644 index 0000000..8d230d1 --- /dev/null +++ b/modbus/LG/KCHH020LDGC.md @@ -0,0 +1,133 @@ +# LG KCHH020LDGC chiller via RS485 + +[`lg-kchh020ldgc-pro-rs485-vc.shelly.js`](lg-kchh020ldgc-pro-rs485-vc.shelly.js) +is a standalone bridge for one selected LG KCHH020LDGC chiller. It creates +exactly nine firmware-managed Virtual Components, reads the physical controller +before accepting commands, and confirms each attempted write by reading back. + +## Hardware and communication + +- Compatible Shelly Pro host with the Pro RS485/Modbus Add-on selected in device settings. +- Working managed-VC (`@meta`, `Script.getVcHandle`) and `jsuart`/`UART` APIs. + A firmware version number alone is not a compatibility check. +- The host must advertise `jsuart` on `serial:100` and provide `UART.get(100)`. +- LG external Modbus connection: **CH4 A+/B-**. The original HMI remains on **CH3**. +- RS485 Modbus RTU, **9600 baud, 8N1**; one active client per physical bus. + +Pro EM-50 can host the Modbus bridge. For simultaneous measurement of all three +chiller supply phases, use a compatible **Pro 3EM** with appropriately rated CTs. +Metering is provided by the host's native energy components and does not consume +the bridge's nine Virtual Components. This script controls the LG controller's +enable request; it does not switch the chiller's mains supply. + +Follow the LG installation manual and Shelly add-on instructions for isolation, +signal reference, shielding and termination. Stop other bus masters before running. + +## Nine managed components + +| Role | Type | Purpose | +| --- | --- | --- | +| `power` | Boolean | Chiller enable/stop request; not compressor-running feedback | +| `mode` | Enum | Cooling / Heating; Unknown blocks synchronization | +| `cool` | Number | Cooling setpoint, 4-25 degrees C, whole-degree commands | +| `heat` | Number | Heating setpoint, 30-60 degrees C, whole-degree commands | +| `ewt` | Number | Entering-water temperature, read only | +| `lwt` | Number | Leaving-water temperature, read only | +| `address` | Number | Persistent destination Modbus address, integer 1-247 | +| `fault` | Number | LG fault code, read only | +| `link` | Text | Readiness, last-good uptime, command result and stale-data status | + +The complete `@meta` declaration is the first physical line and is 943 characters, +below the 1024-character firmware/repository limit. Keep it on one line. Additional +UI configuration is applied sequentially to the script-owned roles before any +serial setup or bus requests. Firmware allocates IDs; the script uses role handles. + +`address` selects the existing LG server to query; it does **not** change the LG +controller's own address. Selection works even in read-only commissioning mode. +An address change discards queued commands, cancels obsolete callbacks, waits for +old traffic to clear, and reads the new server before enabling its controls. +An already transmitted command to the old server cannot be undone. + +Install/update the entire file in the same script slot to preserve managed roles. +Firmware replaces the former outdoor-temperature role with `address` on upgrade. +Managed roles do not automatically adopt manually created Virtual Components; +back up and identify any obsolete components before removing them for capacity. + +## Register profile + +These are zero-based wire offsets, not displayed 4xxxx addresses. + +| Point | Display address | Wire offset | Read / write | +| --- | --- | --- | --- | +| Enable request | Coil 00001 | 0 | FC01 / FC05 | +| Operating mode | Holding 40002 | 1 | FC03 / FC06 | +| Cooling target | Holding 40003 | 2 | FC03 / FC06 | +| Heating target | Holding 40004 | 3 | FC03 / FC06 | +| Leaving water | Input 30004 | 3 | FC04 / read only | +| Entering water | Input 30005 | 4 | FC04 / read only | +| Fault | Input 30010 | 9 | FC04 / read only | + +The source is LG KCHH installation manual **MFL67262630, Rev.00_011025**, +pages 51-52, 127, 161, 163 and 167. That table names Cooling=0; Heating=4 and +the candidate temperature scale 0.1 come from the supplied installation YAML. +The contributor reported successful physical testing, but no exact controller +firmware or per-function test record was supplied. Confirm encoding on each +installation rather than transferring it to other LG families. + +`SETPOINT_SCALE` and `SENSOR_SCALE` are independent configuration values, either +0.1 or 1. The script does not guess them. Compare raw console values with the HMI. +Demand Limit holding 40001 is omitted because it is absent from this manual table. +Holding 40005/40006 are read diagnostically and never changed automatically. + +FC05 is implemented directly over UART: Shelly `MbRtuClient.WriteCoils` uses FC15, +which is not the single-coil function documented for this LG profile. FC06 is used +for supported holding writes. The implementation sends one request at a time, +checks slave/function/length/CRC, bounds buffers and commands, retries reads once, +and never blindly retries an uncertain write. Even a missing acknowledgement +triggers fresh readback, and an echo is not accepted as proof of operation. + +## Commissioning and validation + +1. Select the RS485 add-on, wire CH4 and verify the APIs required above. +2. Paste the complete script, preserving the first line; leave `ENABLE_WRITES: false`. +3. Save and run; select the controller's real address in **LG Modbus Address**. +4. Compare water temperatures, mode, setpoints and fault code with the HMI. +5. Verify the LG remote/Modbus settings through the manufacturer-approved procedure. +6. Enable writes only for the verified installation and restart the script. + +The contributor confirmed a real-device test on **2026-10-19**. This is +source-reported testing, not independent hardware validation. Publication-format +changes, asynchronous VC setup and failure paths are covered by deterministic +simulation; the exact host/firmware and physical coverage remain unspecified. + +Run automated checks from the repository root: + +```sh +node --check modbus/LG/lg-kchh020ldgc-pro-rs485-vc.shelly.js +node modbus/LG/tests/kchh020-bridge.test.cjs +python3 tools/check-manifest-integrity.py --check-headers --check-sync +``` + +The tests model metadata lifecycle, ownership/capacity, startup, FC05/FC06, +readback, lost replies, CRC faults, rejected writes, stale queues, reconnect, +address changes, persistence and configuration failure. They do not execute on +the Shelly interpreter, measure its heap, or validate real RS485 electrical timing. + +Read-only telemetry uses read-only component permissions. Cloud/app access uses +the device's ordinary Shelly Cloud connection; verify the actual app/card support. +While offline, the last readings remain visible and `link` marks them stale. + +## Stop and rollback + +Set `ENABLE_WRITES: false`, disable autostart, and stop the script. Original serial +configuration is saved once per port in `Script.storage` under `lgser100`. Restore +it only when no other script uses that port. Deleting the script also removes its +managed roles and script storage, so preserve the backup before deletion. + +## Sources + +- [LG KCHH installation manual](https://cms.intermetal.bg/storage/uploads/2025/06/25/685bc65a31f95MFL67262630_00_250120_06_WEB_English-2_compressed.pdf) +- [Shelly UART API](https://shelly-api-docs.shelly.cloud/gen2/Scripts/APIs/UART/) +- [Shelly managed Virtual Components](https://shelly-api-docs.shelly.cloud/gen2/Scripts/APIs/Virtual/) +- [Shelly Serial API](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Serial/) +- [Shelly Pro RS485 add-on](https://shelly-api-docs.shelly.cloud/gen2/Addons/ShellyRs485Addon/) diff --git a/modbus/LG/README.md b/modbus/LG/README.md index 35e67b5..ca4f2db 100644 --- a/modbus/LG/README.md +++ b/modbus/LG/README.md @@ -37,3 +37,13 @@ LG THERMA V generations and controller PCBs differ. The connector name, Modbus e The runtime bridge performs a physical read before enabling writes and follows supported writes with readback. It must not be used to bypass manufacturer safety logic. Work inside HVAC electrical equipment requires appropriate isolation and competence. RS-485 terminals must never be connected to mains voltage. + +## LG KCHH020LDGC chillers + +- [`lg-kchh020ldgc-pro-rs485-vc.shelly.js`](lg-kchh020ldgc-pro-rs485-vc.shelly.js) + provides nine managed Virtual Components, FC05 enable control, FC06 mode/setpoints, + water temperatures, fault reporting, readback and a persistent Modbus address selector. +- [KCHH020LDGC setup, register profile and validation](KCHH020LDGC.md). +- This is a separate KCHH profile. Do not transfer the THERMA V map or its slave ID. +- A real-device test was reported by the contributor; deterministic RTU/UI fault tests + are included under `tests/kchh020-bridge.test.cjs`. diff --git a/modbus/LG/lg-kchh020ldgc-pro-rs485-vc.shelly.js b/modbus/LG/lg-kchh020ldgc-pro-rs485-vc.shelly.js new file mode 100644 index 0000000..b30be38 --- /dev/null +++ b/modbus/LG/lg-kchh020ldgc-pro-rs485-vc.shelly.js @@ -0,0 +1,995 @@ +/* @meta {"vc":{"power":{"type":"boolean","access":"wc","config":{"name":"LG Chiller Enable"}},"mode":{"type":"enum","access":"wc","config":{"name":"LG Chiller Mode","options":["Cooling","Heating","Unknown"],"default_value":"Unknown"}},"cool":{"type":"number","access":"wc","config":{"name":"LG Cooling Setpoint","min":4,"max":25,"default_value":7}},"heat":{"type":"number","access":"wc","config":{"name":"LG Heating Setpoint","min":30,"max":60,"default_value":35}},"ewt":{"type":"number","access":"rc","config":{"name":"LG Entering Water"}},"lwt":{"type":"number","access":"rc","config":{"name":"LG Leaving Water"}},"address":{"type":"number","access":"wc","config":{"name":"LG Modbus Address","min":1,"max":247,"default_value":1,"persisted":true}},"fault":{"type":"number","access":"rc","config":{"name":"LG Fault Code"}},"link":{"type":"text","access":"rc","config":{"name":"LG Communication","default_value":"UNSYNCED","max_len":160}}}} */ +/** + * @title LG KCHH020LDGC chiller with nine managed Virtual Components + * @description Local RS485/Modbus RTU chiller bridge with FC05 enable, FC06 + * setpoints/mode, verified readback, and a persisted selectable Modbus address. + * @status production + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/modbus/LG/lg-kchh020ldgc-pro-rs485-vc.shelly.js + */ + +/** + SPDX-License-Identifier: Apache-2.0 + Copyright 2026 Georgi Germanov and contributors + + LG KCHH020LDGC / one selected chiller per Shelly / v1.1.0. + Host: compatible Shelly Pro + Pro RS485 Add-on; managed VC and jsuart APIs. + Pro EM-50 supports Modbus control; Pro 3EM additionally meters all three phases. + Exact host/firmware must expose serial:100 with jsuart and UART.get(100). + Requires Script.getVcHandle(), Script.storage, UART.get(SERIAL_ID). + Hardware/firmware availability is checked at startup, not assumed tested. + + INSTALL + 1. Enable the Pro RS485 add-on in the Shelly device settings. Stop the old + bridge and other masters on this physical bus before starting this one. + 2. Shelly A/B -> LG external Modbus CH4 A+/B-. HMI remains on CH3. + Follow LG installation wiring/isolation/termination instructions. + 3. Keep the complete @meta declaration on one physical line, <=1024 chars. + Nine roles: power, mode, cool, heat, ewt, lwt, address, fault, link. + Firmware owns the roles; UI details are configured sequentially at startup. + Paste this entire file into the SAME script slot, Save, then Run. Keep @meta + first. Firmware owns/reuses its nine roles; no fixed VC IDs are assumed. + The old outdoor-temperature role is replaced by LG Modbus Address. + Select the actual external LG address (1..247) in that persistent tile. + It selects the server queried by Shelly; it does NOT reprogram the LG + controller's address. On selection, queued commands are discarded and + actual values are re-read before commands can run. Selection is local + configuration, so it also works with ENABLE_WRITES:false. + 4. With ENABLE_WRITES:false compare raw console data and all values to HMI. + SETPOINT_SCALE and SENSOR_SCALE are independent. 0.1 is the candidate + scale from the supplied YAML, NOT established by the LG register table. + 5. Only after confirming the map/scales, real Shelly/firmware and LG remote + Modbus settings, set ENABLE_WRITES:true and restart. Initial values are + read from LG before commands are accepted. Nothing is written on boot. + + MAP / EVIDENCE + LG KCHH manual MFL67262630 Rev.00_011025 pp.51-52,127,161,163,167: + coil 00001 -> offset0, FC01/05, chiller enable REQUEST (not compressor run); + holding40002/3/4 -> offsets1/2/3, FC03/06, mode/cooling/heating; + input30004/5 -> offsets3/4, FC04, leaving/entering water; + input30010 -> offset9, FC04, fault code. 9600 baud, 8N1, RS485 RTU. + Heating=4 is from supplied YAML; that manual table only names Cooling=0. + Cooling 4..25 C; heating 30..60 C. Demand Limit40001 is not used. + Control/remote registers40005/6 are read for diagnosis, never changed. + + https://cms.intermetal.bg/storage/uploads/2025/06/25/685bc65a31f95MFL67262630_00_250120_06_WEB_English-2_compressed.pdf + https://shelly-api-docs.shelly.cloud/gen2/Scripts/APIs/UART/ + https://shelly-api-docs.shelly.cloud/gen2/Scripts/APIs/Virtual/ + https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Serial/ + https://shelly-api-docs.shelly.cloud/gen2/Addons/ShellyRs485Addon/ + + BEHAVIOUR + One outstanding request; FC05/06 writes only to the allowlist below. + One read retry; writes are NEVER blindly retried. Every attempted write + is followed by a fresh snapshot, including when its acknowledgement is + lost. Mismatch/exception is reported, not silently called a success. + Last confirmed values stay visible if offline; the ninth tile explicitly + marks them STALE with the last good uptime. Reconnect clears old commands. + Local polling works without cloud. Smart Control requires the normal + Shelly Cloud connection and verification in the actual device/app account. + Its exact card layout/history/scenes have not been verified by this file. + + ROLLBACK + Set ENABLE_WRITES:false, disable this script's autostart, then stop it. + Original serial config is saved once per port in Script.storage under + lgser100 (or lgser), and printed on first change. Restore it only when + no other script uses the port. Stop, rather than delete, to retain tiles + and the serial backup: firmware removes managed VCs/storage on deletion. + LG protections, remote/local modes and other Shelly components are not + modified. This file never switches the chiller's mains supply. + + Validation: real-configuration-tested as reported by the contributor on + 2026-10-19. Exact Shelly firmware and per-function physical coverage were not + recorded; this is not independent hardware verification. Publication-format + changes and RTU/UI fault cases were statically checked and simulator-checked. + Available script heap, UART direction/echo behaviour, controller firmware, + scaling and cloud UI still + must be verified for every different installation. One repeating timer, + five VC change listeners, one startup RPC at a time; buffers/queues bounded. +*/ + +var VC_CONFIG = { + power: { + name: 'LG Chiller Enable', + persisted: false, + default_value: false, + meta: { + ui: { + view: 'toggle', + }, + }, + }, + mode: { + name: 'LG Chiller Mode', + persisted: false, + options: ['Cooling', 'Heating', 'Unknown'], + default_value: 'Unknown', + }, + cool: { + name: 'LG Cooling Setpoint', + persisted: false, + default_value: 7, + min: 4, + max: 25, + meta: { + ui: { + view: 'field', + unit: 'C', + step: 1, + }, + }, + }, + heat: { + name: 'LG Heating Setpoint', + persisted: false, + default_value: 35, + min: 30, + max: 60, + meta: { + ui: { + view: 'field', + unit: 'C', + step: 1, + }, + }, + }, + ewt: { + name: 'LG Entering Water', + persisted: false, + default_value: 0, + min: -30, + max: 100, + meta: { + ui: { + view: 'label', + unit: 'C', + step: 0.1, + }, + }, + }, + lwt: { + name: 'LG Leaving Water', + persisted: false, + default_value: 0, + min: -30, + max: 100, + meta: { + ui: { + view: 'label', + unit: 'C', + step: 0.1, + }, + }, + }, + address: { + name: 'LG Modbus Address', + persisted: true, + default_value: 1, + min: 1, + max: 247, + meta: { + ui: { + view: 'field', + step: 1, + }, + }, + }, + fault: { + name: 'LG Fault Code', + persisted: false, + default_value: 0, + min: 0, + max: 65535, + meta: { + ui: { + view: 'label', + step: 1, + }, + }, + }, + link: { + name: 'LG Communication', + persisted: false, + default_value: 'UNSYNCED', + max_len: 160, + meta: { + ui: { + view: 'label', + }, + }, + }, +}; + +var CFG = { + SERIAL_ID: 100, // Pro RS485 Add-on. Do not copy to other hardware. + SLAVE_ID: 1, // Fallback only; persistent address tile takes priority. + CONFIGURE_SERIAL: true, // Back up then set jsuart / 9600 / 8N1 locally. + ENABLE_WRITES: false, // Commissioning gate. Read comments above first. + SETPOINT_SCALE: 0.1, // Celsius = signed raw * scale; confirm with HMI. + SENSOR_SCALE: 0.1, // Independently confirm input-register scaling. + HEATING_MODE: 4, // Candidate from user's working HA YAML. + POLL_MS: 5000, + GAP_MS: 150, + TIMEOUT_MS: 1500, + SETTLE_MS: 1000, + STALE_MS: 15000, + COMMAND_TTL_MS: 6000, + MAX_QUEUE: 4, + BACKOFF_MAX_MS: 30000, +}; + +var VC_ROLES = [ + 'power', + 'mode', + 'cool', + 'heat', + 'ewt', + 'lwt', + 'address', + 'fault', + 'link', +]; +var READ_REQUESTS = [ + { fc: 1, a: 0, n: 1 }, + { fc: 3, a: 1, n: 5 }, + { fc: 4, a: 3, n: 2 }, + { fc: 4, a: 9, n: 1 }, +]; +var vc = {}, + actual = null, + commandQueue = [], + rawSnapshot = [], + uart = null; +var synchronized = false, + stopped = false, + transaction = null, + completed = null, + rxBuffer = ''; +var phase = 'poll', + readIndex = 0, + readRetry = 0, + failureCount = 0; +var dueAt = 0, + nextBusAt = 0, + quietUntil = 0, + lastSuccessAt = -1, + lastRawLogAt = -60000; +var lastRxAt = -10000, + activeCommand = null, + notice = '', + lastLink = ''; +var selfSource = '', + pageOffset = 0, + serialPort = null, + portMatches = 0; +var clockTimer = null; + +function now() { + return Shelly.getUptimeMs(); +} +function log(s) { + print('[LG KCHH] ' + s); +} +function finite(v) { + return typeof v === 'number' && isFinite(v); +} +function integer(v) { + return finite(v) && Math.floor(v) === v; +} +function signed(v) { + return v > 32767 ? v - 65536 : v; +} +function near(a, b) { + return typeof a === 'number' && typeof b === 'number' + ? Math.abs(a - b) < 0.001 + : a === b; +} +function round1(v) { + return Math.round(v * 10) / 10; +} +function inRange(v, a, b) { + return finite(v) && v >= a && v <= b; +} + +function put(role, v) { + if (vc[role] && !near(vc[role].getValue(), v)) vc[role].setValue(v); +} +function link(s) { + s = String(s).slice(0, 160); + if (s !== lastLink) { + lastLink = s; + put('link', s); + log(s); + } +} +function lastStamp() { + return lastSuccessAt < 0 + ? 'never' + : String(Math.floor(lastSuccessAt / 1000)) + 's uptime'; +} +function stop(s) { + stopped = true; + synchronized = false; + commandQueue = []; + if (clockTimer !== null) { + Timer.clear(clockTimer); + clockTimer = null; + } + link('STOP: ' + s); +} +function mirror() { + if (!actual) return; + for (var i = 0; i < 8; i++) put(VC_ROLES[i], actual[VC_ROLES[i]]); +} +function status() { + var s = synchronized + ? CFG.ENABLE_WRITES + ? 'ONLINE' + : 'READ ONLY - verify map/scales' + : 'STALE'; + if (activeCommand) s += ' | pending ' + activeCommand.role; + if (notice) s += ' | ' + notice; + link(s + ' | last=' + lastStamp()); +} + +function crc(s) { + var c = 65535; + for (var i = 0; i < s.length; i++) { + c = c ^ s.charCodeAt(i); + for (var j = 0; j < 8; j++) c = c & 1 ? (c >> 1) ^ 40961 : c >> 1; + } + return c & 65535; +} +function byte(n) { + return String.fromCharCode(n & 255); +} +function frame(fc, a, n) { + var s = + byte(CFG.SLAVE_ID) + + byte(fc) + + byte(a >> 8) + + byte(a) + + byte(n >> 8) + + byte(n); + var c = crc(s); + return s + byte(c) + byte(c >> 8); +} +function allowed(fc, a, n) { + if (fc === 1) return a === 0 && n === 1; + if (fc === 3) return a === 1 && n === 5; + if (fc === 4) return (a === 3 && n === 2) || (a === 9 && n === 1); + if (!CFG.ENABLE_WRITES || !synchronized) return false; + if (fc === 5) return a === 0 && (n === 0 || n === 65280); + if (fc !== 6 || !integer(n) || n < 0 || n > 65535) return false; + if (a === 1) return n === 0 || n === CFG.HEATING_MODE; + if (a === 2) return inRange(signed(n) * CFG.SETPOINT_SCALE, 4, 25); + if (a === 3) return inRange(signed(n) * CFG.SETPOINT_SCALE, 30, 60); + return false; +} +function finish(values, error) { + if (!transaction) return; + completed = { cb: transaction.cb, values: values, error: error }; + transaction = null; + rxBuffer = ''; + nextBusAt = now() + CFG.GAP_MS; +} +function send(fc, a, n, cb) { + if (transaction || completed || !allowed(fc, a, n)) { + stop('Internal bus/allowlist violation'); + return; + } + var s = frame(fc, a, n); + transaction = { + sid: CFG.SLAVE_ID, + fc: fc, + a: a, + n: n, + cb: cb, + until: now() + CFG.TIMEOUT_MS, + request: s, + }; + rxBuffer = ''; + try { + if (uart.send(s) !== s.length) finish(null, 'short UART send'); + } catch (e) { + finish(null, 'UART send failed'); + } +} +function receive(data) { + lastRxAt = now(); + if (!transaction || typeof data !== 'string') { + rxBuffer = ''; + return; + } + rxBuffer += data; + if (rxBuffer.length > 256) { + finish(null, 'RX overflow'); + quietUntil = now() + CFG.TIMEOUT_MS; + return; + } + var scans = 0; + while (transaction && rxBuffer.length >= 5 && scans < 256) { + scans++; + var fc = rxBuffer.charCodeAt(1), + len = 0, + expected = transaction.fc; + if ( + rxBuffer.charCodeAt(0) !== transaction.sid || + (fc !== expected && fc !== (expected | 128)) + ) { + rxBuffer = rxBuffer.slice(1); + continue; + } + if (fc & 128) len = 5; + else if (expected === 5 || expected === 6) len = 8; + else { + var count = expected === 1 ? 1 : transaction.n * 2; + if (rxBuffer.charCodeAt(2) !== count) { + rxBuffer = rxBuffer.slice(1); + continue; + } + len = 5 + count; + } + if (rxBuffer.length < len) return; + var s = rxBuffer.slice(0, len), + c = crc(s.slice(0, len - 2)); + if ( + (c & 255) !== s.charCodeAt(len - 2) || + c >> 8 !== s.charCodeAt(len - 1) + ) { + rxBuffer = rxBuffer.slice(1); + continue; + } + if (fc & 128) { + finish(null, 'Modbus exception ' + s.charCodeAt(2)); + return; + } + if (expected === 5 || expected === 6) { + if (s !== transaction.request) { + rxBuffer = rxBuffer.slice(len); + continue; + } + finish([], null); + return; // An echo is NOT proof; mandatory readback follows. + } + var values = []; + if (expected === 1) values = [(s.charCodeAt(3) & 1) !== 0]; + else + for (var i = 0; i < transaction.n; i++) + values.push((s.charCodeAt(3 + i * 2) << 8) | s.charCodeAt(4 + i * 2)); + finish(values, null); + return; + } +} + +function reject(role, why) { + if (actual) put(role, actual[role]); + notice = 'BLOCKED ' + role + ': ' + why; + status(); +} +function validCommand(role, v) { + if (role === 'power') return typeof v === 'boolean'; + if (role === 'mode') return v === 'Cooling' || v === 'Heating'; + if (role === 'cool') return inRange(v, 4, 25) && integer(v); + if (role === 'heat') return inRange(v, 30, 60) && integer(v); + return false; +} +function command(role, e) { + if (!e || !e.source || e.source === selfSource || e.source === 'sys') return; + var v = e.value, + i; + if (!synchronized || !actual || now() - lastSuccessAt > CFG.STALE_MS) { + reject(role, 'not synchronized'); + return; + } + if (!CFG.ENABLE_WRITES) { + reject(role, 'commissioning read-only'); + return; + } + if (!validCommand(role, v)) { + reject(role, 'value/range'); + return; + } + if (role === 'power' && v && actual.fault !== 0) { + reject(role, 'LG fault active'); + return; + } + for (i = 0; i < commandQueue.length; i++) { + if (commandQueue[i].role === role) { + commandQueue[i] = { role: role, value: v, born: now() }; + put(role, actual[role]); + if (phase === 'idle') dueAt = now(); + return; + } + } + if ( + near(v, actual[role]) && + (!activeCommand || activeCommand.role !== role) + ) { + put(role, actual[role]); + return; + } + if (commandQueue.length >= CFG.MAX_QUEUE) { + reject(role, 'queue full'); + return; + } + commandQueue.push({ role: role, value: v, born: now() }); + if (phase === 'idle') dueAt = now(); + put(role, actual[role]); + notice = 'queued ' + role; + status(); +} +function onPowerChanged(e) { + command('power', e); +} +function onModeChanged(e) { + command('mode', e); +} +function onCoolingChanged(e) { + command('cool', e); +} +function onHeatingChanged(e) { + command('heat', e); +} + +function onAddressChanged(e) { + if ( + stopped || + !e || + !e.source || + e.source === selfSource || + e.source === 'sys' + ) + return; + var addr = e.value; + if (!integer(addr) || !inRange(addr, 1, 247)) { + put('address', CFG.SLAVE_ID); + notice = 'BLOCKED address: use integer 1..247'; + status(); + return; + } + if (addr === CFG.SLAVE_ID) return; + var unconfirmed = activeCommand !== null; + // Quarantine the old request, including rapid A -> B -> A selections. + quietUntil = Math.max( + quietUntil, + now() + CFG.TIMEOUT_MS + (transaction ? CFG.TIMEOUT_MS : 0) + ); + CFG.SLAVE_ID = addr; + transaction = null; + completed = null; + rxBuffer = ''; + activeCommand = null; + commandQueue = []; + actual = null; + synchronized = false; + lastSuccessAt = -1; + lastRawLogAt = -60000; + failureCount = 0; + rawSnapshot = []; + readIndex = 0; + readRetry = 0; + phase = 'poll'; + dueAt = quietUntil; + notice = ''; + put('address', addr); + link( + 'UNSYNCED - selected Modbus address ' + + addr + + (unconfirmed ? ' | previous command unconfirmed' : '') + ); +} + +function fault(reason) { + synchronized = false; + commandQueue = []; + rawSnapshot = []; + readIndex = 0; + readRetry = 0; + if (transaction || completed) { + transaction = null; + completed = null; + rxBuffer = ''; + quietUntil = now() + CFG.TIMEOUT_MS; + } + if (activeCommand) { + reason = activeCommand.role + ' unconfirmed: ' + reason; + activeCommand = null; + } + failureCount = Math.min(failureCount + 1, 5); + dueAt = + now() + Math.min(CFG.BACKOFF_MAX_MS, 1000 * Math.pow(2, failureCount)); + phase = 'idle'; + notice = ''; + mirror(); + link('STALE: ' + reason + ' | last=' + lastStamp()); +} +function readResult(values, error) { + if (error) { + synchronized = false; + commandQueue = []; + mirror(); + link('STALE: ' + error + ' | last=' + lastStamp()); + if (readRetry < 1 && error.indexOf('Modbus exception') < 0) { + readRetry++; + readIndex = 0; + rawSnapshot = []; + dueAt = now() + 250; + return; + } + fault(error); + return; + } + rawSnapshot[readIndex] = values; + readIndex++; +} +function snapshot() { + var h = rawSnapshot[1], + t = rawSnapshot[2], + s = {}; + s.power = rawSnapshot[0][0]; + s.mode = + h[0] === 0 ? 'Cooling' : h[0] === CFG.HEATING_MODE ? 'Heating' : 'Unknown'; + s.cool = round1(signed(h[1]) * CFG.SETPOINT_SCALE); + s.heat = round1(signed(h[2]) * CFG.SETPOINT_SCALE); + s.address = CFG.SLAVE_ID; + s.lwt = round1(signed(t[0]) * CFG.SENSOR_SCALE); + s.ewt = round1(signed(t[1]) * CFG.SENSOR_SCALE); + s.fault = rawSnapshot[3][0]; + if (now() - lastRawLogAt >= 30000 || !synchronized) { + log( + 'SID=' + + CFG.SLAVE_ID + + ' RAW holding40002..6=' + + JSON.stringify(h) + + ' input30004..5=' + + JSON.stringify(t) + + ' fault=' + + s.fault + ); + lastRawLogAt = now(); + } + if ( + s.mode === 'Unknown' || + !inRange(s.cool, 4, 25) || + !inRange(s.heat, 30, 60) || + !inRange(s.lwt, -30, 100) || + !inRange(s.ewt, -30, 100) + ) { + fault('MAP/SCALE CHECK - compare RAW with HMI'); + return; + } + actual = s; + lastSuccessAt = now(); + synchronized = true; + failureCount = 0; + mirror(); + if (activeCommand) { + if ( + near(s[activeCommand.role], activeCommand.value) && + (!activeCommand.error || + activeCommand.error.indexOf('Modbus exception') < 0) + ) { + notice = 'CONFIRMED ' + activeCommand.role + '=' + activeCommand.value; + } else { + notice = + 'MISMATCH ' + activeCommand.role + ' actual=' + s[activeCommand.role]; + commandQueue = []; + if (activeCommand.error) notice += ' (' + activeCommand.error + ')'; + } + activeCommand = null; + } + rawSnapshot = []; + readIndex = 0; + phase = 'idle'; + dueAt = commandQueue.length ? now() : now() + CFG.POLL_MS; + status(); +} +function writeResult(values, error) { + if (error) { + synchronized = false; + commandQueue = []; + mirror(); + link( + 'STALE: write acknowledgement uncertain/rejected | last=' + lastStamp() + ); + } + activeCommand.error = error; + phase = 'settle'; + dueAt = now() + CFG.SETTLE_MS; + // Even lost response is resolved by reading; never resend this write. +} +function beginCommand() { + while (commandQueue.length) { + var c = commandQueue.shift(); + if (now() - c.born > CFG.COMMAND_TTL_MS) { + notice = 'EXPIRED ' + c.role; + status(); + continue; + } + if (!synchronized || !CFG.ENABLE_WRITES || !validCommand(c.role, c.value)) + continue; + if (near(actual[c.role], c.value)) continue; + if (c.role === 'power' && c.value && actual.fault !== 0) { + reject(c.role, 'LG fault active'); + continue; + } + activeCommand = c; + notice = ''; + status(); + if (c.role === 'power') send(5, 0, c.value ? 65280 : 0, writeResult); + else if (c.role === 'mode') + send(6, 1, c.value === 'Heating' ? CFG.HEATING_MODE : 0, writeResult); + else { + var raw = c.value / CFG.SETPOINT_SCALE; + if (!integer(raw)) { + activeCommand = null; + reject(c.role, 'non-representable raw value'); + return; + } + send(6, c.role === 'cool' ? 2 : 3, raw, writeResult); + } + return; + } +} +function tick() { + if (stopped) return; + var t = now(); + if (transaction && t >= transaction.until) { + quietUntil = t + CFG.TIMEOUT_MS; + finish(null, 'timeout/CRC/no matching frame'); + } + if (completed) { + var d = completed; + completed = null; + d.cb(d.values, d.error); + } + if (synchronized && t - lastSuccessAt > CFG.STALE_MS) { + fault('poll freshness timeout'); + } + if ( + transaction || + completed || + t < nextBusAt || + t < quietUntil || + t - lastRxAt < 20 || + t < dueAt + ) + return; + if (phase === 'settle') { + phase = 'poll'; + readIndex = 0; + rawSnapshot = []; + readRetry = 0; + } + if (phase === 'idle') { + if (synchronized && commandQueue.length) { + beginCommand(); + if (transaction) return; + } + phase = 'poll'; + readIndex = 0; + rawSnapshot = []; + readRetry = 0; + } + if (phase === 'poll') { + if (readIndex === READ_REQUESTS.length) { + snapshot(); + return; + } + var r = READ_REQUESTS[readIndex]; + send(r.fc, r.a, r.n, readResult); + } +} + +function attachUart() { + try { + uart = UART.get(CFG.SERIAL_ID); + if (!uart) { + stop('UART ' + CFG.SERIAL_ID + ' unavailable/occupied'); + return; + } + uart.configure({ baud: 9600, format: '8N1' }); + uart.recv(receive); + vc.power.on('change', onPowerChanged); + vc.mode.on('change', onModeChanged); + vc.cool.on('change', onCoolingChanged); + vc.heat.on('change', onHeatingChanged); + vc.address.on('change', onAddressChanged); + var selected = vc.address.getValue(); + if (integer(selected) && inRange(selected, 1, 247)) CFG.SLAVE_ID = selected; + else put('address', CFG.SLAVE_ID); + dueAt = now() + 500; + clockTimer = Timer.set(100, true, tick); + link('UNSYNCED - reading LG first'); + } catch (e) { + stop('UART/jsuart API unavailable: ' + String(e)); + } +} +function serialWritten(r, e, m) { + if (e) { + stop('Serial.SetConfig ' + e + ' ' + m); + return; + } + if (r && r.restart_required) { + stop('Serial config requires Shelly restart'); + return; + } + attachUart(); +} +function serialRead(r, e, m) { + if (e || !r || !r.serial) { + stop('Serial.GetConfig ' + CFG.SERIAL_ID + ': ' + m); + return; + } + if ( + r.mode === 'jsuart' && + r.serial.baud === 9600 && + r.serial.format === '8N1' + ) { + attachUart(); + return; + } + if (!CFG.CONFIGURE_SERIAL) { + stop('Set selected serial port to jsuart / 9600 / 8N1'); + return; + } + try { + var key = 'lgser' + CFG.SERIAL_ID; + if (Script.storage.getItem(key) === null) { + Script.storage.setItem(key, JSON.stringify(r)); + log('Serial rollback config=' + JSON.stringify(r)); + } + } catch (x) { + stop('Cannot preserve original serial config'); + return; + } + Shelly.call( + 'Serial.SetConfig', + { + id: CFG.SERIAL_ID, + config: { mode: 'jsuart', serial: { baud: 9600, format: '8N1' } }, + }, + serialWritten + ); +} +function scanPage(r, e, m) { + if (e || !r || !r.components || !integer(r.offset) || !integer(r.total)) { + stop('GetComponents: ' + m); + return; + } + for (var i = 0; i < r.components.length; i++) { + var c = r.components[i]; + if (c.key === 'serial:' + CFG.SERIAL_ID) { + serialPort = c; + portMatches++; + } + } + var end = r.offset + r.components.length; + if (end < r.total) { + if (end <= pageOffset) { + stop('Non-progressing component pagination'); + return; + } + pageOffset = end; + Shelly.call( + 'Shelly.GetComponents', + { offset: pageOffset, include: ['config'] }, + scanPage + ); + return; + } + if ( + portMatches !== 1 || + !serialPort.attrs || + !serialPort.attrs.modes || + serialPort.attrs.modes.indexOf('jsuart') < 0 + ) { + stop( + 'serial:' + + CFG.SERIAL_ID + + ' does not advertise jsuart; verify device/add-on/firmware' + ); + return; + } + Shelly.call('Serial.GetConfig', { id: CFG.SERIAL_ID }, serialRead); +} +function configureVirtualComponents(index) { + if (index >= VC_ROLES.length) { + link('UNSYNCED - checking serial capabilities'); + Shelly.call( + 'Shelly.GetComponents', + { offset: 0, include: ['config'] }, + scanPage + ); + return; + } + var role = VC_ROLES[index], + current = vc[role].getConfig(); + if (!current || !integer(current.id)) { + stop('Missing managed config for ' + role); + return; + } + var type = + role === 'power' + ? 'Boolean' + : role === 'mode' + ? 'Enum' + : role === 'link' + ? 'Text' + : 'Number'; + Shelly.call( + type + '.SetConfig', + { id: current.id, config: VC_CONFIG[role] }, + virtualConfigWritten, + index + ); +} +function virtualConfigWritten(result, error, message, index) { + if (error) { + stop('Managed VC configuration: ' + VC_ROLES[index] + ' ' + message); + return; + } + configureVirtualComponents(index + 1); +} + +function init() { + if ( + typeof Script === 'undefined' || + typeof Script.getVcHandle !== 'function' || + typeof UART === 'undefined' + ) { + log('STOP: working managed VC and UART APIs required'); + return; + } + if ( + !integer(CFG.SLAVE_ID) || + CFG.SLAVE_ID < 1 || + CFG.SLAVE_ID > 247 || + !integer(CFG.SERIAL_ID) || + CFG.SERIAL_ID < 0 || + (CFG.SETPOINT_SCALE !== 0.1 && CFG.SETPOINT_SCALE !== 1) || + (CFG.SENSOR_SCALE !== 0.1 && CFG.SENSOR_SCALE !== 1) || + CFG.HEATING_MODE !== 4 || + !integer(CFG.MAX_QUEUE) || + !inRange(CFG.MAX_QUEUE, 1, 4) || + !integer(CFG.POLL_MS) || + !inRange(CFG.POLL_MS, 1000, 60000) || + !integer(CFG.COMMAND_TTL_MS) || + !inRange(CFG.COMMAND_TTL_MS, 1000, 60000) || + !integer(CFG.GAP_MS) || + !inRange(CFG.GAP_MS, 100, 5000) || + !integer(CFG.TIMEOUT_MS) || + !inRange(CFG.TIMEOUT_MS, 500, 5000) || + !integer(CFG.SETTLE_MS) || + !inRange(CFG.SETTLE_MS, 500, 10000) || + !integer(CFG.STALE_MS) || + !inRange(CFG.STALE_MS, CFG.POLL_MS * 2, 180000) || + !integer(CFG.BACKOFF_MAX_MS) || + !inRange(CFG.BACKOFF_MAX_MS, 1000, 60000) || + typeof CFG.CONFIGURE_SERIAL !== 'boolean' || + typeof CFG.ENABLE_WRITES !== 'boolean' + ) { + log('STOP: invalid configuration'); + return; + } + selfSource = 'script:' + Script.id; + for (var i = 0; i < VC_ROLES.length; i++) { + vc[VC_ROLES[i]] = Script.getVcHandle(VC_ROLES[i]); + if (!vc[VC_ROLES[i]]) { + stop('Missing managed VC role ' + VC_ROLES[i]); + return; + } + } + var info = Shelly.getDeviceInfo(); + log( + 'v1.1.0 | device=' + + info.model + + ' firmware=' + + info.ver + + ' | writes=' + + CFG.ENABLE_WRITES + ); + configureVirtualComponents(0); +} + +init(); diff --git a/modbus/LG/tests/kchh020-bridge.test.cjs b/modbus/LG/tests/kchh020-bridge.test.cjs new file mode 100644 index 0000000..47fa81b --- /dev/null +++ b/modbus/LG/tests/kchh020-bridge.test.cjs @@ -0,0 +1,860 @@ +const fs = require('fs'); +const vm = require('vm'); +const assert = require('assert/strict'); +const artifact = fs.readFileSync( + require('node:path').join( + __dirname, + '..', + 'lg-kchh020ldgc-pro-rs485-vc.shelly.js' + ), + 'utf8' +); +const meta = JSON.parse(artifact.match(/^\/\* @meta ([\s\S]*?)\*\//)[1]); + +// Independent Modbus CRC implementation using a lookup table. +const table = Array.from({ length: 256 }, (_, v) => { + for (let j = 0; j < 8; j++) v = v & 1 ? (v >>> 1) ^ 0xa001 : v >>> 1; + return v; +}); +function crc(buf) { + let c = 0xffff; + for (const n of buf) c = (c >>> 8) ^ table[(c ^ n) & 255]; + return c; +} +function packet(bytes) { + const b = Buffer.from(bytes), + c = crc(b); + return Buffer.concat([b, Buffer.from([c & 255, c >>> 8])]); +} + +class Fixture { + constructor(options = {}) { + this.options = options; + this.ms = 0; + this.jobs = []; + this.seq = 0; + this.logs = []; + this.frames = []; + this.writes = []; + this.events = []; + this.serialChanges = []; + this.maxTimers = 0; + this.storage = options.storage || new Map(); + this.components = options.components || new Map(); + this.serial = JSON.parse( + JSON.stringify( + options.serial || { + mode: 'disabled', + serial: { baud: 115200, format: '8N1' }, + } + ) + ); + this.plant = { + power: false, + h: [0, 70, 350, 1, 1], + t: [125, 70, 120], + fault: 0, + }; + Object.assign(this.plant, options.plant || {}); + this.plants = new Map([[1, this.plant]]); + for (const [id, plant] of Object.entries(options.plants || {})) + this.plants.set(Number(id), plant); + this.provision(); + this.handles = {}; + this.rx = null; + this.timerIds = new Set(); + const script = { + id: 3, + storage: { + getItem: k => (this.storage.has(k) ? this.storage.get(k) : null), + setItem: (k, v) => this.storage.set(k, v), + }, + getVcHandle: role => this.handle(role), + }; + const uart = { + configure: o => + assert.deepEqual(JSON.parse(JSON.stringify(o)), { + baud: 9600, + format: '8N1', + }), + send: s => this.transmit(s), + recv: cb => { + this.rx = cb; + }, + }; + const sandbox = { + print: s => this.logs.push(String(s)), + Script: script, + UART: { + get: id => (options.uartUnavailable ? null : id === 100 ? uart : null), + }, + Timer: { + set: (delay, repeat, fn) => { + const id = ++this.seq; + this.timerIds.add(id); + this.maxTimers = Math.max(this.maxTimers, this.timerIds.size); + const run = () => { + if (!this.timerIds.has(id)) return; + fn(); + if (repeat) this.schedule(delay, run); + else this.timerIds.delete(id); + }; + this.schedule(delay, run); + return id; + }, + clear: id => this.timerIds.delete(id), + }, + Shelly: { + getUptimeMs: () => this.ms, + getDeviceInfo: () => ({ + model: 'SPEM-002CEBEU50', + ver: '2.0.1', + app: 'ProEM', + }), + call: (method, params, cb, userdata) => + this.rpc(method, params, cb, userdata), + }, + }; + this.ctx = vm.createContext(sandbox); + vm.runInContext(artifact.replace(/\ninit\(\);\s*$/, '\n'), this.ctx, { + timeout: 1500, + }); + Object.assign(this.ctx.CFG, options.cfg || {}); + if (options.prestart) options.prestart(this.ctx); + this.ctx.init(); + } + provision() { + const owned = [...this.components.values()].filter( + c => c.owner === 'script:3' + ); + const existingRoles = new Set(owned.map(c => c.role)); + const missing = Object.keys(meta.vc).filter( + role => !existingRoles.has(role) + ); + const obsolete = owned.filter(c => !meta.vc[c.role]); + if (this.components.size - obsolete.length + missing.length > 10) + throw new Error('firmware VC budget'); + for (const c of obsolete) this.components.delete(c.key); + for (const [role, spec] of Object.entries(meta.vc)) { + let c = owned.find(c => c.role === role); + if (!c) { + let id = 200; + while (this.components.has(spec.type + ':' + id)) id++; + const key = spec.type + ':' + id; + c = { key, role, owner: 'script:3', value: spec.config.default_value }; + this.components.set(key, c); + } + c.spec = structuredClone(spec); + if (this.options.stale && role in this.options.stale) + c.value = this.options.stale[role]; + } + } + handle(role) { + if (this.handles[role]) return this.handles[role]; + const c = [...this.components.values()].find( + c => c.owner === 'script:3' && c.role === role + ); + if (!c) return null; + const listeners = []; + this.handles[role] = { + getValue: () => c.value, + getStatus: () => ({ value: c.value, source: c.source }), + getConfig: () => ({ + id: Number(c.key.split(':')[1]), + ...structuredClone(c.spec.config), + }), + setValue: v => + this.schedule(1, () => this.set(c, v, 'script:3', listeners)), + on: (event, cb) => { + assert.equal(event, 'change'); + listeners.push(cb); + return listeners.length; + }, + input: (v, source = 'http') => this.set(c, v, source, listeners), + force: (v, source = 'http') => { + for (const cb of listeners) + this.schedule(1, () => cb({ source, value: v })); + }, + }; + return this.handles[role]; + } + set(c, value, source, listeners) { + const old = c.value; + c.value = value; + c.source = source; + if (old !== value) + for (const cb of listeners) this.schedule(1, () => cb({ source, value })); + } + input(role, value, source = 'http') { + if (!meta.vc[role].access.includes('w')) return false; + this.handles[role].input(value, source); + return true; + } + value(role) { + return this.handle(role).getValue(); + } + schedule(delay, fn) { + this.jobs.push({ at: this.ms + delay, fn, id: ++this.seq }); + } + advance(duration) { + const end = this.ms + duration; + let iterations = 0; + while (true) { + this.jobs.sort((a, b) => a.at - b.at || a.id - b.id); + if (!this.jobs.length || this.jobs[0].at > end) break; + const j = this.jobs.shift(); + this.ms = j.at; + j.fn(); + assert(++iterations < 100000, 'scheduler runaway'); + } + this.ms = end; + } + rpc(method, p, cb, userdata) { + this.schedule(1, () => { + if (method === 'Shelly.GetComponents') { + const cs = [ + { key: 'sys' }, + { key: 'script:3' }, + ...Array.from(this.components.values()).map(c => ({ + key: c.key, + attrs: { owner: c.owner, role: c.role }, + })), + { + key: 'serial:100', + attrs: { + modes: this.options.noJsuart + ? ['mb_client'] + : ['disabled', 'jsuart', 'mb_client'], + }, + }, + ]; + const offset = p.offset || 0; + cb( + { + components: cs.slice(offset, offset + 3), + offset, + total: cs.length, + }, + 0, + '' + ); + } else if (method === 'Serial.GetConfig') + cb(structuredClone(this.serial), 0, ''); + else if (method === 'Serial.SetConfig') { + this.serialChanges.push(JSON.parse(JSON.stringify(p))); + Object.assign(this.serial, structuredClone(p.config)); + cb({ restart_required: false }, 0, ''); + } else if (method.endsWith('.SetConfig')) { + const type = method.split('.')[0].toLowerCase(); + const c = this.components.get(type + ':' + p.id); + assert(c && c.owner === 'script:3'); + if (c.role === this.options.configFailureRole) { + cb(null, -104, 'simulated config rejection', userdata); + return; + } + Object.assign(c.spec.config, JSON.parse(JSON.stringify(p.config))); + cb({ restart_required: false }, 0, '', userdata); + } else throw Error('Unapproved RPC ' + method); + }); + } + transmit(s) { + const b = Buffer.from(s, 'latin1'); + assert.equal(b.length, 8); + assert.equal(crc(b.subarray(0, 6)), b.readUInt16LE(6)); + const req = { + sid: b[0], + fc: b[1], + a: b.readUInt16BE(2), + n: b.readUInt16BE(4), + at: this.ms, + }; + assert(req.sid >= 1 && req.sid <= 247); + this.frames.push(req); + if (req.fc === 5 || req.fc === 6) this.writes.push(req); + const plant = this.plants.get(req.sid); + if (!plant) return s.length; + const behavior = this.options.behavior + ? this.options.behavior(req, this) + : {}; + let out; + if (behavior.exception) + out = packet([req.sid, req.fc | 128, behavior.exception]); + else if (req.fc === 1) out = packet([req.sid, 1, 1, plant.power ? 1 : 0]); + else if (req.fc === 3) { + assert.equal(req.a, 1); + assert.equal(req.n, 5); + const bytes = plant.h.flatMap(v => [(v >>> 8) & 255, v & 255]); + out = packet([req.sid, 3, 10, ...bytes]); + } else if (req.fc === 4) { + let v = req.a === 3 ? plant.t.slice(1) : [plant.fault]; + assert((req.a === 3 && req.n === 2) || (req.a === 9 && req.n === 1)); + out = packet([ + req.sid, + 4, + v.length * 2, + ...v.flatMap(v => [(v >>> 8) & 255, v & 255]), + ]); + } else if (req.fc === 5) { + assert.equal(req.a, 0); + assert(req.n === 0 || req.n === 65280); + if (!behavior.notApplied) plant.power = req.n !== 0; + out = Buffer.from(b); + } else if (req.fc === 6) { + assert(req.a >= 1 && req.a <= 3); + if (!behavior.notApplied) plant.h[req.a - 1] = req.n; + out = Buffer.from(b); + } else throw Error('Undocumented function code ' + req.fc); + if (behavior.drop) return s.length; + if (behavior.corrupt) out[out.length - 1] ^= 0x40; + if (behavior.wrongSid) { + out[0] = 2; + out = packet(out.subarray(0, out.length - 2)); + } + if (behavior.noise) + out = Buffer.concat([Buffer.from([17, 3, 2, 0, 1, 0, 0]), out]); + const delay = behavior.delay || 30; + if (behavior.split) { + this.schedule(delay, () => + this.rx(out.subarray(0, 3).toString('latin1')) + ); + this.schedule(delay + 20, () => + this.rx(out.subarray(3).toString('latin1')) + ); + } else this.schedule(delay, () => this.rx(out.toString('latin1'))); + return s.length; + } + ready() { + this.advance(1800); + assert.equal(this.ctx.synchronized, true, this.logs.join('\n')); + return this; + } +} + +const results = []; +function test(name, fn) { + try { + fn(); + results.push({ name, status: 'PASS' }); + console.log('PASS', name); + } catch (e) { + results.push({ name, status: 'FAIL', error: e.stack }); + console.error('FAIL', name, e.stack); + } +} + +test('exactly nine managed roles with read-only telemetry', () => { + assert.equal(Object.keys(meta.vc).length, 9); + for (const r of ['ewt', 'lwt', 'fault', 'link']) + assert.equal(meta.vc[r].access, 'rc'); + assert.equal(meta.vc.address.access, 'wc'); + assert.equal(meta.vc.address.config.persisted, true); + assert.equal(meta.vc.outdoor, undefined); + const f = new Fixture().ready(); + assert.equal(f.components.size, 9); + assert.equal(f.maxTimers, 1); +}); +test('firmware reconciliation reruns reuse owned roles', () => { + const a = new Fixture().ready(); + const keys = [...a.components.keys()]; + const b = new Fixture({ + components: a.components, + storage: a.storage, + }).ready(); + assert.deepEqual([...b.components.keys()], keys); + assert.equal(b.components.size, 9); +}); +test('foreign component survives creation without ID assumptions', () => { + const foreign = { key: 'number:200', owner: 'script:99', value: 123 }; + const cs = new Map([[foreign.key, foreign]]); + new Fixture({ components: cs }).ready(); + assert.equal(cs.size, 10); + assert.equal(cs.get('number:200'), foreign); + assert.equal(foreign.value, 123); +}); +test('firmware budget failure is atomic in simulated reconciliation', () => { + const cs = new Map([ + ['number:200', { key: 'number:200', owner: 'foreign', value: 1 }], + ['number:201', { key: 'number:201', owner: 'foreign', value: 2 }], + ]); + assert.throws(() => new Fixture({ components: cs }), /VC budget/); + assert.equal(cs.size, 2); +}); +test('boot never writes stale UI values, including when commissioned', () => { + const f = new Fixture({ + cfg: { ENABLE_WRITES: true }, + stale: { power: true, mode: 'Heating', cool: 24, heat: 58 }, + }).ready(); + assert.equal(f.writes.length, 0); + assert.equal(f.value('power'), false); + assert.equal(f.value('cool'), 7); +}); +test('read-only controls restore actual state without Modbus writes', () => { + const f = new Fixture().ready(); + f.input('power', true); + f.input('cool', 12); + f.advance(2000); + assert.equal(f.writes.length, 0); + assert.equal(f.value('power'), false); + assert.equal(f.value('cool'), 7); +}); +test('FC05 power write is confirmed by subsequent coil read', () => { + const f = new Fixture({ cfg: { ENABLE_WRITES: true } }).ready(); + f.input('power', true); + f.advance(3000); + assert.equal(f.writes.length, 1); + assert.equal(f.writes[0].fc, 5); + assert.equal(f.writes[0].n, 65280); + assert.equal(f.value('power'), true); + assert(f.value('link').includes('CONFIRMED power')); +}); +test('FC06 cooling setpoint uses offset2 and verified candidate scale', () => { + const f = new Fixture({ cfg: { ENABLE_WRITES: true } }).ready(); + f.input('cool', 8); + f.advance(3000); + assert.equal(f.writes.length, 1); + assert.equal(f.writes[0].fc, 6); + assert.equal(f.writes[0].a, 2); + assert.equal(f.writes[0].n, 80); + assert.equal(f.value('cool'), 8); +}); +test('FC06 mode changes use supplied YAML enum4', () => { + const f = new Fixture({ cfg: { ENABLE_WRITES: true } }).ready(); + f.input('mode', 'Heating'); + f.advance(3000); + assert.equal(f.writes[0].a, 1); + assert.equal(f.writes[0].n, 4); + assert.equal(f.value('mode'), 'Heating'); +}); +test('heating permits60 and rejects20/NaN/Infinity', () => { + const f = new Fixture({ cfg: { ENABLE_WRITES: true } }).ready(); + for (const v of [20, NaN, Infinity]) { + f.handle('heat').force(v); + f.advance(50); + } + assert.equal(f.writes.length, 0); + f.input('heat', 60); + f.advance(3000); + assert.equal(f.writes.length, 1); + assert.equal(f.writes[0].a, 3); + assert.equal(f.writes[0].n, 600); +}); +test('sensor access denies external writes', () => { + const f = new Fixture().ready(); + assert.equal(f.input('lwt', 50), false); + assert.equal(f.value('lwt'), 7); +}); +test('changes from original LG controller are mirrored without feedback writes', () => { + const f = new Fixture({ cfg: { ENABLE_WRITES: true } }).ready(); + f.plant.power = true; + f.plant.h[0] = 4; + f.plant.h[1] = 90; + f.plant.t = [150, 380, 330]; + f.advance(7000); + assert.equal(f.value('power'), true); + assert.equal(f.value('mode'), 'Heating'); + assert.equal(f.value('cool'), 9); + assert.equal(f.value('lwt'), 38); + assert.equal(f.writes.length, 0); +}); +test('signed water register handles negative temperatures', () => { + const f = new Fixture({ plant: { t: [125, 65526, 120] } }).ready(); + assert.equal(f.value('lwt'), -1); +}); +test('unknown mode blocks synchronization and commands', () => { + const f = new Fixture({ + cfg: { ENABLE_WRITES: true }, + plant: { h: [3, 70, 350, 1, 1] }, + }); + f.advance(3000); + assert.equal(f.ctx.synchronized, false); + assert(f.value('link').includes('MAP/SCALE')); + f.input('cool', 8); + f.advance(3000); + assert.equal(f.writes.length, 0); +}); +test('wrong setpoint scale is rejected, independent scales supported', () => { + const bad = new Fixture({ plant: { h: [0, 7, 35, 1, 1] } }); + bad.advance(2000); + assert.equal(bad.ctx.synchronized, false); + const good = new Fixture({ + plant: { h: [0, 7, 35, 1, 1] }, + cfg: { SETPOINT_SCALE: 1, SENSOR_SCALE: 0.1 }, + }).ready(); + assert.equal(good.value('heat'), 35); + assert.equal(good.value('ewt'), 12); +}); +test('bad CRC marks stale; reconnect reads fresh values without replay', () => { + let corrupt = false; + const f = new Fixture({ + cfg: { ENABLE_WRITES: true }, + behavior: () => ({ corrupt }), + }).ready(); + corrupt = true; + f.advance(12000); + assert.equal(f.ctx.synchronized, false); + f.input('power', true); + f.advance(100); + corrupt = false; + f.advance(15000); + assert.equal(f.ctx.synchronized, true); + assert.equal(f.writes.length, 0); + assert.equal(f.value('power'), false); +}); +test('lost write acknowledgement is not retried and is resolved by readback', () => { + const f = new Fixture({ + cfg: { ENABLE_WRITES: true }, + behavior: r => ({ drop: r.fc === 6 }), + }).ready(); + f.input('cool', 9); + f.advance(7000); + assert.equal(f.writes.length, 1); + assert.equal(f.value('cool'), 9); + assert(f.value('link').includes('CONFIRMED cool')); +}); +test('write echo with no applied command does not count as success', () => { + const f = new Fixture({ + cfg: { ENABLE_WRITES: true }, + behavior: r => ({ notApplied: r.fc === 5 }), + }).ready(); + f.input('power', true); + f.advance(3500); + assert.equal(f.writes.length, 1); + assert.equal(f.value('power'), false); + assert(f.value('link').includes('MISMATCH power')); +}); +test('Modbus exception is rejected and not blindly retried', () => { + const f = new Fixture({ + cfg: { ENABLE_WRITES: true }, + behavior: r => ({ exception: r.fc === 6 ? 3 : 0 }), + }).ready(); + f.input('heat', 40); + f.advance(3500); + assert.equal(f.writes.length, 1); + assert.equal(f.value('heat'), 35); + assert(f.value('link').includes('MISMATCH heat')); +}); +test('queued duplicate control changes coalesce to the last value', () => { + const f = new Fixture({ cfg: { ENABLE_WRITES: true } }).ready(); + f.input('cool', 8); + f.input('cool', 9); + f.input('cool', 10); + f.advance(3500); + assert.equal(f.writes.length, 1); + assert.equal(f.writes[0].n, 100); +}); +test('commands expire while waiting for another command verification', () => { + const f = new Fixture({ + cfg: { ENABLE_WRITES: true, COMMAND_TTL_MS: 1000, SETTLE_MS: 4000 }, + }).ready(); + f.input('cool', 8); + f.input('heat', 40); + f.advance(8500); + assert.equal(f.writes.length, 1); + assert.equal(f.writes[0].a, 2); +}); +test('fragmented responses and unrelated leading bytes are handled', () => { + const f = new Fixture({ + behavior: () => ({ split: true, noise: true }), + }).ready(); + assert.equal(f.value('ewt'), 12); + assert.equal(f.writes.length, 0); +}); +test('wrong slave responses do not synchronize or enable writes', () => { + const f = new Fixture({ + cfg: { ENABLE_WRITES: true }, + behavior: () => ({ wrongSid: true }), + }); + f.advance(7000); + assert.equal(f.ctx.synchronized, false); + assert.equal(f.writes.length, 0); +}); +test('RX overflow is bounded and blocks commands', () => { + const f = new Fixture({ + cfg: { ENABLE_WRITES: true }, + behavior: () => ({ drop: true }), + }); + f.advance(700); + f.rx('x'.repeat(300)); + f.advance(5000); + assert.equal(f.ctx.synchronized, false); + assert(f.ctx.rxBuffer.length <= 256); +}); +test('broadcast address and invalid timer config rejected at startup', () => { + for (const cfg of [ + { SLAVE_ID: 0 }, + { POLL_MS: NaN }, + { GAP_MS: 0 }, + { TIMEOUT_MS: 6000 }, + ]) { + const f = new Fixture({ cfg }); + f.advance(3000); + assert.equal(f.frames.length, 0); + } +}); +test('no jsuart capability stops before serial reconfiguration', () => { + const f = new Fixture({ noJsuart: true }); + f.advance(1000); + assert.equal(f.frames.length, 0); + assert.equal(f.serialChanges.length, 0); + assert.equal(f.ctx.stopped, true); +}); +test('unavailable UART handle stops without Modbus frames', () => { + const f = new Fixture({ uartUnavailable: true }); + f.advance(1000); + assert.equal(f.frames.length, 0); + assert.equal(f.ctx.stopped, true); +}); +test('original serial configuration backed up once and retained', () => { + const storage = new Map(); + const first = new Fixture({ storage }).ready(); + const saved = storage.get('lgser100'); + assert.equal(JSON.parse(saved).mode, 'disabled'); + new Fixture({ + storage, + serial: { mode: 'mb_client', serial: { baud: 19200, format: '8E1' } }, + }).ready(); + assert.equal(storage.get('lgser100'), saved); + assert.equal(first.serialChanges[0].config.mode, 'jsuart'); +}); +test('allowlist excludes installer registers and FC15', () => { + const f = new Fixture({ cfg: { ENABLE_WRITES: true } }).ready(); + assert.equal(f.ctx.allowed(6, 4, 1), false); + assert.equal(f.ctx.allowed(6, 0, 100), false); + assert.equal(f.ctx.allowed(15, 0, 1), false); + assert.equal(f.ctx.allowed(5, 1, 65280), false); +}); +test('CRC agrees with a known Modbus vector', () => { + const f = new Fixture(); + const input = Buffer.from('010300000001', 'hex'); + assert.equal(f.ctx.crc(input.toString('latin1')), 0x0a84); + assert.equal(crc(input), 0x0a84); +}); +test('enabled plant fault blocks start request but allows stop', () => { + const f = new Fixture({ + cfg: { ENABLE_WRITES: true }, + plant: { fault: 123 }, + }).ready(); + f.input('power', true); + f.advance(3000); + assert.equal(f.writes.length, 0); + f.plant.power = true; + f.advance(7000); + assert.equal(f.value('power'), true); + f.input('power', false); + f.advance(3000); + assert.equal(f.writes.length, 1); + assert.equal(f.writes[0].n, 0); +}); +test('user commands have separate desired and actual values until readback', () => { + const f = new Fixture({ cfg: { ENABLE_WRITES: true } }).ready(); + f.input('cool', 8); + f.advance(300); + assert.equal(f.value('cool'), 7); + f.advance(3000); + assert.equal(f.value('cool'), 8); +}); +test('first read timeout blocks queued writes before retry and starts a fresh snapshot', () => { + let drop = false; + const f = new Fixture({ + cfg: { ENABLE_WRITES: true }, + behavior: r => ({ drop: drop && r.fc === 4 }), + }).ready(); + drop = true; + f.advance(8000); + assert.equal(f.ctx.synchronized, false); + assert.equal(f.ctx.commandQueue.length, 0); + f.input('power', true); + f.advance(100); + drop = false; + f.plant.t = [160, 90, 140]; + f.advance(12000); + assert.equal(f.ctx.synchronized, true); + assert.equal(f.writes.length, 0); + assert.equal(f.value('power'), false); + assert.equal(f.value('lwt'), 9); +}); +test('write timeout clears other queued commands before readback recovery', () => { + const f = new Fixture({ + cfg: { ENABLE_WRITES: true }, + behavior: r => ({ drop: r.fc === 6 }), + }).ready(); + f.input('cool', 8); + f.input('heat', 40); + f.advance(7000); + assert.equal(f.writes.length, 1); + assert.equal(f.value('cool'), 8); + assert.equal(f.value('heat'), 35); +}); +test('Modbus address tile selects a new server without any actuator write', () => { + const second = { + power: true, + h: [4, 90, 400, 1, 1], + t: [130, 380, 320], + fault: 0, + }; + const f = new Fixture({ plants: { 2: second } }).ready(); + f.input('address', 2); + f.advance(50); + assert.equal(f.ctx.synchronized, false); + assert.equal(f.ctx.CFG.SLAVE_ID, 2); + f.advance(5000); + assert.equal(f.ctx.synchronized, true); + assert.equal(f.value('address'), 2); + assert.equal(f.value('heat'), 40); + assert.equal(f.value('power'), true); + assert.equal(f.writes.length, 0); + assert(f.frames.slice(-4).every(r => r.sid === 2)); +}); +test('selected Modbus address persists across script restart', () => { + const second = { + power: true, + h: [4, 90, 400, 1, 1], + t: [130, 380, 320], + fault: 0, + }; + const first = new Fixture({ plants: { 2: second } }).ready(); + first.input('address', 2); + first.advance(5000); + const reboot = new Fixture({ + components: first.components, + storage: first.storage, + plants: { 2: second }, + }).ready(); + assert.equal(reboot.ctx.CFG.SLAVE_ID, 2); + assert(reboot.frames.every(r => r.sid === 2)); + assert.equal(reboot.value('heat'), 40); + assert.equal(reboot.writes.length, 0); +}); +test('invalid address values are rejected without changing destination', () => { + const f = new Fixture().ready(); + for (const n of [0, 248, 1.5, NaN, Infinity, '2']) { + f.handle('address').force(n); + f.advance(25); + } + assert.equal(f.ctx.CFG.SLAVE_ID, 1); + assert.equal(f.value('address'), 1); + assert(f.frames.every(r => r.sid === 1)); + assert.equal(f.writes.length, 0); +}); +test('address change discards queued commands for the old server', () => { + const second = { + power: false, + h: [0, 90, 400, 1, 1], + t: [130, 90, 120], + fault: 0, + }; + const f = new Fixture({ + cfg: { ENABLE_WRITES: true }, + plants: { 2: second }, + }).ready(); + f.input('cool', 8); + f.input('heat', 40); + f.input('address', 2); + f.advance(5000); + assert.equal(f.writes.length, 0); + assert.equal(f.ctx.CFG.SLAVE_ID, 2); + assert.equal(f.value('cool'), 9); +}); +test('address change during an outstanding command cancels callbacks and never retries it', () => { + const second = { + power: false, + h: [0, 90, 400, 1, 1], + t: [130, 90, 120], + fault: 0, + }; + const f = new Fixture({ + cfg: { ENABLE_WRITES: true }, + plants: { 2: second }, + behavior: r => ({ delay: r.fc === 6 ? 700 : 30 }), + }).ready(); + f.input('cool', 8); + f.advance(150); + assert.equal(f.writes.length, 1); + f.input('address', 2); + f.advance(6000); + assert.equal(f.ctx.synchronized, true); + assert.equal(f.value('cool'), 9); + assert.equal(f.value('address'), 2); + assert.equal(f.writes.length, 1); + assert.equal(f.writes[0].sid, 1); +}); +test('rapid address selection A-B-A rejects old responses and re-reads A', () => { + const f = new Fixture({ behavior: r => ({ delay: 700 }) }); + f.advance(750); + assert(f.ctx.transaction); + f.input('address', 2); + f.advance(20); + f.input('address', 1); + f.advance(8000); + assert.equal(f.ctx.synchronized, true); + assert.equal(f.ctx.CFG.SLAVE_ID, 1); + assert.equal(f.value('address'), 1); + assert.equal(f.value('cool'), 7); + assert.equal(f.writes.length, 0); +}); +test('unresponsive selected address stays unsynchronized and blocks actuator commands', () => { + const f = new Fixture({ cfg: { ENABLE_WRITES: true } }).ready(); + f.input('address', 247); + f.advance(7000); + assert.equal(f.ctx.synchronized, false); + assert.equal(f.value('address'), 247); + f.input('power', true); + f.advance(3000); + assert.equal(f.writes.length, 0); +}); +test('same-slot manifest migration replaces only the old outdoor role and preserves foreign components', () => { + const old = new Fixture().ready(); + const tile = [...old.components.values()].find(c => c.role === 'address'); + tile.role = 'outdoor'; + tile.value = 15; + const foreign = { key: 'number:299', owner: 'script:99', value: 999 }; + old.components.set(foreign.key, foreign); + const updated = new Fixture({ + components: old.components, + storage: old.storage, + }).ready(); + assert.equal(updated.components.size, 10); + assert.equal(updated.components.get(foreign.key), foreign); + assert.equal( + [...updated.components.values()].filter(c => c.owner === 'script:3').length, + 9 + ); + assert(![...updated.components.values()].some(c => c.role === 'outdoor')); + assert.equal(updated.value('address'), 1); +}); + +test('managed metadata satisfies the physical 1024-character first-line limit', () => { + const first = artifact.split('\n')[0]; + assert(first.startsWith('/* @meta {') && first.endsWith('} */')); + assert(first.length <= 1024); + assert(artifact.trimEnd().endsWith('init();')); +}); +test('owned VC configuration failure prevents serial setup and bus requests', () => { + const f = new Fixture({ configFailureRole: 'heat' }); + f.advance(3000); + assert.equal(f.ctx.stopped, true); + assert.equal(f.serialChanges.length, 0); + assert.equal(f.frames.length, 0); +}); + +const failed = results.filter(r => r.status === 'FAIL'); +const report = { + validation: 'simulator-checked', + hardware_tested: false, + tests: results.length, + passed: results.length - failed.length, + failed: failed.length, + limits: + 'Node VM with documented APIs and firmware reconciliation model; cannot validate Espruino memory/parser, real UART/RS485, actual LG map/scales or cloud UI.', + results, +}; +if (process.env.LG_TEST_REPORT) + fs.writeFileSync(process.env.LG_TEST_REPORT, JSON.stringify(report, null, 2)); +console.log( + JSON.stringify({ + tests: report.tests, + passed: report.passed, + failed: report.failed, + }) +); +process.exitCode = failed.length ? 1 : 0;