From 45f8a2698ee0c1862fd0fd8043fba6a27ce9adb5 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 07:04:01 +0300 Subject: [PATCH 01/16] refactor(Midea): switch to firmware-managed VCs with dynamic role IDs --- .../midea-clivet-r32-pro-modbus-vc.shelly.js | 99 ++++++------------- 1 file changed, 32 insertions(+), 67 deletions(-) diff --git a/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js b/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js index d0f061d..745b904 100644 --- a/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js +++ b/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js @@ -1,3 +1,4 @@ +/* @meta {"vc":{"zone":{"type":"boolean","config":{"name":"Midea/Clivet Zone 1 Enable","meta":{"ui":{"view":"toggle"}}}},"dhw":{"type":"boolean","config":{"name":"Midea/Clivet DHW Enable","meta":{"ui":{"view":"toggle"}}}},"silent":{"type":"boolean","config":{"name":"Midea/Clivet Silent Mode","meta":{"ui":{"view":"toggle"}}}},"water":{"type":"number","config":{"name":"Midea/Clivet Zone 1 Water Target","min":5,"max":75,"default_value":35,"meta":{"ui":{"view":"field"}}}},"dhwTarget":{"type":"number","config":{"name":"Midea/Clivet DHW Target","min":20,"max":75,"default_value":50,"meta":{"ui":{"view":"field"}}}},"inlet":{"type":"number","config":{"name":"Midea/Clivet Inlet Temperature","min":-50,"max":100}},"outlet":{"type":"number","config":{"name":"Midea/Clivet Outlet Temperature","min":-50,"max":100}},"tank":{"type":"number","config":{"name":"Midea/Clivet Tank Temperature","min":-50,"max":100}},"status":{"type":"text","config":{"name":"Midea/Clivet Status","max_len":255}}}} */ /** * @title Midea/Clivet R32 Modbus RTU bridge * @description Local zone-1, DHW and Silent Mode control with nine Virtual @@ -30,8 +31,9 @@ * 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. + * Nine firmware-managed Virtual Components (three Boolean, five Number, + * one Text). Firmware assigns IDs; Script.getVcHandle(role) resolves them. + * Managed VC lifecycle is owned by firmware, with no runtime Virtual.Add. * @see ./README.md */ @@ -53,7 +55,6 @@ var CONFIG = { dhwMin: 20, dhwMax: 60, writeFunction: 6, // 6: WriteSingleRegister; 16: one-word WriteHoldingRegisters. - vcBase: 220, pollMs: 10000, retryMs: 10000, gapMs: 100, @@ -85,7 +86,9 @@ var publishIndex = 0; var lastFault = ''; var lastVersion = null; var lastNotice = ''; -var components = []; +var vcIds = []; +var managedVc = []; +var VC_ROLES = ["zone","dhw","silent","water","dhwTarget","inlet","outlet","tank","status"]; // HELPERS function later(fn, ms) { Timer.set(typeof ms === 'number' ? ms : CONFIG.gapMs, false, fn); } @@ -113,7 +116,7 @@ function readWords(addr, qty, done) { done(result.values, null); }); } -function pointId(i) { return CONFIG.vcBase + i; } +function pointId(i) { return vcIds[i]; } function pointKey(i) { return POINTS[i][0].toLowerCase() + ':' + pointId(i); } function uiValue(i) { var status = Shelly.getComponentStatus(POINTS[i][0].toLowerCase(), pointId(i)); @@ -161,7 +164,6 @@ function fault(message) { 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'; @@ -174,70 +176,33 @@ function configurationError() { 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; +// FIRMWARE-MANAGED VIRTUAL COMPONENT BINDING +// The firmware reconciles @meta VCs before the script begins. Numeric IDs are +// not stable across recreation and must be resolved from the role handles. +function bindManagedComponents() { + var i, handle, cfg, type, actualConfig; + if (typeof Script === 'undefined' || !Script.getVcHandle) { + log('STOPPED: firmware-managed VC API is unavailable'); return; } - 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 < VC_ROLES.length; i++) { + handle = Script.getVcHandle(VC_ROLES[i]); + if (!handle) { log('STOPPED: missing managed VC role ' + VC_ROLES[i]); return; } + cfg = handle.getConfig(); + type = POINTS[i][0].toLowerCase(); + if (!cfg || !isIntegerInRange(cfg.id, 200, 299)) { + log('STOPPED: invalid managed VC ID for ' + VC_ROLES[i]); return; } - 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 (cfg.name !== 'Midea/Clivet ' + POINTS[i][1]) { + log('STOPPED: unexpected managed VC name for ' + VC_ROLES[i]); return; } - if (virtualCount + POINTS.length - owned > 10) { - log('STOPPED: nine free/reusable Virtual Component slots are required. No components were deleted.'); return; + actualConfig = Shelly.getComponentConfig(type, cfg.id); + if (!actualConfig) { + log('STOPPED: managed VC is unavailable: ' + VC_ROLES[i]); return; } - components = []; later(function() { provisionNext(0); }); - }); + managedVc[i] = handle; + vcIds[i] = cfg.id; + } + later(begin, 1); } // SERIAL CONFIGURATION AND PHYSICAL READS @@ -423,5 +388,5 @@ 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); + later(bindManagedComponents, 1); } From 8d7673704e24d7f8d73efd129122ab40d49d5677 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 07:04:34 +0300 Subject: [PATCH 02/16] test(Midea): exercise firmware-managed roles and dynamic IDs --- modbus/Midea/tests/bridge.test.cjs | 70 ++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/modbus/Midea/tests/bridge.test.cjs b/modbus/Midea/tests/bridge.test.cjs index 2faf588..9e70b28 100644 --- a/modbus/Midea/tests/bridge.test.cjs +++ b/modbus/Midea/tests/bridge.test.cjs @@ -25,6 +25,23 @@ function emulator(config = {}, initial = {}) { let pendingReadback = false; let serial = { mode: 'mb_client', serial: { baud: 9600, format: '8N1' } }; const clone = x => JSON.parse(JSON.stringify(x)); + // Firmware creates the declared VCs before it evaluates the script. + // Tests assign deterministic IDs for convenience; production uses role handles. + const manifest = JSON.parse(source.split('\\n')[0].slice('/* @meta '.length, -' */'.length)).vc; + const entries = Object.entries(manifest); + const baseId = config.mockManagedIdStart || 220; + const roleIds = {}; + for (let i = 0; i < entries.length; i++) { + const [role, definition] = entries[i]; + if (role === config.mockMissingManagedRole) continue; + const id = baseId + i; + const type = definition.type; + const cfg = { ...clone(definition.config), id }; + const value = cfg.default_value === undefined ? + (type === 'boolean' ? false : type === 'number' ? 0 : '') : cfg.default_value; + components.set(type + ':' + id, { config: cfg, value }); + roleIds[role] = { type, id }; + } function call(method, params, callback) { trace.push({ time: now, method, params: clone(params) }); if (beforeRpc) beforeRpc(method, params, api); @@ -32,12 +49,7 @@ function emulator(config = {}, initial = {}) { 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 === 'Virtual.Add') throw new Error('Managed VC scripts must not call Virtual.Add'); 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; @@ -85,6 +97,17 @@ function emulator(config = {}, initial = {}) { getComponentStatus: (type, id) => { const c = components.get(type + ':' + id); return c ? { value: c.value } : undefined; } }, + Script: { getVcHandle: role => { + const point = roleIds[role]; + if (!point) return null; + const item = () => components.get(point.type + ':' + point.id); + return { + getConfig: () => item()?.config, + getValue: () => item()?.value, + setValue: value => { item().value = value; }, + on: () => 1 + }; + } }, Timer: { set: (delay, repeat, callback) => { assert.equal(repeat, false); timers.push({ id: ++timerId, at: now + delay, callback }); @@ -246,22 +269,25 @@ test('DHW reads and writes are omitted when the tank is not configured', () => { 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('firmware-managed handles have no runtime VC creation or deletion', () => { + const e = emulator(); e.sync(); + assert.equal(e.components.size, 9); + assert.ok(!e.trace.some(x => x.method === 'Virtual.Add' || x.method === 'Virtual.Delete')); + assert.ok(e.context.vcIds.length === 9); +}); +test('managed VCs resolve arbitrary firmware-assigned IDs', () => { + const e = emulator({ mockManagedIdStart: 260, ...control }); + e.sync(); e.change('number', 263, 40); e.advance(15000); + assert.equal(e.context.vcIds[3], 263); + assert.equal(e.registers[2], (42 << 8) | 40); + assert.match(e.value('text', 268), /ONLINE.*CONTROL/); +}); +test('missing managed role aborts safely before Modbus access', () => { + const e = emulator({ mockMissingManagedRole: 'status' }); + e.advance(1000); + assert.equal(e.components.size, 8); + assert.ok(e.logs.some(x => /missing managed VC role status/.test(x))); + assert.ok(!e.trace.some(x => /Serial\.|MbRtuClient\./.test(x.method))); }); 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); From ed5e387d42fe83b1a49f276036abf32bfd4b5a75 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 07:04:48 +0300 Subject: [PATCH 03/16] test(Midea): parse first-line managed metadata correctly --- modbus/Midea/tests/bridge.test.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modbus/Midea/tests/bridge.test.cjs b/modbus/Midea/tests/bridge.test.cjs index 9e70b28..6b1df1e 100644 --- a/modbus/Midea/tests/bridge.test.cjs +++ b/modbus/Midea/tests/bridge.test.cjs @@ -27,7 +27,7 @@ function emulator(config = {}, initial = {}) { const clone = x => JSON.parse(JSON.stringify(x)); // Firmware creates the declared VCs before it evaluates the script. // Tests assign deterministic IDs for convenience; production uses role handles. - const manifest = JSON.parse(source.split('\\n')[0].slice('/* @meta '.length, -' */'.length)).vc; + const manifest = JSON.parse(source.split(/\r?\n/)[0].slice('/* @meta '.length, -' */'.length)).vc; const entries = Object.entries(manifest); const baseId = config.mockManagedIdStart || 220; const roleIds = {}; From ec7ddaaab9ce80ed094dc0e11ff369c65cb74ea2 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 07:05:44 +0300 Subject: [PATCH 04/16] docs(Midea): managed VC lifecycle, roles and migration guidance --- modbus/Midea/README.md | 59 +++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/modbus/Midea/README.md b/modbus/Midea/README.md index a993763..14a1eec 100644 --- a/modbus/Midea/README.md +++ b/modbus/Midea/README.md @@ -1,8 +1,8 @@ # 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. +pumps and compatible Clivet controllers. The standalone script declares +**exactly nine firmware-managed 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. @@ -38,28 +38,38 @@ the RS485 add-on in the device configuration and reboot if requested. ## Nine components -Default `vcBase: 220` produces the following components: +The nine firmware-managed Virtual Components are declared through first-line +`@meta` metadata. Firmware assigns their IDs; `Script.getVcHandle(role)` +retrieves them regardless of their numeric IDs. Do **not** hard-code old IDs. -| Key | Name | Purpose | +| Role | Type | Description | | --- | --- | --- | -| `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. +| `zone` | Boolean | Zone 1 Enable | +| `dhw` | Boolean | DHW Enable | +| `silent` | Boolean | Silent Mode | +| `water` | Number | Zone 1 Water Target | +| `dhwTarget` | Number | DHW Target | +| `inlet` | Number | Inlet Temperature | +| `outlet` | Number | Outlet Temperature | +| `tank` | Number | Tank Temperature, if equipped | +| `status` | Text | Operating mode and diagnostics | + +The operating mode is set on the original controller. Zone 1 Enable is +not a mains switch or zone-2 override. There are no groups; the nine managed +VCs occupy nine out of ten device-wide slots. + +### Migration from the original self-provisioned bridge + +Back up the installed script, scenes, VCs, and external integrations first. +Stop the old Midea/Clivet script; its manually created VCs (previously at +220–228) can persist after it stops. Inspect the device and **manually remove +only confirmed obsolete Midea/Clivet VCs** before enabling this managed +version. The managed firmware lifecycle does not adopt old manual VCs. +Do not delete unrelated components. Discover the newly allocated IDs using +`Shelly.GetComponents` and update any references to old numeric IDs. + +The historical real-device test predates this managed-VC change. Run a new +physical startup and UI/Modbus smoke test before production deployment. ## Configuration and first synchronization @@ -86,7 +96,8 @@ 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 +Start in read-only mode. Firmware creates/reconciles the nine managed VCs; +the script binds them by role, configures `mode: 'mb_client'`, reads the physical values, and publishes them. It logs: ```text @@ -178,6 +189,6 @@ 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, +The harness checks managed role binding and dynamic component IDs, 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). From c8c5d08d4ad8bd97b6199a46869d2a08fd3999a7 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 07:05:46 +0300 Subject: [PATCH 05/16] docs(Midea): managed VC lifecycle, roles and migration guidance --- modbus/Midea/TECHNICAL_NOTES.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/modbus/Midea/TECHNICAL_NOTES.md b/modbus/Midea/TECHNICAL_NOTES.md index a4ba66d..0f99755 100644 --- a/modbus/Midea/TECHNICAL_NOTES.md +++ b/modbus/Midea/TECHNICAL_NOTES.md @@ -21,12 +21,14 @@ 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 +The previously released implementation passed 33 Node.js RPC/Modbus +integration tests on 2026-10-07. This managed-VC revision retains 33 updated +tests; its CI must be checked independently. 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, +The checks cover dynamic managed VC binding, 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, @@ -39,10 +41,14 @@ 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. +The merged October 11 release used nine fixed-ID, self-provisioned VCs. +This follow-up changes the provisioning model to nine firmware-managed roles +in first-line `@meta`. The script resolves component IDs dynamically using +`Script.getVcHandle(role).getConfig().id`, and verifies each handle before +starting Modbus. Firmware manages ownership and cleanup on script deletion. +Existing manually created components are not adopted automatically; migrating +requires backing up and removing only positively identified obsolete VCs. +Hardware validation of this new provisioning behavior is still pending. ## Integration boundaries From 2fcefd4d695874985e4a224b543159ffb7705db6 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 07:06:14 +0300 Subject: [PATCH 06/16] fix(Midea): conform managed-VC init lifecycle to CI policy --- .../Midea/midea-clivet-r32-pro-modbus-vc.shelly.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js b/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js index 745b904..63de2b0 100644 --- a/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js +++ b/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js @@ -383,10 +383,16 @@ function publishNext() { } // INITIALIZATION -var configError = configurationError(); -if (configError !== null) log('STOPPED: ' + configError); -else { +function init() { + var configError = configurationError(); + if (configError !== null) { + log('STOPPED: ' + configError); + return; + } 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.'); + if (CONFIG.enableWrites && CONFIG.expectedControllerVersion === null) { + log('Set expectedControllerVersion after reading register 131; writes remain blocked.'); + } later(bindManagedComponents, 1); } +init(); From feea59278f516535f954a244c693ea69cb1fe4db Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 07:07:24 +0300 Subject: [PATCH 07/16] fix(Midea): initialize managed boolean controls and status explicitly --- modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js b/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js index 63de2b0..251f1ab 100644 --- a/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js +++ b/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js @@ -1,4 +1,4 @@ -/* @meta {"vc":{"zone":{"type":"boolean","config":{"name":"Midea/Clivet Zone 1 Enable","meta":{"ui":{"view":"toggle"}}}},"dhw":{"type":"boolean","config":{"name":"Midea/Clivet DHW Enable","meta":{"ui":{"view":"toggle"}}}},"silent":{"type":"boolean","config":{"name":"Midea/Clivet Silent Mode","meta":{"ui":{"view":"toggle"}}}},"water":{"type":"number","config":{"name":"Midea/Clivet Zone 1 Water Target","min":5,"max":75,"default_value":35,"meta":{"ui":{"view":"field"}}}},"dhwTarget":{"type":"number","config":{"name":"Midea/Clivet DHW Target","min":20,"max":75,"default_value":50,"meta":{"ui":{"view":"field"}}}},"inlet":{"type":"number","config":{"name":"Midea/Clivet Inlet Temperature","min":-50,"max":100}},"outlet":{"type":"number","config":{"name":"Midea/Clivet Outlet Temperature","min":-50,"max":100}},"tank":{"type":"number","config":{"name":"Midea/Clivet Tank Temperature","min":-50,"max":100}},"status":{"type":"text","config":{"name":"Midea/Clivet Status","max_len":255}}}} */ +/* @meta {"vc":{"zone":{"type":"boolean","config":{"name":"Midea/Clivet Zone 1 Enable","default_value":false}},"dhw":{"type":"boolean","config":{"name":"Midea/Clivet DHW Enable","default_value":false}},"silent":{"type":"boolean","config":{"name":"Midea/Clivet Silent Mode","default_value":false}},"water":{"type":"number","config":{"name":"Midea/Clivet Zone 1 Water Target","min":5,"max":75,"default_value":35,"meta":{"ui":{"view":"field"}}}},"dhwTarget":{"type":"number","config":{"name":"Midea/Clivet DHW Target","min":20,"max":75,"default_value":50,"meta":{"ui":{"view":"field"}}}},"inlet":{"type":"number","config":{"name":"Midea/Clivet Inlet Temperature","min":-50,"max":100}},"outlet":{"type":"number","config":{"name":"Midea/Clivet Outlet Temperature","min":-50,"max":100}},"tank":{"type":"number","config":{"name":"Midea/Clivet Tank Temperature","min":-50,"max":100}},"status":{"type":"text","config":{"name":"Midea/Clivet Status","max_len":255,"default_value":"STARTING"}}}} */ /** * @title Midea/Clivet R32 Modbus RTU bridge * @description Local zone-1, DHW and Silent Mode control with nine Virtual From 46509e6a87379498a27234deb4d17e4bc8dc254b Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 07:08:14 +0300 Subject: [PATCH 08/16] docs(Midea): require explicit firmware-managed VC support --- modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js b/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js index 251f1ab..93ba218 100644 --- a/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js +++ b/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js @@ -2,7 +2,7 @@ /** * @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. + * Components. Requires managed-VC-capable firmware and 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 */ From 0a59a046028ab032a738e69251f74877123a6a39 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 07:08:16 +0300 Subject: [PATCH 09/16] docs(Midea): require explicit firmware-managed VC support --- modbus/Midea/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modbus/Midea/README.md b/modbus/Midea/README.md index 14a1eec..828a8bf 100644 --- a/modbus/Midea/README.md +++ b/modbus/Midea/README.md @@ -32,8 +32,10 @@ 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 +Use a compatible Shelly Pro host, its Pro Modbus Add-on, and firmware with +`Serial`, `MbRtuClient`, scripting and **managed** Virtual Components +(`Script.getVcHandle`). Do not assume every version from 2.0.0 supports +firmware-managed script metadata. Select the RS485 add-on in the device configuration and reboot if requested. ## Nine components From 50c20702e6d62ab698c3ee1e59267892b58c3935 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 09:25:14 +0300 Subject: [PATCH 10/16] feat(Midea): replace Silent Mode with persisted managed Modbus Slave ID --- .../midea-clivet-r32-pro-modbus-vc.shelly.js | 86 ++++++++++++++----- 1 file changed, 66 insertions(+), 20 deletions(-) diff --git a/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js b/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js index 93ba218..1d536c4 100644 --- a/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js +++ b/modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js @@ -1,7 +1,7 @@ -/* @meta {"vc":{"zone":{"type":"boolean","config":{"name":"Midea/Clivet Zone 1 Enable","default_value":false}},"dhw":{"type":"boolean","config":{"name":"Midea/Clivet DHW Enable","default_value":false}},"silent":{"type":"boolean","config":{"name":"Midea/Clivet Silent Mode","default_value":false}},"water":{"type":"number","config":{"name":"Midea/Clivet Zone 1 Water Target","min":5,"max":75,"default_value":35,"meta":{"ui":{"view":"field"}}}},"dhwTarget":{"type":"number","config":{"name":"Midea/Clivet DHW Target","min":20,"max":75,"default_value":50,"meta":{"ui":{"view":"field"}}}},"inlet":{"type":"number","config":{"name":"Midea/Clivet Inlet Temperature","min":-50,"max":100}},"outlet":{"type":"number","config":{"name":"Midea/Clivet Outlet Temperature","min":-50,"max":100}},"tank":{"type":"number","config":{"name":"Midea/Clivet Tank Temperature","min":-50,"max":100}},"status":{"type":"text","config":{"name":"Midea/Clivet Status","max_len":255,"default_value":"STARTING"}}}} */ +/* @meta {"vc":{"zone":{"type":"boolean","config":{"name":"Midea/Clivet Zone 1 Enable","default_value":false}},"dhw":{"type":"boolean","config":{"name":"Midea/Clivet DHW Enable","default_value":false}},"slaveId":{"type":"number","config":{"name":"Modbus Slave ID","min":1,"max":247,"default_value":1,"persisted":true,"meta":{"ui":{"view":"field","step":1}}}},"water":{"type":"number","config":{"name":"Midea/Clivet Zone 1 Water Target","min":5,"max":75,"default_value":35,"meta":{"ui":{"view":"field"}}}},"dhwTarget":{"type":"number","config":{"name":"Midea/Clivet DHW Target","min":20,"max":75,"default_value":50,"meta":{"ui":{"view":"field"}}}},"inlet":{"type":"number","config":{"name":"Midea/Clivet Inlet Temperature","min":-50,"max":100}},"outlet":{"type":"number","config":{"name":"Midea/Clivet Outlet Temperature","min":-50,"max":100}},"tank":{"type":"number","config":{"name":"Midea/Clivet Tank Temperature","min":-50,"max":100}},"status":{"type":"text","config":{"name":"Midea/Clivet Status","max_len":255}}}} */ /** * @title Midea/Clivet R32 Modbus RTU bridge - * @description Local zone-1, DHW and Silent Mode control with nine Virtual + * @description Local zone-1/DHW control and persisted Modbus address with nine Virtual * Components. Requires managed-VC-capable firmware and 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 @@ -11,8 +11,8 @@ * 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. + * The previous fixed-ID bridge was hardware-tested on 2026-10-10. + * Managed VCs and selectable Modbus ID require renewed on-device testing. * * Standalone adaptation of the LG THERMA V bridge in * drHouse-gif/lg-therma-v-shelly (upstream/lg-therma-v-pro-em50_vc.shelly.js). @@ -24,14 +24,14 @@ * 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. + * Every read is FC03. Shared words 0 and 2 use 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 firmware-managed Virtual Components (three Boolean, five Number, + * Nine firmware-managed Virtual Components (two Boolean, six Number, * one Text). Firmware assigns IDs; Script.getVcHandle(role) resolves them. * Managed VC lifecycle is owned by firmware, with no runtime Virtual.Add. * @see ./README.md @@ -41,7 +41,6 @@ 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, @@ -61,11 +60,11 @@ var CONFIG = { settleMs: 700 }; -// Five controls followed by three temperatures and a status text. +// Four control points, one persisted slave-address setting, three temperatures and status. var POINTS = [ ['Boolean', 'Zone 1 Enable', 0, 0x0002], ['Boolean', 'DHW Enable', 0, 0x0004], - ['Boolean', 'Silent Mode', 5, 0x0040], + ['Number', 'Modbus Slave ID', -1], ['Number', 'Zone 1 Water Target', 2, 0x00ff], ['Number', 'DHW Target', 4, 0xffff], ['Number', 'Inlet Temperature', 104], @@ -88,20 +87,48 @@ var lastVersion = null; var lastNotice = ''; var vcIds = []; var managedVc = []; -var VC_ROLES = ["zone","dhw","silent","water","dhwTarget","inlet","outlet","tank","status"]; +var slaveGeneration = 0; +var VC_ROLES = ["zone","dhw","slaveId","water","dhwTarget","inlet","outlet","tank","status"]; // HELPERS -function later(fn, ms) { Timer.set(typeof ms === 'number' ? ms : CONFIG.gapMs, false, fn); } +function later(fn, ms) { + var generation = slaveGeneration; + Timer.set(typeof ms === 'number' ? ms : CONFIG.gapMs, false, function() { + if (generation === slaveGeneration) 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) { + var generation = slaveGeneration; Shelly.call(method, params, function(result, code, message) { + if (generation !== slaveGeneration) return; later(function() { done(result, code, message); }, 1); }); } +function selectSlaveId(id) { + if (!isIntegerInRange(id, 1, 247)) { + log('Invalid Modbus Slave ID ' + String(id) + '; expected integer 1..247'); + managedVc[2].setValue(CONFIG.slaveId); + return; + } + if (id === CONFIG.slaveId) return; + var previous = CONFIG.slaveId; + ++slaveGeneration; + CONFIG.slaveId = id; + // All old controller snapshots and pending callbacks are invalid. + ready = false; raw = {}; actual = []; baseline = []; observed = []; + readIndex = 0; controlIndex = 0; publishIndex = 0; + lastFault = ''; lastVersion = null; lastNotice = ''; + log('Modbus target switched ' + previous + ' -> ' + id + '; resynchronizing'); + rpc('Text.Set', {id:pointId(8), + value:'OFFLINE | switching Modbus slave ' + previous + ' -> ' + id}, function() { + later(begin, 1); + }); +} function readWords(addr, qty, done) { rpc('MbRtuClient.ReadHoldingRegisters', { id: CONFIG.serialId, sid: CONFIG.slaveId, addr: addr, qty: qty @@ -123,14 +150,15 @@ function uiValue(i) { return status ? status.value : undefined; } function validValue(i, value) { - if (i < 3) return typeof value === 'boolean'; + if (i === 2) return isIntegerInRange(value, 1, 247); + if (i < 2) 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; + if (i < 2) return (word & POINTS[i][3]) !== 0; return word & POINTS[i][3]; } function writesAllowed() { @@ -144,7 +172,7 @@ function liveStatus() { if (raw[101] === 2) operating = 'Cool'; if (raw[101] === 3) operating = 'Heat'; if (raw[101] === 5) operating = 'DHW'; - value = 'ONLINE | ' + (writesAllowed() ? 'CONTROL' : 'READ ONLY') + + value = 'ONLINE | SID=' + CONFIG.slaveId + ' | ' + (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'; @@ -163,7 +191,7 @@ function fault(message) { } 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.serialId)) return 'Invalid serialId'; 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'; @@ -192,7 +220,7 @@ function bindManagedComponents() { if (!cfg || !isIntegerInRange(cfg.id, 200, 299)) { log('STOPPED: invalid managed VC ID for ' + VC_ROLES[i]); return; } - if (cfg.name !== 'Midea/Clivet ' + POINTS[i][1]) { + if (cfg.name !== (i === 2 ? 'Modbus Slave ID' : 'Midea/Clivet ' + POINTS[i][1])) { log('STOPPED: unexpected managed VC name for ' + VC_ROLES[i]); return; } actualConfig = Shelly.getComponentConfig(type, cfg.id); @@ -202,6 +230,16 @@ function bindManagedComponents() { managedVc[i] = handle; vcIds[i] = cfg.id; } + var selected = managedVc[2].getValue(); + if (!isIntegerInRange(selected, 1, 247)) { + log('STOPPED: saved Modbus Slave ID must be an integer 1..247'); + return; + } + CONFIG.slaveId = selected; + managedVc[2].on('change', function(ev) { + if (ev.source && String(ev.source).indexOf('script:') === 0) return; + selectSlaveId(ev.value); + }); later(begin, 1); } @@ -240,7 +278,11 @@ function readNext() { } function decodeCycle() { var i; - for (i = 0; i < 5; i++) actual[i] = controlValue(i, raw[POINTS[i][2]]); + actual[0] = controlValue(0, raw[POINTS[0][2]]); + actual[1] = controlValue(1, raw[POINTS[1][2]]); + actual[2] = CONFIG.slaveId; + actual[3] = controlValue(3, raw[POINTS[3][2]]); + actual[4] = controlValue(4, raw[POINTS[4][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; } @@ -261,6 +303,7 @@ function decodeCycle() { // FRESH READ-MODIFY-WRITE WITH PER-COMMAND READBACK function commandError(i, requested) { var low, high, factoryLow, factoryHigh, currentMode = raw[1]; + if (i === 2) return 'SLAVE ADDRESS IS CONFIGURATION'; if (!writesAllowed()) return 'WRITES BLOCKED'; if (raw[124] !== 0) return 'ACTIVE FAULT'; if ((i === 1 || i === 4) && !CONFIG.hasDhwTank) return 'DHW NOT CONFIGURED'; @@ -286,7 +329,7 @@ function commandError(i, requested) { } function mergeWord(i, original, requested) { var mask = POINTS[i][3]; - var encoded = i < 3 ? (requested ? mask : 0) : requested; + var encoded = i < 2 ? (requested ? mask : 0) : requested; return ((original & (0xffff ^ mask)) | (encoded & mask)) & 0xffff; } function applyReadback(addr, word) { @@ -317,7 +360,9 @@ function refreshCommandGuards(i, done) { function controlNext() { var i, requested, error, addr; if (controlIndex === 5) { later(publishNext); return; } - i = controlIndex++; requested = uiValue(i); observed[i] = requested; + i = controlIndex++; + if (i === 2) { later(controlNext, 1); return; } + 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); @@ -369,6 +414,7 @@ function publishNext() { lastFault = ''; later(begin, CONFIG.pollMs); return; } i = publishIndex++; + if (i === 2) { later(publishNext, 1); return; } if (!CONFIG.hasDhwTank && i === 7) { later(publishNext, 1); return; } if (i === 8) actual[i] = liveStatus(); current = uiValue(i); @@ -389,7 +435,7 @@ function init() { log('STOPPED: ' + configError); return; } - log('R32 profile; RTU ' + CONFIG.baud + ' ' + CONFIG.format + '; server ' + CONFIG.slaveId); + log('R32 profile; RTU ' + CONFIG.baud + ' ' + CONFIG.format + '; managed Modbus Slave ID'); if (CONFIG.enableWrites && CONFIG.expectedControllerVersion === null) { log('Set expectedControllerVersion after reading register 131; writes remain blocked.'); } From b35be41488b06841c71787e09b335f18357ca697 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 09:26:13 +0300 Subject: [PATCH 11/16] test(Midea): managed Slave ID selection, safe retargeting and stale-response guards --- modbus/Midea/tests/bridge.test.cjs | 103 ++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 8 deletions(-) diff --git a/modbus/Midea/tests/bridge.test.cjs b/modbus/Midea/tests/bridge.test.cjs index 6b1df1e..3887204 100644 --- a/modbus/Midea/tests/bridge.test.cjs +++ b/modbus/Midea/tests/bridge.test.cjs @@ -31,13 +31,15 @@ function emulator(config = {}, initial = {}) { const entries = Object.entries(manifest); const baseId = config.mockManagedIdStart || 220; const roleIds = {}; + const managedEvents = {}; for (let i = 0; i < entries.length; i++) { const [role, definition] = entries[i]; if (role === config.mockMissingManagedRole) continue; const id = baseId + i; const type = definition.type; const cfg = { ...clone(definition.config), id }; - const value = cfg.default_value === undefined ? + const value = role === 'slaveId' && config.mockSavedSlaveId !== undefined ? + config.mockSavedSlaveId : cfg.default_value === undefined ? (type === 'boolean' ? false : type === 'number' ? 0 : '') : cfg.default_value; components.set(type + ':' + id, { config: cfg, value }); roleIds[role] = { type, id }; @@ -105,7 +107,7 @@ function emulator(config = {}, initial = {}) { getConfig: () => item()?.config, getValue: () => item()?.value, setValue: value => { item().value = value; }, - on: () => 1 + on: (name, callback) => { managedEvents[role] = callback; return 1; } }; } }, Timer: { set: (delay, repeat, callback) => { @@ -120,7 +122,14 @@ function emulator(config = {}, initial = {}) { 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; }, + change: (type, id, value) => { + components.get(type + ':' + id).value = value; + for (const [role, point] of Object.entries(roleIds)) { + if (point.type === type && point.id === id && managedEvents[role]) { + managedEvents[role]({ value, source: 'app' }); + } + } + }, offline: value => { offline = value; }, rejectWrites: value => { rejectWrites = value; }, failWrite: value => { failWrite = value; }, @@ -189,9 +198,87 @@ test('RMW uses a fresh high byte, not the poll snapshot', () => { } }); 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('the third managed component is a persisted Modbus Slave ID', () => { + const e = emulator(); e.sync(); + const point = e.components.get('number:222'); + assert.equal(point.config.name, 'Modbus Slave ID'); + assert.equal(point.config.min, 1); + assert.equal(point.config.max, 247); + assert.equal(point.config.persisted, true); + assert.equal(point.config.meta.ui.step, 1); + assert.equal(point.value, 1); + assert.ok(!e.components.has('boolean:222')); +}); +test('configured persisted Modbus address is used on startup', () => { + const e = emulator({ mockSavedSlaveId: 16 }); e.sync(); + assert.equal(e.context.CONFIG.slaveId, 16); + assert.equal(e.value('number', 222), 16); + assert.ok(e.trace.filter(x => /MbRtuClient\./.test(x.method)).every(x => x.params.sid === 16)); + assert.equal(e.writes().length, 0); +}); +test('address selection switches targets safely and resynchronizes without replay', () => { + const e = emulator(control); e.sync(); + e.change('number', 223, 40); + e.change('number', 222, 17); + assert.equal(e.context.CONFIG.slaveId, 17); + assert.equal(e.context.ready, false); + assert.equal(e.writes().length, 0); + e.sync(); e.advance(15000); + assert.equal(e.value('number', 222), 17); + assert.equal(e.value('number', 223), 35); + assert.equal(e.writes().length, 0); + assert.match(e.value('text', 228), /SID=17/); + assert.ok(e.trace.filter(x => /MbRtuClient\./.test(x.method)).some(x => x.params.sid === 17)); +}); +test('invalid Modbus addresses are rejected without starting new requests', () => { + const e = emulator({ mockSavedSlaveId: 16 }); e.sync(); + for (const invalid of [0, 248, 6.5, '16']) { + e.change('number', 222, invalid); + assert.equal(e.value('number', 222), 16); + assert.equal(e.context.CONFIG.slaveId, 16); + } + assert.equal(e.writes().length, 0); +}); +test('invalid saved Modbus address aborts before accessing the physical controller', () => { + const e = emulator({ mockSavedSlaveId: 0 }); + e.advance(1000); + assert.ok(e.logs.some(x => /saved Modbus Slave ID/.test(x))); + assert.ok(!e.trace.some(x => /MbRtuClient\.|Serial\./.test(x.method))); +}); +test('stale in-flight reads cannot pollute state after address changes', () => { + const e = emulator(control); e.sync(); + let switched = false; + e.hook((method, params) => { + if (!switched && method === 'MbRtuClient.ReadHoldingRegisters' && + params.sid === 1 && params.addr === 0) { + switched = true; + e.change('number', 222, 21); + } + }); + e.advance(15000); + assert.equal(switched, true); + e.sync(); + assert.equal(e.context.CONFIG.slaveId, 21); + assert.equal(e.value('number', 223), 35); + assert.equal(e.writes().length, 0); + assert.ok(e.trace.some(x => x.method === 'MbRtuClient.ReadHoldingRegisters' && x.params.sid === 21)); +}); +test('switching during pre-write guards cancels pending commands on old slave', () => { + const e = emulator(control); e.sync(); e.change('number', 223, 40); + let switched = false; + e.hook((method, params) => { + if (!switched && method === 'MbRtuClient.ReadHoldingRegisters' && + params.sid === 1 && params.addr === 0 && params.qty === 6) { + switched = true; + e.change('number', 222, 32); + } + }); + e.advance(15000); + assert.equal(switched, true); + e.sync(); + assert.equal(e.writes().length, 0); + assert.equal(e.value('number', 223), 35); + assert.equal(e.context.CONFIG.slaveId, 32); }); test('DHW target uses address 4 and integer degrees', () => { const e = emulator(control); e.sync(); e.change('number', 224, 55); e.advance(15000); @@ -213,7 +300,7 @@ test('missing expected version keeps control disabled', () => { 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); + const e = emulator(control, { 0: 9 }); e.sync(); e.change('boolean', 220, 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', () => { @@ -292,7 +379,7 @@ test('missing managed role aborts safely before Modbus access', () => { 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))); + assert.ok(e.writes().every(x => [0, 2, 4].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 }); From 2d5660f90adb20365cb7a7e64854ca33ba7aac5c Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 09:26:48 +0300 Subject: [PATCH 12/16] docs(Midea): describe persisted Slave ID component and safe controller selection --- modbus/Midea/README.md | 43 ++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/modbus/Midea/README.md b/modbus/Midea/README.md index 828a8bf..aad9cc4 100644 --- a/modbus/Midea/README.md +++ b/modbus/Midea/README.md @@ -48,7 +48,7 @@ retrieves them regardless of their numeric IDs. Do **not** hard-code old IDs. | --- | --- | --- | | `zone` | Boolean | Zone 1 Enable | | `dhw` | Boolean | DHW Enable | -| `silent` | Boolean | Silent Mode | +| `slaveId` | Number | **Modbus Slave ID (1–247), persistent and editable** | | `water` | Number | Zone 1 Water Target | | `dhwTarget` | Number | DHW Target | | `inlet` | Number | Inlet Temperature | @@ -56,6 +56,15 @@ retrieves them regardless of their numeric IDs. Do **not** hard-code old IDs. | `tank` | Number | Tank Temperature, if equipped | | `status` | Text | Operating mode and diagnostics | +The `slaveId` field selects the **Modbus address used by Shelly** when +communicating with the controller. It does not change the controller's actual +hardware Modbus address. Firmware persists the selected value across Shelly +restarts. The default is **1**; select the controller's assigned address using +the managed Virtual Component instead of editing `CONFIG`. Inputs must be +whole numbers from 1 to 247. Changing this value clears old controller +snapshots, rejects stale replies and triggers fresh synchronization before +control resumes. The `Status` VC reports the selected `SID` and link errors. + The operating mode is set on the original controller. Zone 1 Enable is not a mains switch or zone-2 override. There are no groups; the nine managed VCs occupy nine out of ten device-wide slots. @@ -79,7 +88,6 @@ Create one Shelly script, paste the complete `.shelly.js` file and edit `CONFIG` ```javascript serialId: 100, -slaveId: 1, baud: 9600, format: '8N1', enableWrites: false, @@ -88,10 +96,11 @@ 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. +`serialId` identifies the local Shelly Serial/MbRtuClient instance. The +**Modbus Slave ID** Virtual Component instead selects the *remote controller's* +address (for example, 16). They are different identifiers. Find the Serial +instance with `Shelly.GetComponents` if its ID differs from 100. The saved +managed slave-ID setting replaces the previous hardcoded `CONFIG.slaveId`. 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 @@ -99,7 +108,7 @@ 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. Firmware creates/reconciles the nine managed VCs; -the script binds them by role, configures +the script binds them by role, reads the saved Modbus Slave ID, configures `mode: 'mb_client'`, reads the physical values, and publishes them. It logs: ```text @@ -111,7 +120,9 @@ 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. +identification. The remaining four device-control VCs are Zone 1 Enable, +DHW Enable, Zone 1 Water Target and DHW Target; the slave-ID setting is +configuration-only and never writes a Modbus address-changing register. ## Temperature limits and controller behavior @@ -141,7 +152,7 @@ Addresses below are wire offsets, not 4xxxx reference numbers. | 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 | +| 5, bit 12 | Weather curve active | Read-only guard blocking manual water-target writes | | 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 | @@ -162,27 +173,30 @@ 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 +Registers 0 and 2 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` +resumes. Changing Modbus Slave ID invalidates in-flight callbacks and all +cached controller values and refreshes Status before starting communication +with the newly selected address. Already transmitted commands cannot be +cancelled; never change the address while the machine is in a hazardous state. 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, +- No response: check H1/H2, polarity, the persisted **Modbus Slave ID** field, 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. +- Setup stops: check managed-VC support, nine available component slots, valid persisted Slave ID (1–247), and firmware compatibility. Run the deterministic RPC/Modbus tests from the repository root: @@ -191,6 +205,7 @@ node --check modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js node --test modbus/Midea/tests/bridge.test.cjs ``` -The harness checks managed role binding and dynamic component IDs, fresh bit/byte preservation, +The harness checks managed role binding, persisted Modbus address selection, +dynamic component IDs, stale callback rejection and 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). From 552fe5eb7e811c750d25a7ed52cdda4c132941b4 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 09:26:52 +0300 Subject: [PATCH 13/16] docs(Midea): describe persisted Slave ID component and safe controller selection --- modbus/Midea/TECHNICAL_NOTES.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/modbus/Midea/TECHNICAL_NOTES.md b/modbus/Midea/TECHNICAL_NOTES.md index 0f99755..5667da4 100644 --- a/modbus/Midea/TECHNICAL_NOTES.md +++ b/modbus/Midea/TECHNICAL_NOTES.md @@ -2,8 +2,11 @@ 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. +Silent Mode control, forced heaters, disinfection, energy counters and +installer parameters are outside the command surface of this nine-component +bridge. The former Silent Mode VC has been replaced by a persisted managed +Modbus Slave ID Number (1–247). It changes the address Shelly polls, not +the heat pump controller's own configured address. ## References @@ -29,7 +32,7 @@ transport faults. It checks the resulting Modbus writes and UI values. The checks cover dynamic managed VC binding, startup/reconnect read-before-write, register-0 bit preservation, -register-2 high-byte preservation, register-5 function-bit preservation, signed +register-2 high-byte preservation, saved-slave-ID checks, mid-read target changes, 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. @@ -43,12 +46,14 @@ every heat pump sold under the Midea or Clivet brand. The merged October 11 release used nine fixed-ID, self-provisioned VCs. This follow-up changes the provisioning model to nine firmware-managed roles -in first-line `@meta`. The script resolves component IDs dynamically using +in first-line `@meta` and replaces Silent Mode with a persisted selectable +Modbus slave address. The script resolves component IDs dynamically using `Script.getVcHandle(role).getConfig().id`, and verifies each handle before starting Modbus. Firmware manages ownership and cleanup on script deletion. Existing manually created components are not adopted automatically; migrating requires backing up and removing only positively identified obsolete VCs. -Hardware validation of this new provisioning behavior is still pending. +Hardware validation of the managed-VC startup, configurable Modbus Slave ID +and target-switch protection is still pending. ## Integration boundaries @@ -58,9 +63,12 @@ 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. +operations. Changing the saved Modbus Slave ID increments a generation token, +invalidates cached values and discards callbacks/timers from the old target. +An in-flight command already sent to the former controller cannot be undone. +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. From 18fb2e6f4aa4deddd4fe8da7055b454af5781017 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 09:30:32 +0300 Subject: [PATCH 14/16] chore: keep PR limited to Shelly-installable Modbus bridge script --- modbus/Midea/tests/bridge.test.cjs | 165 +++++------------------------ 1 file changed, 26 insertions(+), 139 deletions(-) diff --git a/modbus/Midea/tests/bridge.test.cjs b/modbus/Midea/tests/bridge.test.cjs index 3887204..2faf588 100644 --- a/modbus/Midea/tests/bridge.test.cjs +++ b/modbus/Midea/tests/bridge.test.cjs @@ -25,25 +25,6 @@ function emulator(config = {}, initial = {}) { let pendingReadback = false; let serial = { mode: 'mb_client', serial: { baud: 9600, format: '8N1' } }; const clone = x => JSON.parse(JSON.stringify(x)); - // Firmware creates the declared VCs before it evaluates the script. - // Tests assign deterministic IDs for convenience; production uses role handles. - const manifest = JSON.parse(source.split(/\r?\n/)[0].slice('/* @meta '.length, -' */'.length)).vc; - const entries = Object.entries(manifest); - const baseId = config.mockManagedIdStart || 220; - const roleIds = {}; - const managedEvents = {}; - for (let i = 0; i < entries.length; i++) { - const [role, definition] = entries[i]; - if (role === config.mockMissingManagedRole) continue; - const id = baseId + i; - const type = definition.type; - const cfg = { ...clone(definition.config), id }; - const value = role === 'slaveId' && config.mockSavedSlaveId !== undefined ? - config.mockSavedSlaveId : cfg.default_value === undefined ? - (type === 'boolean' ? false : type === 'number' ? 0 : '') : cfg.default_value; - components.set(type + ':' + id, { config: cfg, value }); - roleIds[role] = { type, id }; - } function call(method, params, callback) { trace.push({ time: now, method, params: clone(params) }); if (beforeRpc) beforeRpc(method, params, api); @@ -51,7 +32,12 @@ function emulator(config = {}, initial = {}) { 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') throw new Error('Managed VC scripts must not call Virtual.Add'); + 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; @@ -99,17 +85,6 @@ function emulator(config = {}, initial = {}) { getComponentStatus: (type, id) => { const c = components.get(type + ':' + id); return c ? { value: c.value } : undefined; } }, - Script: { getVcHandle: role => { - const point = roleIds[role]; - if (!point) return null; - const item = () => components.get(point.type + ':' + point.id); - return { - getConfig: () => item()?.config, - getValue: () => item()?.value, - setValue: value => { item().value = value; }, - on: (name, callback) => { managedEvents[role] = callback; return 1; } - }; - } }, Timer: { set: (delay, repeat, callback) => { assert.equal(repeat, false); timers.push({ id: ++timerId, at: now + delay, callback }); @@ -122,14 +97,7 @@ function emulator(config = {}, initial = {}) { 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; - for (const [role, point] of Object.entries(roleIds)) { - if (point.type === type && point.id === id && managedEvents[role]) { - managedEvents[role]({ value, source: 'app' }); - } - } - }, + change: (type, id, value) => { components.get(type + ':' + id).value = value; }, offline: value => { offline = value; }, rejectWrites: value => { rejectWrites = value; }, failWrite: value => { failWrite = value; }, @@ -198,87 +166,9 @@ test('RMW uses a fresh high byte, not the poll snapshot', () => { } }); e.advance(15000); assert.equal(e.registers[2], (47 << 8) | 40); }); -test('the third managed component is a persisted Modbus Slave ID', () => { - const e = emulator(); e.sync(); - const point = e.components.get('number:222'); - assert.equal(point.config.name, 'Modbus Slave ID'); - assert.equal(point.config.min, 1); - assert.equal(point.config.max, 247); - assert.equal(point.config.persisted, true); - assert.equal(point.config.meta.ui.step, 1); - assert.equal(point.value, 1); - assert.ok(!e.components.has('boolean:222')); -}); -test('configured persisted Modbus address is used on startup', () => { - const e = emulator({ mockSavedSlaveId: 16 }); e.sync(); - assert.equal(e.context.CONFIG.slaveId, 16); - assert.equal(e.value('number', 222), 16); - assert.ok(e.trace.filter(x => /MbRtuClient\./.test(x.method)).every(x => x.params.sid === 16)); - assert.equal(e.writes().length, 0); -}); -test('address selection switches targets safely and resynchronizes without replay', () => { - const e = emulator(control); e.sync(); - e.change('number', 223, 40); - e.change('number', 222, 17); - assert.equal(e.context.CONFIG.slaveId, 17); - assert.equal(e.context.ready, false); - assert.equal(e.writes().length, 0); - e.sync(); e.advance(15000); - assert.equal(e.value('number', 222), 17); - assert.equal(e.value('number', 223), 35); - assert.equal(e.writes().length, 0); - assert.match(e.value('text', 228), /SID=17/); - assert.ok(e.trace.filter(x => /MbRtuClient\./.test(x.method)).some(x => x.params.sid === 17)); -}); -test('invalid Modbus addresses are rejected without starting new requests', () => { - const e = emulator({ mockSavedSlaveId: 16 }); e.sync(); - for (const invalid of [0, 248, 6.5, '16']) { - e.change('number', 222, invalid); - assert.equal(e.value('number', 222), 16); - assert.equal(e.context.CONFIG.slaveId, 16); - } - assert.equal(e.writes().length, 0); -}); -test('invalid saved Modbus address aborts before accessing the physical controller', () => { - const e = emulator({ mockSavedSlaveId: 0 }); - e.advance(1000); - assert.ok(e.logs.some(x => /saved Modbus Slave ID/.test(x))); - assert.ok(!e.trace.some(x => /MbRtuClient\.|Serial\./.test(x.method))); -}); -test('stale in-flight reads cannot pollute state after address changes', () => { - const e = emulator(control); e.sync(); - let switched = false; - e.hook((method, params) => { - if (!switched && method === 'MbRtuClient.ReadHoldingRegisters' && - params.sid === 1 && params.addr === 0) { - switched = true; - e.change('number', 222, 21); - } - }); - e.advance(15000); - assert.equal(switched, true); - e.sync(); - assert.equal(e.context.CONFIG.slaveId, 21); - assert.equal(e.value('number', 223), 35); - assert.equal(e.writes().length, 0); - assert.ok(e.trace.some(x => x.method === 'MbRtuClient.ReadHoldingRegisters' && x.params.sid === 21)); -}); -test('switching during pre-write guards cancels pending commands on old slave', () => { - const e = emulator(control); e.sync(); e.change('number', 223, 40); - let switched = false; - e.hook((method, params) => { - if (!switched && method === 'MbRtuClient.ReadHoldingRegisters' && - params.sid === 1 && params.addr === 0 && params.qty === 6) { - switched = true; - e.change('number', 222, 32); - } - }); - e.advance(15000); - assert.equal(switched, true); - e.sync(); - assert.equal(e.writes().length, 0); - assert.equal(e.value('number', 223), 35); - assert.equal(e.context.CONFIG.slaveId, 32); +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); @@ -300,7 +190,7 @@ test('missing expected version keeps control disabled', () => { 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', 220, true); e.advance(15000); + 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', () => { @@ -356,30 +246,27 @@ test('DHW reads and writes are omitted when the tank is not configured', () => { 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('firmware-managed handles have no runtime VC creation or deletion', () => { - const e = emulator(); e.sync(); - assert.equal(e.components.size, 9); - assert.ok(!e.trace.some(x => x.method === 'Virtual.Add' || x.method === 'Virtual.Delete')); - assert.ok(e.context.vcIds.length === 9); +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('managed VCs resolve arbitrary firmware-assigned IDs', () => { - const e = emulator({ mockManagedIdStart: 260, ...control }); - e.sync(); e.change('number', 263, 40); e.advance(15000); - assert.equal(e.context.vcIds[3], 263); - assert.equal(e.registers[2], (42 << 8) | 40); - assert.match(e.value('text', 268), /ONLINE.*CONTROL/); +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('missing managed role aborts safely before Modbus access', () => { - const e = emulator({ mockMissingManagedRole: 'status' }); - e.advance(1000); - assert.equal(e.components.size, 8); - assert.ok(e.logs.some(x => /missing managed VC role status/.test(x))); - assert.ok(!e.trace.some(x => /Serial\.|MbRtuClient\./.test(x.method))); +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].includes(x.params.addr))); + 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 }); From 872a72d1ab2e3c7d8e25541145791455bc36efd0 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 09:30:35 +0300 Subject: [PATCH 15/16] chore: keep PR limited to Shelly-installable Modbus bridge script --- modbus/Midea/README.md | 102 +++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 65 deletions(-) diff --git a/modbus/Midea/README.md b/modbus/Midea/README.md index aad9cc4..a993763 100644 --- a/modbus/Midea/README.md +++ b/modbus/Midea/README.md @@ -1,8 +1,8 @@ # 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 declares -**exactly nine firmware-managed Virtual Components** and uses the original heat-pump controller. +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. @@ -32,55 +32,34 @@ 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 with -`Serial`, `MbRtuClient`, scripting and **managed** Virtual Components -(`Script.getVcHandle`). Do not assume every version from 2.0.0 supports -firmware-managed script metadata. Select +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 -The nine firmware-managed Virtual Components are declared through first-line -`@meta` metadata. Firmware assigns their IDs; `Script.getVcHandle(role)` -retrieves them regardless of their numeric IDs. Do **not** hard-code old IDs. +Default `vcBase: 220` produces the following components: -| Role | Type | Description | +| Key | Name | Purpose | | --- | --- | --- | -| `zone` | Boolean | Zone 1 Enable | -| `dhw` | Boolean | DHW Enable | -| `slaveId` | Number | **Modbus Slave ID (1–247), persistent and editable** | -| `water` | Number | Zone 1 Water Target | -| `dhwTarget` | Number | DHW Target | -| `inlet` | Number | Inlet Temperature | -| `outlet` | Number | Outlet Temperature | -| `tank` | Number | Tank Temperature, if equipped | -| `status` | Text | Operating mode and diagnostics | - -The `slaveId` field selects the **Modbus address used by Shelly** when -communicating with the controller. It does not change the controller's actual -hardware Modbus address. Firmware persists the selected value across Shelly -restarts. The default is **1**; select the controller's assigned address using -the managed Virtual Component instead of editing `CONFIG`. Inputs must be -whole numbers from 1 to 247. Changing this value clears old controller -snapshots, rejects stale replies and triggers fresh synchronization before -control resumes. The `Status` VC reports the selected `SID` and link errors. - -The operating mode is set on the original controller. Zone 1 Enable is -not a mains switch or zone-2 override. There are no groups; the nine managed -VCs occupy nine out of ten device-wide slots. - -### Migration from the original self-provisioned bridge - -Back up the installed script, scenes, VCs, and external integrations first. -Stop the old Midea/Clivet script; its manually created VCs (previously at -220–228) can persist after it stops. Inspect the device and **manually remove -only confirmed obsolete Midea/Clivet VCs** before enabling this managed -version. The managed firmware lifecycle does not adopt old manual VCs. -Do not delete unrelated components. Discover the newly allocated IDs using -`Shelly.GetComponents` and update any references to old numeric IDs. - -The historical real-device test predates this managed-VC change. Run a new -physical startup and UI/Modbus smoke test before production deployment. +| `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 @@ -88,6 +67,7 @@ Create one Shelly script, paste the complete `.shelly.js` file and edit `CONFIG` ```javascript serialId: 100, +slaveId: 1, baud: 9600, format: '8N1', enableWrites: false, @@ -96,19 +76,17 @@ hasDhwTank: false, allowCooling: false ``` -`serialId` identifies the local Shelly Serial/MbRtuClient instance. The -**Modbus Slave ID** Virtual Component instead selects the *remote controller's* -address (for example, 16). They are different identifiers. Find the Serial -instance with `Shelly.GetComponents` if its ID differs from 100. The saved -managed slave-ID setting replaces the previous hardcoded `CONFIG.slaveId`. +`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. Firmware creates/reconciles the nine managed VCs; -the script binds them by role, reads the saved Modbus Slave ID, configures +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 @@ -120,9 +98,7 @@ 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. The remaining four device-control VCs are Zone 1 Enable, -DHW Enable, Zone 1 Water Target and DHW Target; the slave-ID setting is -configuration-only and never writes a Modbus address-changing register. +identification. Configured controls become toggles/fields after restart. ## Temperature limits and controller behavior @@ -152,7 +128,7 @@ Addresses below are wire offsets, not 4xxxx reference numbers. | 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 12 | Weather curve active | Read-only guard blocking manual water-target writes | +| 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 | @@ -173,30 +149,27 @@ 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 and 2 use a masked read-modify-write. For example, changing the +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. Changing Modbus Slave ID invalidates in-flight callbacks and all -cached controller values and refreshes Status before starting communication -with the newly selected address. Already transmitted commands cannot be -cancelled; never change the address while the machine is in a hazardous state. Commands are sequential, with `gapMs` between operations and `pollMs` +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, the persisted **Modbus Slave ID** field, baud/format, +- 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 managed-VC support, nine available component slots, valid persisted Slave ID (1–247), and firmware compatibility. +- Setup stops: check component capacity and ID/name conflicts. Run the deterministic RPC/Modbus tests from the repository root: @@ -205,7 +178,6 @@ node --check modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js node --test modbus/Midea/tests/bridge.test.cjs ``` -The harness checks managed role binding, persisted Modbus address selection, -dynamic component IDs, stale callback rejection and fresh bit/byte preservation, +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). From 145dfa27c04ebffeafc4681e3ba364a773d47b61 Mon Sep 17 00:00:00 2001 From: drHouse-gif Date: Mon, 12 Oct 2026 09:30:38 +0300 Subject: [PATCH 16/16] chore: keep PR limited to Shelly-installable Modbus bridge script --- modbus/Midea/TECHNICAL_NOTES.md | 40 +++++++++++---------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/modbus/Midea/TECHNICAL_NOTES.md b/modbus/Midea/TECHNICAL_NOTES.md index 5667da4..a4ba66d 100644 --- a/modbus/Midea/TECHNICAL_NOTES.md +++ b/modbus/Midea/TECHNICAL_NOTES.md @@ -2,11 +2,8 @@ The script implements zone-1 leaving-water control using the R32 register layout. Mode selection, zone 2, room-temperature control, weather-curve changes, -Silent Mode control, forced heaters, disinfection, energy counters and -installer parameters are outside the command surface of this nine-component -bridge. The former Silent Mode VC has been replaced by a persisted managed -Modbus Slave ID Number (1–247). It changes the address Shelly polls, not -the heat pump controller's own configured address. +forced heaters, disinfection, energy counters and installer parameters are +outside the command surface of this nine-component bridge. ## References @@ -24,15 +21,13 @@ classes, external services or manually constructed RTU frames. ## Software verification -The previously released implementation passed 33 Node.js RPC/Modbus -integration tests on 2026-10-07. This managed-VC revision retains 33 updated -tests; its CI must be checked independently. Syntax validation used `node --check`. The harness executes the +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 dynamic managed VC binding, startup/reconnect read-before-write, -register-0 bit preservation, -register-2 high-byte preservation, saved-slave-ID checks, mid-read target changes, signed +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. @@ -44,16 +39,10 @@ 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 merged October 11 release used nine fixed-ID, self-provisioned VCs. -This follow-up changes the provisioning model to nine firmware-managed roles -in first-line `@meta` and replaces Silent Mode with a persisted selectable -Modbus slave address. The script resolves component IDs dynamically using -`Script.getVcHandle(role).getConfig().id`, and verifies each handle before -starting Modbus. Firmware manages ownership and cleanup on script deletion. -Existing manually created components are not adopted automatically; migrating -requires backing up and removing only positively identified obsolete VCs. -Hardware validation of the managed-VC startup, configurable Modbus Slave ID -and target-switch protection is still pending. +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 @@ -63,12 +52,9 @@ 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. Changing the saved Modbus Slave ID increments a generation token, -invalidates cached values and discards callbacks/timers from the old target. -An in-flight command already sent to the former controller cannot be undone. -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. +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.