Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9869ff0
Add experimental INNOVA B32 Modbus RTU example
drHouse-gif Oct 9, 2026
9ff5db5
Add experimental INNOVA ESE645 Modbus RTU example
drHouse-gif Oct 9, 2026
c6a91c4
Add experimental INNOVA ESE648 Modbus RTU example
drHouse-gif Oct 9, 2026
43414bc
Document INNOVA profiles and community test limitations
drHouse-gif Oct 9, 2026
950b450
Document contributor-confirmed hardware tests for all INNOVA profiles
drHouse-gif Oct 9, 2026
b615964
Fix managed-VC init entrypoint for INNOVA B32
drHouse-gif Oct 9, 2026
b76ccff
Fix managed-VC init entrypoint for INNOVA ESE645
drHouse-gif Oct 9, 2026
c97e141
Fix managed-VC init entrypoint for INNOVA ESE648
drHouse-gif Oct 9, 2026
f21adb7
refactor: self-provision INNOVA virtual components like Midea bridge
drHouse-gif Oct 11, 2026
2136629
refactor: self-provision INNOVA virtual components like Midea bridge
drHouse-gif Oct 11, 2026
3b4dd41
refactor: self-provision INNOVA virtual components like Midea bridge
drHouse-gif Oct 11, 2026
c7b210c
docs: document runtime VC provisioning and safe migration
drHouse-gif Oct 11, 2026
07c3055
refactor: use managed VCs for INNOVA instead of Virtual.Add
drHouse-gif Oct 12, 2026
3867652
refactor: use managed VCs for INNOVA instead of Virtual.Add
drHouse-gif Oct 12, 2026
41101d4
refactor: use managed VCs for INNOVA instead of Virtual.Add
drHouse-gif Oct 12, 2026
4d473b1
docs: restore managed INNOVA VC lifecycle and migration
drHouse-gif Oct 12, 2026
47430dc
feat(INNOVA): managed persisted Modbus slave ID replaces online VC
drHouse-gif Oct 12, 2026
53ed883
feat(INNOVA): managed persisted Modbus slave ID replaces online VC
drHouse-gif Oct 12, 2026
589a7bc
feat(INNOVA): managed persisted Modbus slave ID replaces online VC
drHouse-gif Oct 12, 2026
a4be04e
docs(INNOVA): persisted managed Modbus address selection and safe ret…
drHouse-gif Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions modbus/README-innova-airleaf.md
Original file line number Diff line number Diff line change
@@ -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://<SHELLY_IP>/rpc/Shelly.GetDeviceInfo`
- `http://<SHELLY_IP>/rpc/Serial.GetConfig?id=0`
- `http://<SHELLY_IP>/rpc/Shelly.GetComponents?dynamic_only=true`
- `http://<SHELLY_IP>/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/)
174 changes: 174 additions & 0 deletions modbus/innova-airleaf-b32.shelly.js
Original file line number Diff line number Diff line change
@@ -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<roles.length; j++) {
VC[roles[j]] = Script.getVcHandle(roles[j]);
if (!VC[roles[j]]) { log('Managed VC missing: ' + roles[j]); return; }
}
if (!validSlaveId(VC.slaveId.getValue())) {
log('Invalid saved Modbus Slave ID; valid range is integer 1..247');
fault('Invalid Modbus Slave ID (1..247)');
return;
}
SLAVE_ID = VC.slaveId.getValue();
listenSlaveId();
fault('Waiting for Modbus slave ' + SLAVE_ID);
Shelly.call('Serial.GetConfig', {id:CLIENT_ID}, function(cfg, err) {
if (err !== 0 || !cfg || cfg.mode !== 'mb_client' || !cfg.serial ||
cfg.serial.baud !== EXPECTED_BAUD || cfg.serial.format !== '8N1') {
log('Serial must be mb_client, 9600 8N1; configuration NOT changed');
fault('Serial configuration mismatch');
return;
}
listen('power'); listen('target'); listen('mode'); listen('fan');
active = true;
log('Started READ-ONLY=' + String(!ENABLE_WRITES) + ', slave=' + SLAVE_ID);
Timer.set(POLL_MS, true, readNext);
readNext();
});
}
init();
Loading
Loading