diff --git a/modbus/README-innova-airleaf.md b/modbus/README-innova-airleaf.md new file mode 100644 index 0000000..180e24f --- /dev/null +++ b/modbus/README-innova-airleaf.md @@ -0,0 +1,86 @@ +# INNOVA AirLeaf Modbus RTU examples + +**Status: under upstream review.** Contributor-reported physical Modbus tests preceded this managed-VC refactor. The new managed startup/lifecycle still needs a fresh device smoke test; writes remain disabled by default. + +**AI assistance disclosure:** These examples were produced with AI assistance and reviewed/submitted with user direction. Please check the repository's authorship requirements before accepting this contribution. + +# INNOVA AirLeaf ↔ Shelly The Pill (Modbus RTU) + +Three standalone variants are provided for distinct controller boards; install **only one**. Each script declares nine firmware-managed VCs through the first-line `@meta` and binds them by `Script.getVcHandle(role)`. Component IDs are assigned by firmware, not hard-coded. The modified startup has not yet been tested on a real Pill. + +## Source profile selection + +| File | Documented controller / board | Notes | +|---|---|---| +| `innova-airleaf-b32.shelly.js` | ECA644/ECA647 with INN-FR-B32 | Board-specific water temperature (reg. 1) and commanded motor-speed register (reg. 15) | +| `innova-airleaf-ese645.shelly.js` | EDA649/EDB649 with ESE645 | Uses common registers; auxiliary VC reports real setpoint (reg. 8), motor VC reports raw PRG word (reg. 201) | +| `innova-airleaf-ese648.shelly.js` | ESE648 board in N273025C configurations | Same common-register VC behavior; exact installed controller must be checked | + +The source document lists common register parameters resident on INN-FR-B32, ESE645/ESE648 and ECA644/ECA647/EDA649/EDB649 wall controls. `*` registers are only for INN-FR-B32. Other INNOVA products, including EWF644II, are **not** presumed compatible. Do not install on an unverified board. + +## Nine virtual components + +1. INNOVA Power (boolean) — PRG register 201, bit 7. +2. Room temperature (number) — register 0 (0.1 C). +3. Target temperature (number) — register 231 (0.1 C). +4. Operating mode (enum) — register 233, 3/5/0. +5. Fan mode (enum) — PRG 201, bits 0-2, values 0-3. +6. Water temperature (B32) / actual setpoint (ESE) — register 1 or 8. +7. Motor speed **command** (B32) / raw program flags (ESE) — register 15 or 201. **Not measured fan RPM.** +8. Modbus diagnostics / alarm flags (text) — register 105 and communication errors. +9. Modbus Slave ID (number, 1–247) — persisted controller address used by the Shelly Modbus client; editable as an integer field in the device UI. + +No group component is declared (nine of the ten device-wide VC slots). Firmware creates/reconciles managed components at startup and removes them when their owning script is deleted. Old manually created VCs do not migrate automatically. Never run multiple profiles on the same host. + +### Selecting the Modbus slave from the device interface + +- The **Modbus Slave ID** VC is a number field with step 1 and range **1..247**; its managed role is `slaveId`, and the configured value uses `persisted: true`. +- The value selects the address to which the **Shelly Modbus client sends requests**. It **does not program or change the physical INNOVA controller's own Modbus address**. First configure the address on the controller using its supported procedure, then select the matching value in Shelly. +- On a selection change the script clears all cached registers and previous-device control state, prevents Modbus writes until it has read fresh data, and discards responses from reads addressed to the former slave. Previously transmitted Modbus writes cannot be cancelled. +- Invalid or fractional values are rejected; the script restores the last valid selection. The existing **Modbus diagnostics** text component reports offline/communication errors. A dedicated **Modbus online** boolean VC is no longer created. +- The default address is **1** for a fresh installation. On subsequent restarts the firmware restores the saved selection. If the controller uses another slave address, the default will fail to communicate until updated. + + + +## First commissioning steps + +1. Back up scripts/scenes and VCs; stop the old INNOVA `Virtual.Add` script. Manually remove **only confirmed obsolete INNOVA-created VCs** before switching, or there may be insufficient capacity. Do not delete unrelated components. +2. Identify the actual INNOVA controller board, its slave address, and its serial settings. *Never* scan/write unknown devices blindly. +3. Confirm electrical connection A↔A, B↔B, common/reference and termination according to hardware manuals; never connect mains to RS485. +4. In The Pill, verify the `Serial` mode is `mb_client`, and set the serial settings to **match the INNOVA**. The scripts check but never alter serial config. They expect **9600 8N1**; the Pill previously reported **115200 8N1**, which will cause a deliberate refusal to start polling. +5. Open the managed Virtual Component **Modbus Slave ID** and set the address of the connected INNOVA controller (integer **1–247**, initially **1**). It is saved across Shelly restarts; editing the script is unnecessary. +6. Upload/start the appropriate profile with `ENABLE_WRITES=false`. Firmware creates nine managed VCs from `@meta`, including **Modbus Slave ID**, and the script obtains role handles without fixed component IDs. Check the selected address before starting communication. +7. Confirm room temperature, target and alarm/communication diagnostics against the physical controller. The online state remains an internal safety guard rather than a visible VC. Allow enough time for each register to be read; polling advances approximately once per 3.5 s. +8. Only after checking Modbus scaling, bit maps, terminal wiring, physical reactions, and the installation safety design, consider changing `ENABLE_WRITES=true` to allow four user-facing command VCs. Writes use FC06 and read-back polling. Treat any mismatch as a failed commissioning test. + +## Read-only diagnostics + +- `http:///rpc/Shelly.GetDeviceInfo` +- `http:///rpc/Serial.GetConfig?id=0` +- `http:///rpc/Shelly.GetComponents?dynamic_only=true` +- `http:///rpc/Script.GetStatus?id=1` (replace `1` with the actual script ID) + +Read `Shelly.GetComponents` to verify all nine managed roles exist (the component IDs are firmware-assigned). No live connection to the Pill was used to validate this revision. + +## Limitations / safety + +- **Not production-certified**. Earlier contributor-reported controller tests do not validate the new VC provisioning on every The Pill firmware; verify on real hardware before production use. +- The `@meta` declaration must be the first physical line and remain within 1024 characters (B32: 1017, ESE645/ESE648: 1013). Adding more metadata may exceed the firmware parser's limit. +- Managed component IDs may differ after migration. Update any external scripts/scenes referring to old IDs. +- On firmware `2.0.1-ge1a198b`, Shelly The Pill Gen3 has a documented restart problem when starting managed VC scripts. Test on a firmware known to support managed VCs; this code change does not fix firmware crashes. +- Modbus writes remain explicitly disabled by `ENABLE_WRITES=false` until commissioning and verification. Switching the selected Modbus address only retargets requests; it does not issue an address-changing command to the INNOVA controller. +- The `power` VC reflects requested standby configuration, not measured electrical power. +- A successful FC06 reply does not prove the mechanical output changed; poll read-back and physical inspection remain required. +- Actual setpoint can be modified by local controls and limits. Commission write permissions cautiously. +- Timeout responses are handled in a minimal fashion; no write retries, so uncertain commands are not repeated automatically. +- Do not use as a primary equipment-safety interlock, freeze guard, or thermal protection mechanism. + +## Register documentation + +[INNOVA N273025C Rev.01 PDF](https://www.innova.it/site/assets/files/2792/n273025c_kit_bridge_modbus_rtu_rev_01_en.pdf) + +[Shelly Modbus RTU Client RPC](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/MbRtuClient/) + +[Shelly Virtual Components API](https://shelly-api-docs.shelly.cloud/gen2/Scripts/APIs/Virtual/) + +[Shelly Serial component](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Serial/) \ No newline at end of file diff --git a/modbus/innova-airleaf-b32.shelly.js b/modbus/innova-airleaf-b32.shelly.js new file mode 100644 index 0000000..9da4541 --- /dev/null +++ b/modbus/innova-airleaf-b32.shelly.js @@ -0,0 +1,174 @@ +/* @meta {"vc":{"power":{"type":"boolean","config":{"name":"INNOVA Power","default_value":false}},"room":{"type":"number","config":{"name":"Room temperature","min":0,"max":100,"default_value":0}},"target":{"type":"number","config":{"name":"Target temperature","min":5,"max":40,"default_value":20}},"mode":{"type":"enum","config":{"name":"Operating mode","options":["auto","heating","cooling","unknown"],"default_value":"unknown"}},"fan":{"type":"enum","config":{"name":"Fan mode","options":["auto","silent","night","maximum","unknown"],"default_value":"unknown"}},"aux":{"type":"number","config":{"name":"Water temperature","min":0,"max":100,"default_value":0}},"motor":{"type":"number","config":{"name":"Motor speed command","min":0,"max":65535,"default_value":0}},"alarm":{"type":"text","config":{"name":"Modbus diagnostics","default_value":"Starting"}},"slaveId":{"type":"number","config":{"name":"Modbus Slave ID","min":1,"max":247,"default_value":1,"persisted":true,"meta":{"ui":{"view":"field","step":1}}}}}} */ +/** + * @title INNOVA AirLeaf B32 Modbus RTU + * @description Read-only by default; nine firmware-managed VCs for documented N273025C controllers. + * @status under development + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/modbus/innova-airleaf-b32.shelly.js + */ +/** + * SPDX-License-Identifier: Apache-2.0 + * Hardware: The Pill + Modbus RS485 add-on; 9600 8N1 expected, verify on controller. + * Modbus FC03/FC06; zero-based registers and a persisted managed Slave ID. + * Managed VCs declared via @meta and resolved with Script.getVcHandle(role). + * Nine managed roles: power, room, target, mode, fan, aux, motor, alarm, slaveId. + * power/target/mode/fan are commands ONLY when ENABLE_WRITES=true after commissioning. + * Safety: read-only by default; no serial auto-configuration; no output on boot. + * Auxiliary readings vary with controller profile, see README. + */ +var SLAVE_ID = 1; // Loaded from persisted managed VC on startup. +var CLIENT_ID = 0; +var ENABLE_WRITES = false; +var POLL_MS = 3500; +var EXPECTED_BAUD = 9600; +var PROFILE = 'b32'; +var VC = {}; +var REG = { room:0, water:1, actual:8, motor:15, alarm:105, program:201, target:231, mode:233 }; +var scan = PROFILE === 'b32' ? [201,231,233,0,1,15,105] : [201,231,233,0,8,105]; +var i = 0, busy = false, active = false, online = false, initial = true; +var reg = {}, desired = {}, failCount = 0, lastSuccess = 0; + +function log(s) { print('[INNOVA ' + PROFILE + '] ' + s); } +function validSlaveId(id) { + return typeof id === 'number' && id === Math.floor(id) && id >= 1 && id <= 247; +} +function selectSlaveId(id) { + // Selects the remote target; does not readdress the remote controller. + if (!validSlaveId(id)) { + log('Rejected invalid Modbus Slave ID: ' + String(id)); + VC.slaveId.setValue(SLAVE_ID); + return; + } + if (id === SLAVE_ID) return; + var previous = SLAVE_ID; + SLAVE_ID = id; + // Prevent the previous slave's cached state from authorizing new commands. + reg = {}; desired = {}; online = false; initial = true; + failCount = 0; lastSuccess = 0; i = 0; + log('Modbus slave changed ' + previous + ' -> ' + id); + fault('Selecting Modbus slave ' + id + '; awaiting fresh readings'); + // A request already in flight cannot be cancelled, but its reply is + // discarded by the requestSlave/commandSlave guard below. + if (active && !busy) readNext(); +} +function listenSlaveId() { + VC.slaveId.on('change', function(ev) { + if (ev.source && String(ev.source).indexOf('script:') === 0) return; + selectSlaveId(ev.value); + }); +} +function put(role, val) { + if (!VC[role] || val === undefined || val === null) return; + desired[role] = val; + if (VC[role].getValue() !== val) VC[role].setValue(val); +} +function fault(msg) { put('alarm', msg); } +function valueToMode(v) { return v === 3 ? 'heating' : v === 5 ? 'cooling' : v === 0 ? 'auto' : 'unknown'; } +function valueToFan(v) { + v &= 7; + return v === 0 ? 'auto' : v === 1 ? 'silent' : v === 2 ? 'night' : v === 3 ? 'maximum' : 'unknown'; +} +function publishRegister(addr, n) { + if (typeof n !== 'number' || n < 0 || n > 65535) return; + reg[addr] = n; + if (addr === 201) { put('power', (n & 128) === 0); put('fan', valueToFan(n)); } + if (addr === 231) put('target', n / 10); + if (addr === 233) put('mode', valueToMode(n)); + if (addr === 0 && n <= 1000) put('room', n / 10); + if (addr === 1 && n <= 1000 && PROFILE === 'b32') put('aux', n / 10); + if (addr === 8 && PROFILE !== 'b32' && n <= 1000) put('aux', n / 10); + if (addr === 15 && PROFILE === 'b32') put('motor', n); + if (addr === 105) { put('alarm', n === 0 ? 'No alarms' : 'Alarm flags: ' + String(n)); } + if (addr === 201 && PROFILE !== 'b32') put('motor', n); +} +function readNext() { + if (!active || busy) return; + busy = true; + var addr = scan[i], requestSlave = SLAVE_ID; + Shelly.call('MbRtuClient.ReadHoldingRegisters', { id:CLIENT_ID, sid:requestSlave, addr:addr, qty:1 }, function(res, code) { + busy = false; + if (requestSlave !== SLAVE_ID) { i = 0; return; } + if (code === 0 && res && res.values && res.values.length === 1) { + failCount = 0; + lastSuccess = Shelly.getUptimeMs(); + online = true; + publishRegister(addr, res.values[0]); + // Accept commands only after a fresh snapshot from the selected slave. + if (reg[201] !== undefined && reg[231] !== undefined && reg[233] !== undefined) initial = false; + } else { + failCount++; + if (failCount >= 3) { + if (online) log('Modbus offline'); + online = false; + fault('Modbus communication error, slave ' + SLAVE_ID); + } + } + i = (i + 1) % scan.length; + }); +} +function verifiedCommand(role, val) { + if (initial || !ENABLE_WRITES || !online || busy) { if (desired[role] !== undefined) put(role, desired[role]); return; } + if (reg[201] === undefined || reg[231] === undefined || reg[233] === undefined) return; + var addr = 0, data = 0; + if (role === 'target') { + if (typeof val !== 'number' || val < 16 || val > 30 || Math.round(val*10) !== val*10) return; + addr = 231; data = Math.round(val * 10); + } else if (role === 'mode') { + if (val !== 'heating' && val !== 'cooling') return; + addr = 233; data = val === 'heating' ? 3 : 5; + } else if (role === 'power') { + if (typeof val !== 'boolean') return; + addr = 201; data = val ? (reg[201] & (65535 ^ 128)) : (reg[201] | 128); + } else if (role === 'fan') { + if (val !== 'auto' && val !== 'silent' && val !== 'night' && val !== 'maximum') return; + var mode = val === 'auto' ? 0 : val === 'silent' ? 1 : val === 'night' ? 2 : 3; + addr = 201; data = (reg[201] & (65535 ^ 7)) | mode; + } else return; + busy = true; + var commandSlave = SLAVE_ID; + Shelly.call('MbRtuClient.WriteSingleRegister', {id:CLIENT_ID, sid:commandSlave, addr:addr, value:data}, function(res, code) { + busy = false; + // An already-sent write cannot be undone; ignore a stale callback. + if (commandSlave !== SLAVE_ID) { i = 0; return; } + if (code !== 0) { log('Write failed at ' + addr + ': ' + code); fault('Write failure'); } + // Never declare success until a fresh read confirms the device's state. + i = 0; + }); +} +function listen(role) { + VC[role].on('change', function(ev) { + if (ev.source && String(ev.source).indexOf('script:') === 0) return; + if (desired[role] === ev.value) return; + verifiedCommand(role, ev.value); + }); +} + +function init() { + var roles = ['power','room','target','mode','fan','aux','motor','alarm','slaveId']; + var j; + for (j=0; j= 1 && id <= 247; +} +function selectSlaveId(id) { + // Selects the remote target; does not readdress the remote controller. + if (!validSlaveId(id)) { + log('Rejected invalid Modbus Slave ID: ' + String(id)); + VC.slaveId.setValue(SLAVE_ID); + return; + } + if (id === SLAVE_ID) return; + var previous = SLAVE_ID; + SLAVE_ID = id; + // Prevent the previous slave's cached state from authorizing new commands. + reg = {}; desired = {}; online = false; initial = true; + failCount = 0; lastSuccess = 0; i = 0; + log('Modbus slave changed ' + previous + ' -> ' + id); + fault('Selecting Modbus slave ' + id + '; awaiting fresh readings'); + // A request already in flight cannot be cancelled, but its reply is + // discarded by the requestSlave/commandSlave guard below. + if (active && !busy) readNext(); +} +function listenSlaveId() { + VC.slaveId.on('change', function(ev) { + if (ev.source && String(ev.source).indexOf('script:') === 0) return; + selectSlaveId(ev.value); + }); +} +function put(role, val) { + if (!VC[role] || val === undefined || val === null) return; + desired[role] = val; + if (VC[role].getValue() !== val) VC[role].setValue(val); +} +function fault(msg) { put('alarm', msg); } +function valueToMode(v) { return v === 3 ? 'heating' : v === 5 ? 'cooling' : v === 0 ? 'auto' : 'unknown'; } +function valueToFan(v) { + v &= 7; + return v === 0 ? 'auto' : v === 1 ? 'silent' : v === 2 ? 'night' : v === 3 ? 'maximum' : 'unknown'; +} +function publishRegister(addr, n) { + if (typeof n !== 'number' || n < 0 || n > 65535) return; + reg[addr] = n; + if (addr === 201) { put('power', (n & 128) === 0); put('fan', valueToFan(n)); } + if (addr === 231) put('target', n / 10); + if (addr === 233) put('mode', valueToMode(n)); + if (addr === 0 && n <= 1000) put('room', n / 10); + if (addr === 1 && n <= 1000 && PROFILE === 'b32') put('aux', n / 10); + if (addr === 8 && PROFILE !== 'b32' && n <= 1000) put('aux', n / 10); + if (addr === 15 && PROFILE === 'b32') put('motor', n); + if (addr === 105) { put('alarm', n === 0 ? 'No alarms' : 'Alarm flags: ' + String(n)); } + if (addr === 201 && PROFILE !== 'b32') put('motor', n); +} +function readNext() { + if (!active || busy) return; + busy = true; + var addr = scan[i], requestSlave = SLAVE_ID; + Shelly.call('MbRtuClient.ReadHoldingRegisters', { id:CLIENT_ID, sid:requestSlave, addr:addr, qty:1 }, function(res, code) { + busy = false; + if (requestSlave !== SLAVE_ID) { i = 0; return; } + if (code === 0 && res && res.values && res.values.length === 1) { + failCount = 0; + lastSuccess = Shelly.getUptimeMs(); + online = true; + publishRegister(addr, res.values[0]); + // Accept commands only after a fresh snapshot from the selected slave. + if (reg[201] !== undefined && reg[231] !== undefined && reg[233] !== undefined) initial = false; + } else { + failCount++; + if (failCount >= 3) { + if (online) log('Modbus offline'); + online = false; + fault('Modbus communication error, slave ' + SLAVE_ID); + } + } + i = (i + 1) % scan.length; + }); +} +function verifiedCommand(role, val) { + if (initial || !ENABLE_WRITES || !online || busy) { if (desired[role] !== undefined) put(role, desired[role]); return; } + if (reg[201] === undefined || reg[231] === undefined || reg[233] === undefined) return; + var addr = 0, data = 0; + if (role === 'target') { + if (typeof val !== 'number' || val < 16 || val > 30 || Math.round(val*10) !== val*10) return; + addr = 231; data = Math.round(val * 10); + } else if (role === 'mode') { + if (val !== 'heating' && val !== 'cooling') return; + addr = 233; data = val === 'heating' ? 3 : 5; + } else if (role === 'power') { + if (typeof val !== 'boolean') return; + addr = 201; data = val ? (reg[201] & (65535 ^ 128)) : (reg[201] | 128); + } else if (role === 'fan') { + if (val !== 'auto' && val !== 'silent' && val !== 'night' && val !== 'maximum') return; + var mode = val === 'auto' ? 0 : val === 'silent' ? 1 : val === 'night' ? 2 : 3; + addr = 201; data = (reg[201] & (65535 ^ 7)) | mode; + } else return; + busy = true; + var commandSlave = SLAVE_ID; + Shelly.call('MbRtuClient.WriteSingleRegister', {id:CLIENT_ID, sid:commandSlave, addr:addr, value:data}, function(res, code) { + busy = false; + // An already-sent write cannot be undone; ignore a stale callback. + if (commandSlave !== SLAVE_ID) { i = 0; return; } + if (code !== 0) { log('Write failed at ' + addr + ': ' + code); fault('Write failure'); } + // Never declare success until a fresh read confirms the device's state. + i = 0; + }); +} +function listen(role) { + VC[role].on('change', function(ev) { + if (ev.source && String(ev.source).indexOf('script:') === 0) return; + if (desired[role] === ev.value) return; + verifiedCommand(role, ev.value); + }); +} + +function init() { + var roles = ['power','room','target','mode','fan','aux','motor','alarm','slaveId']; + var j; + for (j=0; j= 1 && id <= 247; +} +function selectSlaveId(id) { + // Selects the remote target; does not readdress the remote controller. + if (!validSlaveId(id)) { + log('Rejected invalid Modbus Slave ID: ' + String(id)); + VC.slaveId.setValue(SLAVE_ID); + return; + } + if (id === SLAVE_ID) return; + var previous = SLAVE_ID; + SLAVE_ID = id; + // Prevent the previous slave's cached state from authorizing new commands. + reg = {}; desired = {}; online = false; initial = true; + failCount = 0; lastSuccess = 0; i = 0; + log('Modbus slave changed ' + previous + ' -> ' + id); + fault('Selecting Modbus slave ' + id + '; awaiting fresh readings'); + // A request already in flight cannot be cancelled, but its reply is + // discarded by the requestSlave/commandSlave guard below. + if (active && !busy) readNext(); +} +function listenSlaveId() { + VC.slaveId.on('change', function(ev) { + if (ev.source && String(ev.source).indexOf('script:') === 0) return; + selectSlaveId(ev.value); + }); +} +function put(role, val) { + if (!VC[role] || val === undefined || val === null) return; + desired[role] = val; + if (VC[role].getValue() !== val) VC[role].setValue(val); +} +function fault(msg) { put('alarm', msg); } +function valueToMode(v) { return v === 3 ? 'heating' : v === 5 ? 'cooling' : v === 0 ? 'auto' : 'unknown'; } +function valueToFan(v) { + v &= 7; + return v === 0 ? 'auto' : v === 1 ? 'silent' : v === 2 ? 'night' : v === 3 ? 'maximum' : 'unknown'; +} +function publishRegister(addr, n) { + if (typeof n !== 'number' || n < 0 || n > 65535) return; + reg[addr] = n; + if (addr === 201) { put('power', (n & 128) === 0); put('fan', valueToFan(n)); } + if (addr === 231) put('target', n / 10); + if (addr === 233) put('mode', valueToMode(n)); + if (addr === 0 && n <= 1000) put('room', n / 10); + if (addr === 1 && n <= 1000 && PROFILE === 'b32') put('aux', n / 10); + if (addr === 8 && PROFILE !== 'b32' && n <= 1000) put('aux', n / 10); + if (addr === 15 && PROFILE === 'b32') put('motor', n); + if (addr === 105) { put('alarm', n === 0 ? 'No alarms' : 'Alarm flags: ' + String(n)); } + if (addr === 201 && PROFILE !== 'b32') put('motor', n); +} +function readNext() { + if (!active || busy) return; + busy = true; + var addr = scan[i], requestSlave = SLAVE_ID; + Shelly.call('MbRtuClient.ReadHoldingRegisters', { id:CLIENT_ID, sid:requestSlave, addr:addr, qty:1 }, function(res, code) { + busy = false; + if (requestSlave !== SLAVE_ID) { i = 0; return; } + if (code === 0 && res && res.values && res.values.length === 1) { + failCount = 0; + lastSuccess = Shelly.getUptimeMs(); + online = true; + publishRegister(addr, res.values[0]); + // Accept commands only after a fresh snapshot from the selected slave. + if (reg[201] !== undefined && reg[231] !== undefined && reg[233] !== undefined) initial = false; + } else { + failCount++; + if (failCount >= 3) { + if (online) log('Modbus offline'); + online = false; + fault('Modbus communication error, slave ' + SLAVE_ID); + } + } + i = (i + 1) % scan.length; + }); +} +function verifiedCommand(role, val) { + if (initial || !ENABLE_WRITES || !online || busy) { if (desired[role] !== undefined) put(role, desired[role]); return; } + if (reg[201] === undefined || reg[231] === undefined || reg[233] === undefined) return; + var addr = 0, data = 0; + if (role === 'target') { + if (typeof val !== 'number' || val < 16 || val > 30 || Math.round(val*10) !== val*10) return; + addr = 231; data = Math.round(val * 10); + } else if (role === 'mode') { + if (val !== 'heating' && val !== 'cooling') return; + addr = 233; data = val === 'heating' ? 3 : 5; + } else if (role === 'power') { + if (typeof val !== 'boolean') return; + addr = 201; data = val ? (reg[201] & (65535 ^ 128)) : (reg[201] | 128); + } else if (role === 'fan') { + if (val !== 'auto' && val !== 'silent' && val !== 'night' && val !== 'maximum') return; + var mode = val === 'auto' ? 0 : val === 'silent' ? 1 : val === 'night' ? 2 : 3; + addr = 201; data = (reg[201] & (65535 ^ 7)) | mode; + } else return; + busy = true; + var commandSlave = SLAVE_ID; + Shelly.call('MbRtuClient.WriteSingleRegister', {id:CLIENT_ID, sid:commandSlave, addr:addr, value:data}, function(res, code) { + busy = false; + // An already-sent write cannot be undone; ignore a stale callback. + if (commandSlave !== SLAVE_ID) { i = 0; return; } + if (code !== 0) { log('Write failed at ' + addr + ': ' + code); fault('Write failure'); } + // Never declare success until a fresh read confirms the device's state. + i = 0; + }); +} +function listen(role) { + VC[role].on('change', function(ev) { + if (ev.source && String(ev.source).indexOf('script:') === 0) return; + if (desired[role] === ev.value) return; + verifiedCommand(role, ev.value); + }); +} + +function init() { + var roles = ['power','room','target','mode','fan','aux','motor','alarm','slaveId']; + var j; + for (j=0; j