From ad631828d2f4c4f07236fd1e4aff8ccc20006182 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Sat, 10 Oct 2026 18:18:39 +0300 Subject: [PATCH] Add tested Midea/Clivet R32 Modbus bridge Add nine virtual components for zone-1, DHW and Silent Mode control, water temperatures and combined diagnostics. Preserve shared register bits/bytes, refresh command guards and confirm writes by readback. Include installation instructions, protocol and license notes, 33 RPC/Modbus tests, the production manifest entry and the Modbus index/changelog. Real-device test passed, confirmed by the contributor on 2026-10-10. --- CHANGELOG.md | 1 + examples-manifest.json | 20 +- modbus/Midea/NOTICE | 16 + modbus/Midea/README.md | 183 ++++++++ modbus/Midea/TECHNICAL_NOTES.md | 60 +++ .../midea-clivet-r32-pro-modbus-vc.shelly.js | 427 ++++++++++++++++++ modbus/Midea/tests/bridge.test.cjs | 295 ++++++++++++ modbus/README.md | 1 + 8 files changed, 1001 insertions(+), 2 deletions(-) create mode 100644 modbus/Midea/NOTICE create mode 100644 modbus/Midea/README.md create mode 100644 modbus/Midea/TECHNICAL_NOTES.md create mode 100644 modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js create mode 100644 modbus/Midea/tests/bridge.test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8792217..cc6f1a1 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 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 - Add `modbus/Growatt/SPF6000/spf6000_vc.shelly.js` for the Growatt SPF 6000 ES Plus off-grid inverter, reusing the input-register map from `SFP5000` (same off-grid family, different power rating). No official protocol document for this exact model was found, so the register addresses are explicitly flagged as unverified in the file header and `@status under development` until confirmed on real hardware diff --git a/examples-manifest.json b/examples-manifest.json index 19029c4..54bb378 100644 --- a/examples-manifest.json +++ b/examples-manifest.json @@ -1564,6 +1564,22 @@ "pv" ] }, + { + "fname": "modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js", + "title": "Midea/Clivet R32 Modbus RTU bridge", + "description": "Local zone-1, DHW and Silent Mode control with nine Virtual Components. Requires firmware 2.0.0+ and Shelly Pro Modbus Add-on.", + "doc": "modbus/Midea/README.md", + "tags": [ + "modbus-controller", + "modbus-rtu", + "hvac", + "midea", + "clivet", + "r32", + "heat-pump", + "virtual-components" + ] + }, { "fname": "modbus/Sigenergy/SigenStor/sigenstor_plant_vc.shelly.js", "title": "Sigenergy SigenStor MODBUS-RTU plant monitor", @@ -1824,7 +1840,7 @@ { "fname": "power-energy/automatic-transfer-switch/iats_grid_backup_controller.shelly.js", "title": "iATS two-channel grid/backup automatic transfer switch controller", - "description": "Controls K1 (grid) and K2 (backup) contactors on a two-channel Shelly device (Pro 2, Pro 2PM, Plus 2PM, 2PM Gen3/Gen4 in switch profile). SW1/input:0 = grid available; SW2/input:1 = backup available. Grid is preferred when both sources are present. Physical inputs are detached from outputs on boot \u2014 this script is the sole owner of switch:0 (K2) and switch:1 (K1). Both outputs are opened before a transfer; a configurable delay prevents contactor overlap. Optional manual override is received from the Sensor Add-on helper via KVS; a stale-sequence watchdog reverts to AUTO when the helper stops publishing.", + "description": "Controls K1 (grid) and K2 (backup) contactors on a two-channel Shelly device (Pro 2, Pro 2PM, Plus 2PM, 2PM Gen3/Gen4 in switch profile). SW1/input:0 = grid available; SW2/input:1 = backup available. Grid is preferred when both sources are present. Physical inputs are detached from outputs on boot — this script is the sole owner of switch:0 (K2) and switch:1 (K1). Both outputs are opened before a transfer; a configurable delay prevents contactor overlap. Optional manual override is received from the Sensor Add-on helper via KVS; a stale-sequence watchdog reverts to AUTO when the helper stops publishing.", "doc": "power-energy/automatic-transfer-switch/README.md", "tags": [ "energy", @@ -1838,7 +1854,7 @@ { "fname": "power-energy/automatic-transfer-switch/iats_sensor_addon_manual_control.shelly.js", "title": "iATS Sensor Add-on manual control helper", - "description": "Companion script for the iATS controller, running on the Shelly Pro Sensor Add-on. DIN0 (input:100) selects AUTO vs MANUAL mode; DIN1 (input:101) selects GRID vs PV target. Publishes the current selection to KVS at a configurable interval. Does not touch relay outputs \u2014 iats_grid_backup_controller.shelly.js remains the sole owner of switch:0/1 and validates source availability before acting on any manual request.", + "description": "Companion script for the iATS controller, running on the Shelly Pro Sensor Add-on. DIN0 (input:100) selects AUTO vs MANUAL mode; DIN1 (input:101) selects GRID vs PV target. Publishes the current selection to KVS at a configurable interval. Does not touch relay outputs — iats_grid_backup_controller.shelly.js remains the sole owner of switch:0/1 and validates source availability before acting on any manual request.", "doc": "power-energy/automatic-transfer-switch/README.md", "tags": [ "energy", diff --git a/modbus/Midea/NOTICE b/modbus/Midea/NOTICE new file mode 100644 index 0000000..b640018 --- /dev/null +++ b/modbus/Midea/NOTICE @@ -0,0 +1,16 @@ +Midea/Clivet R32 Modbus RTU bridge +Copyright 2026 Georgi Germanov and contributors +SPDX-License-Identifier: Apache-2.0 + +This standalone script adapts the transport, deferred-callback, startup +synchronization and readback architecture of lg-therma-v-pro-em50_vc.shelly.js +published in drHouse-gif/lg-therma-v-shelly and ALLTERCO/shelly-script-examples. +The Midea register handling, fresh masked writes, nine-component layout, +non-destructive provisioning and test harness were added for this solution. + +The register data was cross-checked against the R32 profile maintained in +Mosibi/Midea-heat-pump-ESPHome and the 171H120F device configuration in +timlaing/modbus_local_gateway. Technical references are listed in +TECHNICAL_NOTES.md. No manufacturer endorsement is implied. + +Distributed under the repository's Apache License, Version 2.0. diff --git a/modbus/Midea/README.md b/modbus/Midea/README.md new file mode 100644 index 0000000..a993763 --- /dev/null +++ b/modbus/Midea/README.md @@ -0,0 +1,183 @@ +# Midea/Clivet R32 via Shelly Pro Modbus Add-on + +Local RS485 / Modbus RTU monitoring and control for Midea R32 hydronic heat +pumps and compatible Clivet controllers. The standalone script provisions +**exactly nine Virtual Components** and uses the original heat-pump controller. + +A successful real-device test was confirmed by the contributor on 2026-10-10. +The production profile remains scoped to the controller/register layout below. + +- Script: [midea-clivet-r32-pro-modbus-vc.shelly.js](midea-clivet-r32-pro-modbus-vc.shelly.js) +- Protocol and source details: [TECHNICAL_NOTES.md](TECHNICAL_NOTES.md) +- Software checks: [tests/bridge.test.cjs](tests/bridge.test.cjs) + +## Profile and connection + +The profile targets the documented Midea R32 / 171H120F-compatible register +layout with **zone 1 under leaving-water control**. Check the exact wired +controller and its service manual. Brand names alone do not select the map. +R290 controllers, Clivet SWAN-2 water heaters and room-control installations +require their own profiles. + +On the controller described by this profile: + +| Controller | RS485 Add-on | +| --- | --- | +| H2 / A+ | A | +| H1 / B- | B | +| Signal reference, if specified | Follow the controller's wiring manual | + +Do not substitute another two-wire bus or A/B power terminals for H1/H2. +Use twisted-pair cable, one Modbus client on the line, and the termination and +shield arrangement specified for the installation. The original controller +and the heat pump's built-in protections remain in service. + +Use a compatible Shelly Pro host, its Pro Modbus Add-on, and firmware 2.0.0 or +later with `Serial`, `MbRtuClient`, scripting and Virtual Components. Select +the RS485 add-on in the device configuration and reboot if requested. + +## Nine components + +Default `vcBase: 220` produces the following components: + +| Key | Name | Purpose | +| --- | --- | --- | +| `boolean:220` | Zone 1 Enable | Water-control enable flag for zone 1 | +| `boolean:221` | DHW Enable | Domestic hot water request | +| `boolean:222` | Silent Mode | Silent-mode enable flag | +| `number:223` | Zone 1 Water Target | Zone-1 target, integer degrees C | +| `number:224` | DHW Target | Tank target, integer degrees C | +| `number:225` | Inlet Temperature | Water return temperature | +| `number:226` | Outlet Temperature | Water leaving temperature | +| `number:227` | Tank Temperature | T5 sensor, when configured | +| `text:228` | Status | Online/read-only/control, actual mode, fault and version | + +Operating mode is selected on the original controller. Its actual operating +mode is included in Status. Zone 1 Enable is not a mains switch or a global +override of zone 2. The script does not change the silent-mode level. + +No groups are created. Existing unrelated components are not modified or +deleted. The script checks capacity before provisioning; nine slots are needed +within the device's ten-component limit. An ID/name conflict stops setup. +Change `vcBase` to a free range or dedicate a host to this bridge. + +## Configuration and first synchronization + +Create one Shelly script, paste the complete `.shelly.js` file and edit `CONFIG`: + +```javascript +serialId: 100, +slaveId: 1, +baud: 9600, +format: '8N1', +enableWrites: false, +expectedControllerVersion: null, +hasDhwTank: false, +allowCooling: false +``` + +`serialId` identifies the Shelly Serial/MbRtuClient instance. `slaveId` is the +address configured on the heat-pump controller. They are different identifiers. +Address 1 is an example; some controllers use a different address, including 16. +Find the Serial instance with `Shelly.GetComponents` if its ID differs from 100. + +Set `hasDhwTank: true` only when the tank and T5 sensor are present and correctly +configured. Otherwise tank-temperature and DHW-limit reads are omitted, DHW +writes are blocked, and the tank-temperature tile is unused. The default +zero in that unused tile is not a physical measurement. + +Start in read-only mode. The script creates/reuses its components, configures +`mode: 'mb_client'`, reads the physical values, and publishes them. It logs: + +```text +[Midea] Controller version (register 131) = +[Midea] READY: 9 components synchronized; read-only mode +``` + +Compare temperatures, flags and targets with the original controller and +verify the register layout. Set `expectedControllerVersion` to the reported +word, configure the installed temperature limits, then set `enableWrites: true` +and restart. A matching version is an additional guard, not automatic model +identification. Configured controls become toggles/fields after restart. + +## Temperature limits and controller behavior + +The default configured heating/DHW ceiling is 60 C. Change limits only to +values permitted by the installed system. Target writes must satisfy both +the configured bounds and the controller's live bounds: + +- Heating zone 1: low bytes of registers 203/204, upper/lower respectively. +- Cooling zone 1: low bytes of registers 201/202, upper/lower respectively. +- DHW: registers 207/208, upper/lower respectively. + +Select Heat or Cool on the original controller for manual water-target control. +Cooling requests require `allowCooling: true` and an installation commissioned +for cooling. A manual water target is blocked in Auto or when the zone-1 +weather curve is active. The integration does not disable the factory curve. +Room-control flag bit 0 of register 0 and an active fault block device writes. + +## Register map used by this script + +All reads use **Holding Registers, FC03**, including temperatures and faults. +Addresses below are wire offsets, not 4xxxx reference numbers. + +| Address | Meaning | Handling | +| --- | --- | --- | +| 0, bit 1 | Zone-1 water enable | Mask 0x0002 | +| 0, bit 2 | DHW enable | Mask 0x0004 | +| 1 | Configured mode | Read only: 1 Auto, 2 Cool, 3 Heat | +| 2, low byte | Zone-1 water target | Mask 0x00ff; preserve zone-2 high byte | +| 4 | DHW target | Integer C | +| 5, bit 6 | Silent Mode | Mask 0x0040; preserve other bits | +| 100/101 | Compressor frequency / actual mode | Status diagnostics | +| 104/105 | Inlet/outlet water temperature | Signed 16-bit, integer C | +| 115 | Tank temperature | Signed 16-bit, integer C; optional | +| 124 | Current fault | Raw fault number in Status | +| 128, bit 1 | Defrost | Included in Status | +| 131 | Controller version | Read and match configured version | +| 201-204 | Cooling/heating limits | Zone-1 low bytes | +| 207/208 | DHW upper/lower limits | Optional when tank is configured | + +Writes use FC06 by default. Set `writeFunction: 16` only if the exact controller +requires FC16; the script then writes a one-word list. There is no automatic +write-function fallback or blind write retry. + +## Command and reconnect behavior + +The first successful physical synchronization establishes the baseline. Values +left in the app or changed while the bridge is offline are not replayed on +startup/reconnect. Every changed control is checked, relevant guards refreshed, +and the target register read again before writing. + +Registers 0, 2 and 5 use a masked read-modify-write. For example, changing the +zone-1 target keeps the latest zone-2 high byte. After each write the script +waits `settleMs`, reads back the register and publishes the reported value. +Unconfirmed writes are logged rather than presented as successful commands. + +On a communication error, Status shows `OFFLINE | last readings stale`, commands +are blocked, and a full physical synchronization is required before control +resumes. Commands are sequential, with `gapMs` between operations and `pollMs` +between completed cycles. Normal defaults are 100 ms gaps, 700 ms settling, +and a 10-second polling pause. + +## Troubleshooting and software checks + +- No response: check H1/H2, polarity, configured server address, baud/format, + add-on selection, Serial ID and competing Modbus clients. +- Illegal address: verify the controller family and register table. +- Implausible temperatures: this map uses signed **whole degrees**, not LG's + tenths-of-a-degree conversion. +- Read-only status: check `enableWrites`, expected version and room-control flag. +- Rejected targets: check active faults, mode, weather curve and both sets of limits. +- Setup stops: check component capacity and ID/name conflicts. + +Run the deterministic RPC/Modbus tests from the repository root: + +```bash +node --check modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js +node --test modbus/Midea/tests/bridge.test.cjs +``` + +The harness checks nine-component provisioning, fresh bit/byte preservation, +signed temperatures, target limits, readback, timeout/reconnect handling and +UI synchronization. Details are recorded in [TECHNICAL_NOTES.md](TECHNICAL_NOTES.md). diff --git a/modbus/Midea/TECHNICAL_NOTES.md b/modbus/Midea/TECHNICAL_NOTES.md new file mode 100644 index 0000000..a4ba66d --- /dev/null +++ b/modbus/Midea/TECHNICAL_NOTES.md @@ -0,0 +1,60 @@ +# Midea R32 profile notes + +The script implements zone-1 leaving-water control using the R32 register layout. +Mode selection, zone 2, room-temperature control, weather-curve changes, +forced heaters, disinfection, energy counters and installer parameters are +outside the command surface of this nine-component bridge. + +## References + +- LG transport/startup/readback base: [drHouse-gif/lg-therma-v-shelly](https://github.com/drHouse-gif/lg-therma-v-shelly/blob/0a18acdafed25b9abe617cfda339fc47b0e3731b/upstream/lg-therma-v-pro-em50_vc.shelly.js). +- R32 register definitions: [Mosibi/Midea-heat-pump-ESPHome](https://github.com/Mosibi/Midea-heat-pump-ESPHome/blob/385ab6e698f6fd640abfa8c310edf257d2a94ece/models/R32-generic.yaml). +- 171H120F configuration: [timlaing/modbus_local_gateway](https://github.com/timlaing/modbus_local_gateway/blob/main/custom_components/modbus_local_gateway/device_configs/midea_heat_pump.yaml), inspected blob `75b80e19efd51b55581e6384de11603bf2f1d539`. +- [Shelly Serial API](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Serial/). +- [Shelly Modbus RTU Client API](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/MbRtuClient/). +- [Virtual Components](https://shelly-api-docs.shelly.cloud/gen2/DynamicComponents/Virtual/). +- [Shelly scripting language](https://shelly-api-docs.shelly.cloud/gen2/Scripts/LanguageReference/). + +The host runtime uses supported synchronous component lookups, callback-based +RPC and single-shot timers. There are no imports, promises, async functions, +classes, external services or manually constructed RTU frames. + +## Software verification + +On 2026-10-07, Node.js 24 ran 33 deterministic RPC/Modbus integration tests with +33 passes. Syntax validation used `node --check`. The harness executes the +complete standalone script with a register-server emulator and injected +transport faults. It checks the resulting Modbus writes and UI values. + +The checks cover startup/reconnect read-before-write, register-0 bit preservation, +register-2 high-byte preservation, register-5 function-bit preservation, signed +temperature conversion, current fault and curve guards, changed controller mode, +controller version, configured/live limits, explicit FC16, missing DHW hardware, +capacity/ownership and commands replaced during a preparatory read. + +## Real-device validation + +On 2026-10-10, the contributor confirmed that this bridge passed a real-device +test. The script is marked `production` and included in the example manifest. +This confirmation applies to the integration's documented R32 profile, not to +every heat pump sold under the Midea or Clivet brand. + +The validated script retains its fixed-ID, self-provisioned nine-component +layout. Its ownership and capacity preflight deliberately stops on an unrelated +component conflict instead of deleting or replacing another integration's VCs. +It starts Modbus operations only after all nine components have been prepared. + +## Integration boundaries + +Version register 131 is a compatibility guard; it is not a unique hardware ID. +R32, R290 and water-heater maps must not be selected by brand name alone. +An absent sensor can return a plausible constant, so `hasDhwTank` is explicit. +FC03 is required for the diagnostic values as well as writable words. + +There is one Modbus client per serial line. The script serializes its own +operations. A read-modify-write preserves the word sampled immediately before +the write; a controller changing that same word concurrently can still cause a +race because the interface does not provide an atomic masked-write operation. + +Virtual Component updates can trigger scenes/webhooks. Keep scene conditions +idempotent and do not create a feedback loop between reported values and commands. diff --git a/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js b/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js new file mode 100644 index 0000000..d0f061d --- /dev/null +++ b/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js @@ -0,0 +1,427 @@ +/** + * @title Midea/Clivet R32 Modbus RTU bridge + * @description Local zone-1, DHW and Silent Mode control with nine Virtual + * Components. Requires firmware 2.0.0+ and Shelly Pro Modbus Add-on. + * @status production + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js + */ + +/** + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2026 Georgi Germanov and contributors + * + * Real-device validation: successful test confirmed by the contributor on + * 2026-10-10. Compatibility remains limited to the documented R32 profile. + * + * Standalone adaptation of the LG THERMA V bridge in + * drHouse-gif/lg-therma-v-shelly (upstream/lg-therma-v-pro-em50_vc.shelly.js). + * The transport, read-before-write startup and readback pattern are retained; + * the register map, bit-preserving writes and component provisioning differ. + * + * Profile: Midea R32 / 171H120F-compatible wired controllers, zone 1 under + * leaving-water control. R290, SWAN-2 and room-control profiles are separate. + * On the documented controller: H2 -> RS485 A+, H1 -> RS485 B-. + * Follow the model manual for the signal reference and cable shield. + * + * Every read is FC03. Shared words 0, 2 and 5 use a fresh read-modify-write. + * No force-heater, disinfection, installer-setting or mains-switching commands. + * enableWrites defaults to false. Check the exact register map, then set + * expectedControllerVersion to the word reported at address 131 before control. + * hasDhwTank must match the installed tank/sensor; false skips its reads/writes. + * allowCooling must match the commissioned system. Select the operating mode + * on the original controller; Auto water-target writes are intentionally blocked. + * Nine components, no groups: Boolean 220-222; Number 223-227; Text 228. + * The base is adjustable. Other components are never deleted. + * @see ./README.md + */ + +// CONFIGURATION +var CONFIG = { + profile: 'midea-r32-171h120f', + serialId: 100, + slaveId: 1, // Example only: use the address configured on the wired controller. + baud: 9600, + format: '8N1', + enableWrites: false, + expectedControllerVersion: null, + hasDhwTank: false, + allowCooling: false, + waterMin: 20, + waterMax: 60, + coolingMin: 5, + coolingMax: 25, + dhwMin: 20, + dhwMax: 60, + writeFunction: 6, // 6: WriteSingleRegister; 16: one-word WriteHoldingRegisters. + vcBase: 220, + pollMs: 10000, + retryMs: 10000, + gapMs: 100, + settleMs: 700 +}; + +// Five controls followed by three temperatures and a status text. +var POINTS = [ + ['Boolean', 'Zone 1 Enable', 0, 0x0002], + ['Boolean', 'DHW Enable', 0, 0x0004], + ['Boolean', 'Silent Mode', 5, 0x0040], + ['Number', 'Zone 1 Water Target', 2, 0x00ff], + ['Number', 'DHW Target', 4, 0xffff], + ['Number', 'Inlet Temperature', 104], + ['Number', 'Outlet Temperature', 105], + ['Number', 'Tank Temperature', 115], + ['Text', 'Status', -1] +]; +var READS = [[0, 6], [100, 2], [104, 2], [115, 1], [124, 1], + [128, 1], [131, 1], [201, 4], [207, 2]]; +var raw = {}; +var actual = []; +var baseline = []; +var observed = []; +var ready = false; +var readIndex = 0; +var controlIndex = 0; +var publishIndex = 0; +var lastFault = ''; +var lastVersion = null; +var lastNotice = ''; +var components = []; + +// HELPERS +function later(fn, ms) { Timer.set(typeof ms === 'number' ? ms : CONFIG.gapMs, false, fn); } +function log(message) { print('[Midea] ' + message); } +function isIntegerInRange(v, min, max) { return typeof v === 'number' && v >= min && v <= max && v === Math.floor(v); } +function isWord(v) { return isIntegerInRange(v, 0, 65535); } +function signed(v) { return v > 32767 ? v - 65536 : v; } +function same(a, b) { return typeof a === typeof b && a === b; } +function rpc(method, params, done) { + Shelly.call(method, params, function(result, code, message) { + later(function() { done(result, code, message); }, 1); + }); +} +function readWords(addr, qty, done) { + rpc('MbRtuClient.ReadHoldingRegisters', { + id: CONFIG.serialId, sid: CONFIG.slaveId, addr: addr, qty: qty + }, function(result, code, message) { + var i; + if (code !== 0 || !result || !result.values || result.values.length !== qty) { + done(null, 'FC03 @' + addr + ': ' + String(message)); return; + } + for (i = 0; i < qty; i++) { + if (!isWord(result.values[i])) { done(null, 'Malformed word @' + (addr + i)); return; } + } + done(result.values, null); + }); +} +function pointId(i) { return CONFIG.vcBase + i; } +function pointKey(i) { return POINTS[i][0].toLowerCase() + ':' + pointId(i); } +function uiValue(i) { + var status = Shelly.getComponentStatus(POINTS[i][0].toLowerCase(), pointId(i)); + return status ? status.value : undefined; +} +function validValue(i, value) { + if (i < 3) return typeof value === 'boolean'; + if (i === 3) return isWord(value) && value >= 5 && value <= 75; + if (i === 4) return isWord(value) && value >= 20 && value <= 75; + if (i < 8) return typeof value === 'number' && value >= -50 && value <= 100; + return typeof value === 'string'; +} +function controlValue(i, word) { + if (i < 3) return (word & POINTS[i][3]) !== 0; + return word & POINTS[i][3]; +} +function writesAllowed() { + return CONFIG.enableWrites && isWord(CONFIG.expectedControllerVersion) && + raw[131] === CONFIG.expectedControllerVersion && (raw[0] & 1) === 0; +} +function liveStatus() { + var operating = 'Unknown(' + raw[101] + ')'; + var value; + if (raw[101] === 0) operating = 'Off'; + if (raw[101] === 2) operating = 'Cool'; + if (raw[101] === 3) operating = 'Heat'; + if (raw[101] === 5) operating = 'DHW'; + value = 'ONLINE | ' + (writesAllowed() ? 'CONTROL' : 'READ ONLY') + + ' | ' + operating + ' | Hz=' + raw[100] + ' | fault=' + raw[124] + ' | controller=' + raw[131]; + if ((raw[128] & 2) !== 0) value += ' | DEFROST'; + if ((raw[0] & 1) !== 0) value += ' | ROOM CONTROL'; + if (CONFIG.expectedControllerVersion !== null && raw[131] !== CONFIG.expectedControllerVersion) value += ' | VERSION MISMATCH'; + if (!CONFIG.hasDhwTank) value += ' | DHW NOT CONFIGURED'; + if (lastNotice !== '') value += ' | ' + lastNotice; + return value.slice(0, 250); +} +function fault(message) { + ready = false; baseline = []; observed = []; actual = []; + if (lastFault !== message) log('PAUSED: ' + message + '. Commands blocked; resynchronizing.'); + lastFault = message; + rpc('Text.Set', { id: pointId(8), value: ('OFFLINE | last readings stale | ' + message).slice(0, 250) }, function() { + later(begin, CONFIG.retryMs); + }); +} +function configurationError() { + if (CONFIG.profile !== 'midea-r32-171h120f') return 'Unsupported profile'; + if (!isWord(CONFIG.serialId) || !isWord(CONFIG.slaveId) || CONFIG.slaveId < 1 || CONFIG.slaveId > 247) return 'Invalid serialId/slaveId'; + if (!isWord(CONFIG.vcBase) || CONFIG.vcBase < 200 || CONFIG.vcBase > 291) return 'vcBase must be 200..291'; + if (CONFIG.expectedControllerVersion !== null && !isWord(CONFIG.expectedControllerVersion)) return 'Invalid expectedControllerVersion'; + if (typeof CONFIG.enableWrites !== 'boolean' || typeof CONFIG.hasDhwTank !== 'boolean' || typeof CONFIG.allowCooling !== 'boolean') return 'Feature flags must be Boolean'; + if (CONFIG.writeFunction !== 6 && CONFIG.writeFunction !== 16) return 'writeFunction must be 6 or 16'; + if (!isIntegerInRange(CONFIG.baud, 1200, 115200)) return 'Invalid baud rate'; + if (CONFIG.format !== '8N1' && CONFIG.format !== '8N2' && CONFIG.format !== '8E1' && CONFIG.format !== '8O1') return 'Invalid serial format'; + if (!isIntegerInRange(CONFIG.pollMs, 1000, 600000) || !isIntegerInRange(CONFIG.retryMs, 1000, 600000) || !isIntegerInRange(CONFIG.gapMs, 20, 5000) || !isIntegerInRange(CONFIG.settleMs, 100, 10000)) return 'Invalid timing values'; + if (!isWord(CONFIG.waterMin) || !isWord(CONFIG.waterMax) || CONFIG.waterMin < 5 || CONFIG.waterMax > 75 || CONFIG.waterMin > CONFIG.waterMax) return 'Invalid heating limits'; + if (!isWord(CONFIG.coolingMin) || !isWord(CONFIG.coolingMax) || CONFIG.coolingMin < 5 || CONFIG.coolingMax > 25 || CONFIG.coolingMin > CONFIG.coolingMax) return 'Invalid cooling limits'; + if (!isWord(CONFIG.dhwMin) || !isWord(CONFIG.dhwMax) || CONFIG.dhwMin < 20 || CONFIG.dhwMax > 75 || CONFIG.dhwMin > CONFIG.dhwMax) return 'Invalid DHW limits'; + return null; +} + +// NON-DESTRUCTIVE VIRTUAL COMPONENT PROVISIONING +function desiredConfig(i) { + var cfg = { name: 'Midea/Clivet ' + POINTS[i][1], persisted: false, + meta: { ui: { view: 'label' } } }; + var interactive = CONFIG.enableWrites; + if (i < 3) { + cfg.default_value = false; + cfg.meta.ui.titles = ['Off', 'On']; + if (interactive && (i !== 1 || CONFIG.hasDhwTank)) cfg.meta.ui.view = 'toggle'; + } else if (i < 8) { + cfg.default_value = i === 3 ? 35 : i === 4 ? 50 : 0; + cfg.min = i === 3 ? 5 : i === 4 ? 20 : -50; + cfg.max = i < 5 ? 75 : 100; + cfg.meta.ui.unit = 'C'; cfg.meta.ui.step = 1; + if (interactive && (i === 3 || (i === 4 && CONFIG.hasDhwTank))) cfg.meta.ui.view = 'field'; + } else { + cfg.default_value = 'STARTING | awaiting physical readings'; cfg.max_len = 255; + } + return cfg; +} +function provisionNext(i) { + var cfg, existing; + if (i === POINTS.length) { later(begin, 1); return; } + cfg = desiredConfig(i); + existing = Shelly.getComponentConfig(POINTS[i][0].toLowerCase(), pointId(i)); + if (existing && existing.name !== cfg.name) { + log('STOPPED: ' + pointKey(i) + ' belongs to another component. Change vcBase.'); return; + } + if (existing) { + rpc(POINTS[i][0] + '.SetConfig', { id: pointId(i), config: cfg }, function(result, code, message) { + if (code !== 0) { log('Component setup failed: ' + pointKey(i) + ': ' + message); return; } + later(function() { provisionNext(i + 1); }); + }); + } else { + rpc('Virtual.Add', { type: POINTS[i][0].toLowerCase(), id: pointId(i), config: cfg }, function(result, code, message) { + if (code !== 0) { log('Component creation failed: ' + pointKey(i) + ': ' + message); return; } + later(function() { provisionNext(i + 1); }); + }); + } +} +function inspectComponents(offset) { + rpc('Shelly.GetComponents', { dynamic_only: true, offset: offset }, function(result, code, message) { + var list, i, total, owned = 0, virtualCount = 0, key, kind, cfg; + if (code !== 0 || !result || !result.components) { log('Cannot inspect components: ' + message); return; } + list = result.components; + for (i = 0; i < list.length; i++) components.push(list[i]); + total = typeof result.total === 'number' ? result.total : components.length; + if (offset + list.length < total && list.length > 0) { + later(function() { inspectComponents(offset + list.length); }); return; + } + for (i = 0; i < components.length; i++) { + key = components[i].key || ''; kind = key.split(':')[0]; + if (kind === 'boolean' || kind === 'number' || kind === 'enum' || kind === 'text' || kind === 'button' || kind === 'group') virtualCount++; + } + for (i = 0; i < POINTS.length; i++) { + cfg = Shelly.getComponentConfig(POINTS[i][0].toLowerCase(), pointId(i)); + if (cfg && cfg.name !== desiredConfig(i).name) { log('STOPPED: ID conflict at ' + pointKey(i)); return; } + if (cfg) owned++; + } + if (virtualCount + POINTS.length - owned > 10) { + log('STOPPED: nine free/reusable Virtual Component slots are required. No components were deleted.'); return; + } + components = []; later(function() { provisionNext(0); }); + }); +} + +// SERIAL CONFIGURATION AND PHYSICAL READS +function begin() { + var i; + for (i = 0; i < POINTS.length; i++) { + if (!Shelly.getComponentConfig(POINTS[i][0].toLowerCase(), pointId(i))) { fault('Missing ' + pointKey(i)); return; } + } + rpc('Serial.GetConfig', { id: CONFIG.serialId }, function(result, code, message) { + if (code !== 0 || !result) { fault('Serial configuration: ' + message); return; } + if (result.mode === 'mb_client' && result.serial && result.serial.baud === CONFIG.baud && result.serial.format === CONFIG.format) { + startRead(); return; + } + ready = false; + rpc('Serial.SetConfig', { id: CONFIG.serialId, config: { + mode: 'mb_client', serial: { baud: CONFIG.baud, format: CONFIG.format } + } }, function(response, error, text) { + if (error !== 0) { fault('Serial setup: ' + text); return; } + if (response && response.restart_required) { fault('Shelly reboot required'); return; } + later(startRead, 500); + }); + }); +} +function startRead() { raw = {}; actual = []; readIndex = 0; later(readNext); } +function readNext() { + var query, i; + if (readIndex === READS.length) { decodeCycle(); return; } + query = READS[readIndex++]; + if (!CONFIG.hasDhwTank && (query[0] === 115 || query[0] === 207)) { later(readNext, 1); return; } + readWords(query[0], query[1], function(words, error) { + if (error !== null) { fault(error); return; } + for (i = 0; i < words.length; i++) raw[query[0] + i] = words[i]; + later(readNext); + }); +} +function decodeCycle() { + var i; + for (i = 0; i < 5; i++) actual[i] = controlValue(i, raw[POINTS[i][2]]); + actual[5] = signed(raw[104]); actual[6] = signed(raw[105]); + if (CONFIG.hasDhwTank) actual[7] = signed(raw[115]); + if (raw[1] !== 1 && raw[1] !== 2 && raw[1] !== 3) { fault('Unexpected configured mode at register 1'); return; } + for (i = 0; i < 8; i++) { + if ((!CONFIG.hasDhwTank && i === 7)) continue; + if (!validValue(i, actual[i])) { fault('Unexpected physical value: ' + POINTS[i][1]); return; } + } + if (lastVersion !== raw[131]) { lastVersion = raw[131]; log('Controller version (register 131) = ' + lastVersion); } + observed = []; + for (i = 0; i < 5; i++) { + observed[i] = uiValue(i); + if (!validValue(i, observed[i])) { fault('Invalid/missing UI control: ' + POINTS[i][1]); return; } + } + lastNotice = ''; controlIndex = 0; publishIndex = 0; + later(ready ? controlNext : publishNext); +} + +// FRESH READ-MODIFY-WRITE WITH PER-COMMAND READBACK +function commandError(i, requested) { + var low, high, factoryLow, factoryHigh, currentMode = raw[1]; + if (!writesAllowed()) return 'WRITES BLOCKED'; + if (raw[124] !== 0) return 'ACTIVE FAULT'; + if ((i === 1 || i === 4) && !CONFIG.hasDhwTank) return 'DHW NOT CONFIGURED'; + if (i === 0 && requested && currentMode !== 3 && !CONFIG.allowCooling) return 'SELECT HEAT BEFORE ENABLING ZONE 1'; + if (i === 3) { + if ((raw[5] & 0x1000) !== 0) return 'WEATHER CURVE ACTIVE'; + if (currentMode === 2) { + if (!CONFIG.allowCooling) return 'COOLING DISABLED'; + low = CONFIG.coolingMin; high = CONFIG.coolingMax; + factoryLow = raw[202] & 0xff; factoryHigh = raw[201] & 0xff; + } else if (currentMode === 3) { + low = CONFIG.waterMin; high = CONFIG.waterMax; + factoryLow = raw[204] & 0xff; factoryHigh = raw[203] & 0xff; + } else return 'SELECT HEAT/COOL FOR WATER TARGET'; + } else if (i === 4) { + low = CONFIG.dhwMin; high = CONFIG.dhwMax; + factoryLow = raw[208]; factoryHigh = raw[207]; + } else return null; + if (!isWord(factoryLow) || !isWord(factoryHigh) || factoryLow < 5 || factoryHigh > 75 || factoryLow > factoryHigh) return 'INVALID CONTROLLER LIMITS'; + low = Math.max(low, factoryLow); high = Math.min(high, factoryHigh); + if (requested < low || requested > high) return 'TARGET OUTSIDE ' + low + '..' + high + ' C'; + return null; +} +function mergeWord(i, original, requested) { + var mask = POINTS[i][3]; + var encoded = i < 3 ? (requested ? mask : 0) : requested; + return ((original & (0xffff ^ mask)) | (encoded & mask)) & 0xffff; +} +function applyReadback(addr, word) { + var i; + raw[addr] = word; + for (i = 0; i < 5; i++) if (POINTS[i][2] === addr) actual[i] = controlValue(i, word); +} +function refreshCommandGuards(i, done) { + var queries = [[0, 6], [124, 1]]; + var index = 0; + if (i === 3) queries.push([201, 4]); + if (i === 4) queries.push([207, 2]); + function nextGuard() { + var query, j; + if (index === queries.length) { done(null); return; } + query = queries[index++]; + readWords(query[0], query[1], function(words, error) { + if (error !== null) { done(error); return; } + for (j = 0; j < words.length; j++) { + raw[query[0] + j] = words[j]; + if (query[0] === 0) applyReadback(j, words[j]); + } + later(nextGuard); + }); + } + later(nextGuard); +} +function controlNext() { + var i, requested, error, addr; + if (controlIndex === 5) { later(publishNext); return; } + i = controlIndex++; requested = uiValue(i); observed[i] = requested; + if (!validValue(i, requested)) { fault('Invalid command: ' + POINTS[i][1]); return; } + if (same(requested, baseline[i]) || same(requested, actual[i])) { later(controlNext); return; } + error = commandError(i, requested); + if (error !== null) { lastNotice = error; log('REJECTED ' + POINTS[i][1] + ': ' + error); later(controlNext); return; } + addr = POINTS[i][2]; + refreshCommandGuards(i, function(guardError) { + if (guardError !== null) { fault(guardError); return; } + readWords(addr, 1, function(words, readError) { + var original, value, params, method; + if (readError !== null) { fault(readError); return; } + original = words[0]; applyReadback(addr, original); + // Do not act on a command replaced by the user during the preparatory read. + if (!same(uiValue(i), requested)) { later(controlNext); return; } + error = commandError(i, requested); + if (error !== null) { lastNotice = error; later(controlNext); return; } + value = mergeWord(i, original, requested); + if (value === original) { later(controlNext); return; } + params = { id: CONFIG.serialId, sid: CONFIG.slaveId, addr: addr }; + method = CONFIG.writeFunction === 6 ? 'WriteSingleRegister' : 'WriteHoldingRegisters'; + if (CONFIG.writeFunction === 6) params.value = value; else params.values = [value]; + rpc('MbRtuClient.' + method, params, function(result, code, message) { + if (code !== 0) { fault('Unconfirmed write @' + addr + ': ' + message); return; } + later(function() { + readWords(addr, 1, function(back, backError) { + var accepted; + if (backError !== null) { fault('Readback: ' + backError); return; } + applyReadback(addr, back[0]); accepted = controlValue(i, back[0]); + if (!validValue(i, accepted)) { fault('Invalid readback: ' + POINTS[i][1]); return; } + if (same(accepted, requested)) log('CONFIRMED ' + POINTS[i][1] + ' = ' + accepted); + else { lastNotice = 'COMMAND NOT CONFIRMED'; log('NOT CONFIRMED ' + POINTS[i][1] + ': requested ' + requested + ', reported ' + accepted); } + later(controlNext); + }); + }, CONFIG.settleMs); + }); + }); + }); +} + +// PHYSICAL STATE -> UI; STARTUP/OFFLINE VALUES NEVER BECOME COMMANDS +function publishNext() { + var i, current, j; + if (publishIndex === POINTS.length) { + if (!ready) { + for (j = 0; j < 5; j++) { + if (!same(uiValue(j), actual[j])) { fault('UI changed during initial synchronization'); return; } + } + ready = true; log('READY: 9 components synchronized; ' + (writesAllowed() ? 'control enabled' : 'read-only mode')); + } + lastFault = ''; later(begin, CONFIG.pollMs); return; + } + i = publishIndex++; + if (!CONFIG.hasDhwTank && i === 7) { later(publishNext, 1); return; } + if (i === 8) actual[i] = liveStatus(); + current = uiValue(i); + if (ready && i < 5 && !same(current, observed[i])) { later(publishNext, 1); return; } + if (same(current, actual[i])) { + if (i < 5) baseline[i] = actual[i]; later(publishNext, 1); return; + } + rpc(POINTS[i][0] + '.Set', { id: pointId(i), value: actual[i] }, function(result, code, message) { + if (code !== 0) { fault('UI update ' + POINTS[i][1] + ': ' + message); return; } + if (i < 5) baseline[i] = actual[i]; later(publishNext, 1); + }); +} + +// INITIALIZATION +var configError = configurationError(); +if (configError !== null) log('STOPPED: ' + configError); +else { + log('R32 profile; RTU ' + CONFIG.baud + ' ' + CONFIG.format + '; server ' + CONFIG.slaveId); + if (CONFIG.enableWrites && CONFIG.expectedControllerVersion === null) log('Set expectedControllerVersion after reading register 131; writes remain blocked.'); + later(function() { inspectComponents(0); }, 1); +} diff --git a/modbus/Midea/tests/bridge.test.cjs b/modbus/Midea/tests/bridge.test.cjs new file mode 100644 index 0000000..2faf588 --- /dev/null +++ b/modbus/Midea/tests/bridge.test.cjs @@ -0,0 +1,295 @@ +// Deterministic RPC/Modbus integration tests. Run: node --test bridge.test.cjs +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); +const source = fs.readFileSync(path.join(__dirname, '..', 'midea-clivet-r32-pro-modbus-vc.shelly.js'), 'utf8'); + +function emulator(config = {}, initial = {}) { + const registers = { 0: 0x8, 1: 3, 2: (42 << 8) | 35, 3: 42, 4: 50, 5: 0x180, + 100: 45, 101: 3, 104: 30, 105: 35, 115: 48, 124: 0, 128: 0, 131: 47, + 201: (25 << 8) | 25, 202: (5 << 8) | 5, 203: (65 << 8) | 65, + 204: (22 << 8) | 22, 207: 60, 208: 20, ...initial }; + const components = new Map(); + const timers = []; + const logs = []; + const trace = []; + let now = 0; + let timerId = 0; + let offline = false; + let beforeRpc = null; + let rejectWrites = false; + let failWrite = false; + let failReadback = false; + let pendingReadback = false; + let serial = { mode: 'mb_client', serial: { baud: 9600, format: '8N1' } }; + const clone = x => JSON.parse(JSON.stringify(x)); + function call(method, params, callback) { + trace.push({ time: now, method, params: clone(params) }); + if (beforeRpc) beforeRpc(method, params, api); + if (method === 'Shelly.GetComponents') { + const items = [...components.entries()].map(([key, x]) => ({ key, config: x.config, status: { value: x.value } })); + callback({ total: items.length, components: items.slice(params.offset, params.offset + 4) }, 0, ''); return; + } + if (method === 'Virtual.Add') { + const key = params.type + ':' + params.id; + if (components.size >= 10 || components.has(key)) { callback(null, -1, 'Capacity/conflict'); return; } + components.set(key, { config: clone(params.config), value: params.config.default_value }); + callback({ id: params.id }, 0, ''); return; + } + if (method.endsWith('.SetConfig')) { + if (method === 'Serial.SetConfig') { serial = clone(params.config); callback({ restart_required: false }, 0, ''); return; } + const key = method.split('.')[0].toLowerCase() + ':' + params.id; + if (!components.has(key)) { callback(null, -1, 'Missing component'); return; } + Object.assign(components.get(key).config, clone(params.config)); + callback({ restart_required: false }, 0, ''); return; + } + if (method === 'Serial.GetConfig') { callback(clone(serial), 0, ''); return; } + if (method === 'MbRtuClient.ReadHoldingRegisters') { + if (offline || (pendingReadback && failReadback)) { + pendingReadback = false; callback(null, -1, 'Timeout'); return; + } + pendingReadback = false; + const values = []; + for (let i = 0; i < params.qty; i++) { + const value = registers[params.addr + i]; + if (value === undefined) { callback(null, 2, 'Illegal address'); return; } + values.push(value); + } + callback({ values }, 0, ''); return; + } + if (method === 'MbRtuClient.WriteSingleRegister' || method === 'MbRtuClient.WriteHoldingRegisters') { + if (offline || failWrite) { callback(null, -1, 'Write timeout'); return; } + const value = params.value === undefined ? params.values[0] : params.value; + assert.ok(Number.isInteger(value) && value >= 0 && value <= 65535); + if (!rejectWrites) registers[params.addr] = value; + pendingReadback = true; callback({}, 0, ''); return; + } + if (method.endsWith('.Set')) { + const key = method.split('.')[0].toLowerCase() + ':' + params.id; + const item = components.get(key); + if (!item) { callback(null, -1, 'Missing component'); return; } + const kind = key.split(':')[0]; + if (kind === 'number') assert.ok(typeof params.value === 'number' && params.value >= item.config.min && params.value <= item.config.max); + if (kind === 'boolean') assert.equal(typeof params.value, 'boolean'); + if (kind === 'text') assert.ok(params.value.length <= item.config.max_len); + item.value = params.value; callback(null, 0, ''); return; + } + throw new Error('Unexpected RPC: ' + method); + } + const context = vm.createContext({ + print: x => logs.push(x), + Shelly: { call, + getComponentConfig: (type, id) => components.get(type + ':' + id)?.config, + getComponentStatus: (type, id) => { + const c = components.get(type + ':' + id); return c ? { value: c.value } : undefined; + } }, + Timer: { set: (delay, repeat, callback) => { + assert.equal(repeat, false); + timers.push({ id: ++timerId, at: now + delay, callback }); + assert.ok(timers.length <= 3, 'Unbounded timer accumulation'); return timerId; + } } + }); + vm.runInContext(source, context); + Object.assign(context.CONFIG, config); + const api = { + context, registers, components, logs, trace, + writes: () => trace.filter(x => /MbRtuClient.Write/.test(x.method)), + value: (type, id) => components.get(type + ':' + id)?.value, + change: (type, id, value) => { components.get(type + ':' + id).value = value; }, + offline: value => { offline = value; }, + rejectWrites: value => { rejectWrites = value; }, + failWrite: value => { failWrite = value; }, + failReadback: value => { failReadback = value; }, + hook: fn => { beforeRpc = fn; }, + until(predicate, max = 5000) { + for (let n = 0; !predicate(); n++) { + assert.ok(n < max && timers.length, 'Predicate not reached: ' + logs.slice(-3)); + timers.sort((a, b) => a.at - b.at || a.id - b.id); + const t = timers.shift(); now = t.at; t.callback(); + } + }, + advance(ms) { + const end = now + ms; + for (let n = 0; timers.length; n++) { + assert.ok(n < 10000); + timers.sort((a, b) => a.at - b.at || a.id - b.id); + if (timers[0].at > end) break; + const t = timers.shift(); now = t.at; t.callback(); + } + now = end; + }, + sync() { this.until(() => context.ready); } + }; + return api; +} +const control = { enableWrites: true, expectedControllerVersion: 47, hasDhwTank: true }; + +test('exactly nine components; initial synchronization sends no Modbus writes', () => { + const e = emulator(control); e.sync(); + assert.equal(e.components.size, 9); + assert.equal(e.writes().length, 0); + assert.equal(e.value('number', 223), 35); + assert.equal(e.value('number', 227), 48); + assert.match(e.value('text', 228), /ONLINE.*CONTROL/); +}); +test('read-only default restores a changed UI value without writing', () => { + const e = emulator(); e.sync(); e.change('number', 223, 40); e.advance(15000); + assert.equal(e.writes().length, 0); assert.equal(e.value('number', 223), 35); + assert.match(e.value('text', 228), /READ ONLY/); +}); +test('startup never replays persisted control values', () => { + const e = emulator(control); + e.hook((m, p) => { if (m === 'Serial.GetConfig' && !e.context.ready) e.change('number', 223, 55); }); + e.sync(); assert.equal(e.writes().length, 0); assert.equal(e.value('number', 223), 35); +}); +test('zone-1 write preserves zone-2 and other flags in register 0', () => { + const e = emulator(control); e.sync(); e.change('boolean', 220, true); e.advance(15000); + assert.equal(e.registers[0], 0xa); assert.equal(e.writes().length, 1); +}); +test('two controls sharing register 0 preserve each other', () => { + const e = emulator(control); e.sync(); + e.change('boolean', 220, true); e.change('boolean', 221, true); e.advance(20000); + assert.equal(e.registers[0], 0xe); assert.equal(e.writes().length, 2); +}); +test('water target preserves the zone-2 high byte', () => { + const e = emulator(control); e.sync(); e.change('number', 223, 40); e.advance(15000); + assert.equal(e.registers[2], (42 << 8) | 40); + assert.ok(e.logs.some(x => /CONFIRMED.*Water Target/.test(x))); +}); +test('RMW uses a fresh high byte, not the poll snapshot', () => { + const e = emulator(control); e.sync(); e.change('number', 223, 40); + let changed = false; + e.hook((m, p) => { if (!changed && m === 'MbRtuClient.ReadHoldingRegisters' && p.addr === 2 && p.qty === 1) { + changed = true; e.registers[2] = (47 << 8) | 35; + } }); + e.advance(15000); assert.equal(e.registers[2], (47 << 8) | 40); +}); +test('Silent Mode preserves all other function bits and the silent level', () => { + const e = emulator(control); e.sync(); e.change('boolean', 222, true); e.advance(15000); + assert.equal(e.registers[5], 0x1c0); +}); +test('DHW target uses address 4 and integer degrees', () => { + const e = emulator(control); e.sync(); e.change('number', 224, 55); e.advance(15000); + assert.equal(e.registers[4], 55); assert.equal(e.writes()[0].params.addr, 4); +}); +test('negative temperatures decode as signed 16-bit whole degrees', () => { + const e = emulator(control, { 104: 65531 }); e.sync(); assert.equal(e.value('number', 225), -5); +}); +test('physical controller changes update UI without being written back', () => { + const e = emulator(control); e.sync(); e.registers[2] = (42 << 8) | 39; e.advance(15000); + assert.equal(e.value('number', 223), 39); assert.equal(e.writes().length, 0); +}); +test('controller version mismatch blocks writes', () => { + const e = emulator(control, { 131: 48 }); e.sync(); e.change('number', 223, 40); e.advance(15000); + assert.equal(e.writes().length, 0); assert.match(e.value('text', 228), /VERSION MISMATCH/); +}); +test('missing expected version keeps control disabled', () => { + const e = emulator({ enableWrites: true }); e.sync(); e.change('number', 223, 40); e.advance(15000); + assert.equal(e.writes().length, 0); assert.match(e.value('text', 228), /READ ONLY/); +}); +test('room-control profile does not receive writes', () => { + const e = emulator(control, { 0: 9 }); e.sync(); e.change('boolean', 222, true); e.advance(15000); + assert.equal(e.writes().length, 0); assert.match(e.value('text', 228), /ROOM CONTROL/); +}); +test('factory and configured target limits both apply', () => { + const e = emulator(control, { 203: 45 }); e.sync(); e.change('number', 223, 46); e.advance(15000); + assert.equal(e.writes().length, 0); assert.equal(e.value('number', 223), 35); +}); +test('fractional setpoints cause no device write', () => { + const e = emulator(control); e.sync(); e.change('number', 223, 35.5); + e.until(() => !e.context.ready); assert.equal(e.writes().length, 0); +}); +test('weather curve controls its own water target', () => { + const e = emulator(control, { 5: 0x1180 }); e.sync(); e.change('number', 223, 40); e.advance(15000); + assert.equal(e.writes().length, 0); assert.match(e.value('text', 228), /WEATHER CURVE/); +}); +test('fresh pre-write guards catch a newly activated weather curve', () => { + const e = emulator(control); e.sync(); e.change('number', 223, 40); + let reads = 0; + e.hook((m, p) => { if (m === 'MbRtuClient.ReadHoldingRegisters' && p.addr === 0 && ++reads === 2) e.registers[5] |= 0x1000; }); + e.advance(15000); assert.equal(e.writes().length, 0); +}); +test('active faults block writes', () => { + const e = emulator(control, { 124: 9 }); e.sync(); e.change('boolean', 220, true); e.advance(15000); + assert.equal(e.writes().length, 0); assert.match(e.value('text', 228), /fault=9/); +}); +test('offline changes are discarded during reconnect synchronization', () => { + const e = emulator(control); e.sync(); e.offline(true); + e.until(() => !e.context.ready); e.change('boolean', 220, true); e.change('number', 223, 45); + e.offline(false); e.until(() => e.context.ready); e.advance(15000); + assert.equal(e.writes().length, 0); assert.equal(e.value('number', 223), 35); +}); +test('a write timeout is not automatically replayed', () => { + const e = emulator(control); e.sync(); e.failWrite(true); e.change('number', 223, 40); + e.until(() => !e.context.ready); e.failWrite(false); e.until(() => e.context.ready); e.advance(15000); + assert.equal(e.writes().length, 1); assert.equal(e.registers[2] & 255, 35); +}); +test('a lost write response/readback resynchronizes with physical state', () => { + const e = emulator(control); e.sync(); e.failReadback(true); e.change('number', 223, 40); + e.until(() => !e.context.ready); e.failReadback(false); e.until(() => e.context.ready); e.advance(15000); + assert.equal(e.writes().length, 1); assert.equal(e.value('number', 223), 40); +}); +test('a controller-rejected command publishes the actual readback', () => { + const e = emulator(control); e.sync(); e.rejectWrites(true); e.change('number', 223, 40); e.advance(15000); + assert.equal(e.value('number', 223), 35); assert.equal(e.writes().length, 1); + assert.ok(e.logs.some(x => /NOT CONFIRMED/.test(x))); +}); +test('one-word FC16 works only when explicitly configured', () => { + const e = emulator({ ...control, writeFunction: 16 }); e.sync(); e.change('number', 223, 40); e.advance(15000); + assert.equal(e.writes()[0].method, 'MbRtuClient.WriteHoldingRegisters'); + assert.deepEqual(e.writes()[0].params.values, [(42 << 8) | 40]); +}); +test('DHW reads and writes are omitted when the tank is not configured', () => { + const e = emulator({ ...control, hasDhwTank: false }); e.sync(); e.change('boolean', 221, true); e.advance(15000); + assert.equal(e.writes().length, 0); + assert.ok(!e.trace.some(x => x.method === 'MbRtuClient.ReadHoldingRegisters' && (x.params.addr === 115 || x.params.addr === 207))); +}); +test('a foreign component ID is left untouched', () => { + const e = emulator(); + e.components.set('boolean:220', { config: { name: 'Other application' }, value: true }); + e.advance(5000); + assert.equal(e.components.size, 1); assert.equal(e.value('boolean', 220), true); + assert.ok(!e.trace.some(x => /Virtual.Add|SetConfig/.test(x.method))); +}); +test('capacity is checked before any components are created', () => { + const e = emulator(); + for (let id = 200; id < 202; id++) e.components.set('boolean:' + id, { config: { name: 'Other' }, value: false }); + e.advance(5000); assert.equal(e.components.size, 2); + assert.ok(!e.trace.some(x => x.method === 'Virtual.Add')); +}); +test('one unrelated virtual component is compatible with nine bridge components', () => { + const e = emulator(); e.components.set('boolean:200', { config: { name: 'Other' }, value: true }); + e.sync(); assert.equal(e.components.size, 10); assert.equal(e.value('boolean', 200), true); +}); +test('all reads use Holding Registers; no installer or mode register is written', () => { + const e = emulator(control); e.sync(); e.change('boolean', 220, true); e.change('number', 223, 40); e.advance(20000); + assert.ok(!e.trace.some(x => /ReadInput|ReadCoils|WriteCoils/.test(x.method))); + assert.ok(e.writes().every(x => [0, 2, 4, 5].includes(x.params.addr))); +}); +test('cooling targets require allowCooling and controller cooling limits', () => { + const e = emulator({ ...control, allowCooling: true }, { 1: 2, 2: (42 << 8) | 20 }); + e.sync(); e.change('number', 223, 15); e.advance(15000); + assert.equal(e.registers[2], (42 << 8) | 15); +}); +test('Auto does not accept a manual water target', () => { + const e = emulator(control, { 1: 1 }); e.sync(); e.change('number', 223, 40); e.advance(15000); + assert.equal(e.writes().length, 0); +}); +test('fresh guards catch a mode changed on the original controller', () => { + const e = emulator(control); e.sync(); e.change('number', 223, 40); + let reads = 0; + e.hook((m, p) => { if (m === 'MbRtuClient.ReadHoldingRegisters' && p.addr === 0 && ++reads === 2) e.registers[1] = 2; }); + e.advance(15000); assert.equal(e.writes().length, 0); +}); +test('a replaced command is deferred until its latest UI value can be handled', () => { + const e = emulator(control); e.sync(); e.change('number', 223, 40); + let changed = false; + e.hook((m, p) => { if (!changed && m === 'MbRtuClient.ReadHoldingRegisters' && p.addr === 2 && p.qty === 1) { + changed = true; e.change('number', 223, 41); + } }); + e.advance(30000); + assert.equal(e.registers[2] & 255, 41); + assert.ok(e.writes().every(x => (x.params.value & 255) !== 40)); +}); diff --git a/modbus/README.md b/modbus/README.md index 88dd01a..2e89eac 100644 --- a/modbus/README.md +++ b/modbus/README.md @@ -43,6 +43,7 @@ simpler starting points. - [`IGEN/DTSD422/`](IGEN/DTSD422/): six-circuit energy meter (CT1-CT4 documented) - `dtsd422_vc.shelly.js` - [`JKESS/JK200-MBS/`](JKESS/JK200-MBS/): Jikong JK-PB series BMS (cell voltages + pack telemetry) - `jk200_vc.shelly.js` - [`LG/`](LG/): LG THERMA V heat pump via Shelly Pro EM-50 - `lg-therma-v-pro-em50_vc.shelly.js` +- [`Midea/`](Midea/): Midea/Clivet R32 heat pump bridge with nine self-provisioned controls/measurements, controller guards and command readback - `midea-clivet-r32-pro-modbus-vc.shelly.js` - [`LinkedGo/R290/`](LinkedGo/R290/): R290 air-to-water thermal pump - `r290_aw_thermal_pump_vc.shelly.js` - [`LinkedGo/ST802/`](LinkedGo/ST802/): Youth Smart Thermostat + BMS command simulation - `st802_bms_vc.shelly.js` - [`MarsRock/G2_SUN_Series_Grid_Tie_Inverter/`](MarsRock/G2_SUN_Series_Grid_Tie_Inverter/): micro-inverter, only 5 registers total (all promoted to VC) - `g2_sun_series_vc.shelly.js`