Skip to content
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
45f8a26
refactor(Midea): switch to firmware-managed VCs with dynamic role IDs
drHouse-gif Oct 12, 2026
8d76737
test(Midea): exercise firmware-managed roles and dynamic IDs
drHouse-gif Oct 12, 2026
ed5e387
test(Midea): parse first-line managed metadata correctly
drHouse-gif Oct 12, 2026
ec7ddaa
docs(Midea): managed VC lifecycle, roles and migration guidance
drHouse-gif Oct 12, 2026
c8c5d08
docs(Midea): managed VC lifecycle, roles and migration guidance
drHouse-gif Oct 12, 2026
2fcefd4
fix(Midea): conform managed-VC init lifecycle to CI policy
drHouse-gif Oct 12, 2026
feea592
fix(Midea): initialize managed boolean controls and status explicitly
drHouse-gif Oct 12, 2026
46509e6
docs(Midea): require explicit firmware-managed VC support
drHouse-gif Oct 12, 2026
0a59a04
docs(Midea): require explicit firmware-managed VC support
drHouse-gif Oct 12, 2026
50c2070
feat(Midea): replace Silent Mode with persisted managed Modbus Slave ID
drHouse-gif Oct 12, 2026
b35be41
test(Midea): managed Slave ID selection, safe retargeting and stale-r…
drHouse-gif Oct 12, 2026
2d5660f
docs(Midea): describe persisted Slave ID component and safe controlle…
drHouse-gif Oct 12, 2026
552fe5e
docs(Midea): describe persisted Slave ID component and safe controlle…
drHouse-gif Oct 12, 2026
18fb2e6
chore: keep PR limited to Shelly-installable Modbus bridge script
drHouse-gif Oct 12, 2026
872a72d
chore: keep PR limited to Shelly-installable Modbus bridge script
drHouse-gif Oct 12, 2026
145dfa2
chore: keep PR limited to Shelly-installable Modbus bridge script
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
191 changes: 104 additions & 87 deletions modbus/Midea/midea-clivet-r32-pro-modbus-vc.shelly.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* @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
* Components. Requires firmware 2.0.0+ and Shelly Pro Modbus Add-on.
* @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
*/
Expand All @@ -10,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).
Expand All @@ -23,23 +24,23 @@
* 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 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 (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
*/

// CONFIGURATION
var CONFIG = {
profile: 'midea-r32-171h120f',
serialId: 100,
slaveId: 1, // Example only: use the address configured on the wired controller.
baud: 9600,
format: '8N1',
enableWrites: false,
Expand All @@ -53,18 +54,17 @@ var CONFIG = {
dhwMin: 20,
dhwMax: 60,
writeFunction: 6, // 6: WriteSingleRegister; 16: one-word WriteHoldingRegisters.
vcBase: 220,
pollMs: 10000,
retryMs: 10000,
gapMs: 100,
settleMs: 700
};

// Five controls followed by three temperatures and a status text.
// 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],
Expand All @@ -85,20 +85,50 @@ var publishIndex = 0;
var lastFault = '';
var lastVersion = null;
var lastNotice = '';
var components = [];
var vcIds = [];
var managedVc = [];
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
Expand All @@ -113,21 +143,22 @@ 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));
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() {
Expand All @@ -141,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';
Expand All @@ -160,8 +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.vcBase) || CONFIG.vcBase < 200 || CONFIG.vcBase > 291) return 'vcBase must be 200..291';
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';
Expand All @@ -174,70 +204,43 @@ 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;
}
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); });
});
// 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;
}
}
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 !== (i === 2 ? 'Modbus Slave ID' : '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;
}
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);
}

// SERIAL CONFIGURATION AND PHYSICAL READS
Expand Down Expand Up @@ -275,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; }
Expand All @@ -296,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';
Expand All @@ -321,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) {
Expand Down Expand Up @@ -352,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);
Expand Down Expand Up @@ -404,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);
Expand All @@ -418,10 +429,16 @@ function publishNext() {
}

// INITIALIZATION
var configError = configurationError();
if (configError !== null) log('STOPPED: ' + configError);
else {
log('R32 profile; RTU ' + CONFIG.baud + ' ' + CONFIG.format + '; server ' + CONFIG.slaveId);
if (CONFIG.enableWrites && CONFIG.expectedControllerVersion === null) log('Set expectedControllerVersion after reading register 131; writes remain blocked.');
later(function() { inspectComponents(0); }, 1);
function init() {
var configError = configurationError();
if (configError !== null) {
log('STOPPED: ' + configError);
return;
}
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.');
}
later(bindManagedComponents, 1);
}
init();
Loading