diff --git a/CHANGELOG.md b/CHANGELOG.md index 240f98f..9ae922c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,33 @@ All notable changes to this project will be documented in this file. +## 2026-03 +- Add screenshot sections with descriptive captions to Deye, JK200, and ST802 MODBUS README files +- Promote `the_pill/MODBUS/Deye/deye.shelly.js` to production and fix its header `@link` path +- Promote `the_pill/MODBUS/Deye/deye_vc.shelly.js` to production and fix its header `@link` path +- Promote `the_pill/MODBUS/LinkedGo/ST802/st802_bms.shelly.js` to production and update its header `@link` to the ALLTERCO repository path +- Promote `the_pill/MODBUS/LinkedGo/ST802/st802_bms_vc.shelly.js` to production and update its header `@link` to the ALLTERCO repository path +- Promote `the_pill/MODBUS/JKESS/JK200-MBS/jk200.shelly.js` to production and fix its header `@link` path +- Promote `the_pill/MODBUS/JKESS/JK200-MBS/jk200_vc.shelly.js` to production and fix its header `@link` path +- Change JK200 VC `Pack Current` unit from `mA` to `A` and scale value conversion in `jk200_vc.shelly.js`; update `skills/modbus-vc-deploy.md` JK200 VC table and creation example +- Change JK200 VC `Pack Power` unit from `mW` to `W` and scale value conversion in `jk200_vc.shelly.js`; update `skills/modbus-vc-deploy.md` JK200 VC table and creation example +- Change JK200 VC `Pack Voltage` unit from `mV` to `V` and scale value conversion in `jk200_vc.shelly.js`; update `skills/modbus-vc-deploy.md` VC creation table accordingly +- Update `skills/modbus-vc-deploy.md` to require including all created VCs in `Group.Set` membership so grouped components are visible in Shelly UI +- Add `skills/js-to-shelly-standardize.md` for converting non-standard `.js` files into repository-compliant `.shelly.js` scripts with required headers and doc updates +- Standardize BLE open windows script by renaming `ble/open_windows.js` to `ble/ble-open-windows.shelly.js`, adding standard headers, and aligning code style/structure +- Add `skills/git-commit-merge-cleanup.md` documenting the team Git flow and required local/remote `feature/*` branch cleanup after merges +- Add `http-integrations/finance-yahoo/README.md` with Problem (The Story) and Persona sections for the Yahoo Finance stock monitor example +- Rename `http-integrations/finance-yahoo/stock_monitor 2.js` to `http-integrations/finance-yahoo/stock-monitor.shelly.js` to follow script naming standards +- Remove legacy SDS011 setup/UI scripts and rename `uart_lib_SDS011.js` to `the_pill/SDS011/sds011-vc-cycle.shelly.js`; add standard metadata headers and refresh SDS011 README references +- Add LinkedGo R290 A/W thermal pump MODBUS-RTU example for The Pill (`the_pill/MODBUS/LinkedGo/r290_aw_thermal_pump.shelly.js`) with FC03 polling, FC06 control helpers, and RS485 wiring notes +- Update all `the_pill/MODBUS/**/README.md` files with RS485-for-The-Pill wiring guidance plus `Problem (The Story)` and `Persona` sections; add missing README files for `JKESS`, `LinkedGo`, `LinkedGo/ST802`, and `LinkedGo/R290` +- Add skill document `skills/manifest-verify-tools.md` for strict `tools/`-driven manifest/index verification and regeneration workflow + ## 2026-02 +- Mark LinkedGo ST802 BMS client as production; add @link, POLL_MODE, POLL_FAN_SPEED, POLL_HUMIDITY flags +- Add LinkedGo ST802 Youth Smart Thermostat Modbus RTU BMS client (`the_pill/MODBUS/LinkedGo/ST802/st802_bms.shelly.js`) with enable-flag mechanism for 8 BMS scenarios +- Add Shelly script deploy and monitor skill document (`skills/shelly-script-deploy.md`) +- Move JK200 BMS script into JKESS/JK200-MBS namespace - Mark YS-IRTM scripts as production; add all 6 to manifest; remove Under Development banner from README - Add JK200 BMS MODBUS-RTU reader (`the_pill/MODBUS/JK200-MBS`) with README - Mark Deye SG02LP1 MODBUS-RTU scripts as production; fix @link URLs; add to manifest; add README diff --git a/SHELLY_MJS.md b/SHELLY_MJS.md index 67538e8..9ade4da 100644 --- a/SHELLY_MJS.md +++ b/SHELLY_MJS.md @@ -404,18 +404,30 @@ switch-input/shelly2p-domo-coverfix.shelly.js: Shelly Plus 2PM cover fix for Dom Simple fix for outgoing Domoticz MQTTAD command 'GoToPosition'. Shelly firmware 0.x supported. Developed for ShellyTeacher4Domo. -the_pill/MODBUS/Deye/the_pill_mbsa_deye.shelly.js: Deye SG02LP1 MODBUS-RTU +the_pill/MODBUS/Deye/deye.shelly.js: Deye SG02LP1 MODBUS-RTU === -MODBUS-RTU example for reading Deye SG02LP1 solar inverter parameters over UART using the MODBUS-RTU master library. +MODBUS-RTU example for reading Deye SG02LP1 solar inverter -the_pill/MODBUS/Deye/the_pill_mbsa_deye_vc.shelly.js: Deye SG02LP1 MODBUS-RTU + Virtual Components +the_pill/MODBUS/Deye/deye_vc.shelly.js: Deye SG02LP1 MODBUS-RTU + Virtual Components === -MODBUS-RTU reader for Deye SG02LP1 solar inverter with Virtual Component updates. Reads parameters over UART (RS485) and pushes values to user-defined virtual number components. +MODBUS-RTU reader for Deye SG02LP1 solar inverter with -the_pill/MODBUS/JK200-MBS/the_pill_mbsa_jk200.shelly.js: JK200 BMS MODBUS-RTU Reader +the_pill/MODBUS/JKESS/JK200-MBS/jk200.shelly.js: JK200 BMS MODBUS-RTU Reader === MODBUS-RTU reader for Jikong JK-PB series BMS over RS485. +the_pill/MODBUS/JKESS/JK200-MBS/jk200_vc.shelly.js: JK200 BMS MODBUS-RTU Reader + Virtual Components +=== +MODBUS-RTU reader for Jikong JK-PB series BMS over RS485 with + +the_pill/MODBUS/LinkedGo/ST802/st802_bms.shelly.js: LinkedGo ST802 Thermostat - BMS Modbus RTU Client +=== +Modbus RTU master that simulates BMS (Building Management System) + +the_pill/MODBUS/LinkedGo/ST802/st802_bms_vc.shelly.js: LinkedGo ST802 Thermostat - BMS Modbus RTU Client + Virtual Components +=== +Modbus RTU master that simulates BMS commands for the LinkedGo + the_pill/UART/uart_test.shelly.js: UART test === Simple UART loopback test that sends periodic messages and prints received data. diff --git a/ble/README.md b/ble/README.md index 1986237..74a5bbb 100644 --- a/ble/README.md +++ b/ble/README.md @@ -23,6 +23,6 @@ Use these examples to connect Shelly devices to nearby BLE/BLU sensors and butto - `ble-shelly-dw.shelly.js` - `ble-shelly-motion.shelly.js` - `ble-shelly-scanner.shelly.js` +- `ble-open-windows.shelly.js` - `hue-lights-control.shelly.js` - `universal-blu-to-mqtt.shelly.js` - diff --git a/ble/ble-open-windows.shelly.js b/ble/ble-open-windows.shelly.js new file mode 100644 index 0000000..609b76c --- /dev/null +++ b/ble/ble-open-windows.shelly.js @@ -0,0 +1,264 @@ +/** + * @title BLE open windows monitor + * @description Scans Shelly BLU DoorWindow advertisements, tracks open windows, + * and updates Virtual Components with aggregate open-state information. + * @status under development + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/ble/ble-open-windows.shelly.js + */ + +/** + * BLE Open Windows Monitor + * + * Watches configured BLU DoorWindow devices and publishes: + * - boolean:200 true if any configured window is open + * - number:200 count of open windows + * - text:200 last update timestamp + * - text:201 most recently opened window name (or None) + */ + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +const DEVICES = { + // Replace sample MAC addresses with your real BLU DoorWindow addresses. + 'xx:xx:xx:xx:xx:01': { res: {}, name: 'Living Room Back Window', date: null }, + 'xx:xx:xx:xx:xx:02': { res: {}, name: 'Children Room Front Window', date: null }, +}; + +const COMPONENT_ANY_OPEN = 'boolean:200'; +const COMPONENT_OPEN_COUNT = 'number:200'; +const COMPONENT_LAST_UPDATE = 'text:200'; +const COMPONENT_LAST_OPEN_NAME = 'text:201'; + +const BTHOME_SVC_ID_STR = 'fcd2'; + +// ============================================================================ +// HELPERS +// ============================================================================ + +function setValue(component, value) { + const handle = Virtual.getHandle(component); + if (handle) { + handle.setValue(value); + } +} + +function getTimestamp(date) { + return date.toString().split('GMT')[0]; +} + +function getByteSize(type) { + if (type === uint8 || type === int8) { + return 1; + } + if (type === uint16 || type === int16) { + return 2; + } + if (type === uint24 || type === int24) { + return 3; + } + return 255; +} + +// ============================================================================ +// EVENT PROCESSING +// ============================================================================ + +function onEvent(res) { + const addr = res.addr; + const device = DEVICES[addr]; + if (!device) { + return; + } + + const date = new Date(); + device.res = res; + device.date = date; + + let isOpenWindow = false; + let openWindowsCount = 0; + let lastOpenWindowDevice = null; + + for (const dev in DEVICES) { + const trackedDevice = DEVICES[dev]; + if (trackedDevice.res.window === 1) { + openWindowsCount += 1; + isOpenWindow = true; + if (!lastOpenWindowDevice || lastOpenWindowDevice.date <= trackedDevice.date) { + lastOpenWindowDevice = trackedDevice; + } + } + } + + setValue(COMPONENT_ANY_OPEN, isOpenWindow); + setValue(COMPONENT_OPEN_COUNT, openWindowsCount); + setValue(COMPONENT_LAST_UPDATE, getTimestamp(date)); + setValue(COMPONENT_LAST_OPEN_NAME, lastOpenWindowDevice ? lastOpenWindowDevice.name : 'None'); +} + +function scanCB(ev, res) { + if ( + ev !== BLE.Scanner.SCAN_RESULT || + !res || + !DEVICES[res.addr] || + !res.service_data || + !res.service_data[BTHOME_SVC_ID_STR] + ) { + return; + } + + const bthomeData = ShellyBLUParser.getData(res); + if (bthomeData) { + onEvent(bthomeData); + return; + } + + print('Failed to parse BTH data:', JSON.stringify(res)); +} + +function startBleScan() { + const success = BLE.Scanner.Start( + { duration_ms: BLE.Scanner.INFINITE_SCAN, active: false }, + scanCB + ); + print('BLE scanner running:', success !== false); +} + +function init() { + const bleConfig = Shelly.getComponentConfig('ble'); + if (bleConfig.enable === false) { + print('Error: BLE not enabled'); + return; + } + + startBleScan(); +} + +// ============================================================================ +// BTHOME PARSER +// ============================================================================ + +const uint8 = 0; +const int8 = 1; +const uint16 = 2; +const int16 = 3; +const uint24 = 4; +const int24 = 5; + +const BTH = []; +BTH[0x00] = { n: 'pid', t: uint8 }; +BTH[0x01] = { n: 'battery', t: uint8, u: '%' }; +BTH[0x02] = { n: 'temperature', t: int16, f: 0.01, u: 'tC' }; +BTH[0x03] = { n: 'humidity', t: uint16, f: 0.01, u: '%' }; +BTH[0x05] = { n: 'illuminance', t: uint24, f: 0.01 }; +BTH[0x21] = { n: 'motion', t: uint8 }; +BTH[0x2d] = { n: 'window', t: uint8 }; +BTH[0x3a] = { n: 'button', t: uint8 }; +BTH[0x3f] = { n: 'rotation', t: int16, f: 0.1 }; + +const ShellyBLUParser = { + getData: function(res) { + const result = BTHomeDecoder.unpack(res.service_data[BTHOME_SVC_ID_STR]); + if (result) { + result.addr = res.addr; + result.rssi = res.rssi; + } + return result; + }, +}; + +const BTHomeDecoder = { + utoi: function(num, bitsz) { + const mask = 1 << (bitsz - 1); + return num & mask ? num - (1 << bitsz) : num; + }, + getUInt8: function(buffer) { + return buffer.at(0); + }, + getInt8: function(buffer) { + return this.utoi(this.getUInt8(buffer), 8); + }, + getUInt16LE: function(buffer) { + return 0xffff & ((buffer.at(1) << 8) | buffer.at(0)); + }, + getInt16LE: function(buffer) { + return this.utoi(this.getUInt16LE(buffer), 16); + }, + getUInt24LE: function(buffer) { + return 0x00ffffff & ((buffer.at(2) << 16) | (buffer.at(1) << 8) | buffer.at(0)); + }, + getInt24LE: function(buffer) { + return this.utoi(this.getUInt24LE(buffer), 24); + }, + getBufValue: function(type, buffer) { + if (buffer.length < getByteSize(type)) { + return null; + } + + let res = null; + if (type === uint8) { + res = this.getUInt8(buffer); + } + if (type === int8) { + res = this.getInt8(buffer); + } + if (type === uint16) { + res = this.getUInt16LE(buffer); + } + if (type === int16) { + res = this.getInt16LE(buffer); + } + if (type === uint24) { + res = this.getUInt24LE(buffer); + } + if (type === int24) { + res = this.getInt24LE(buffer); + } + return res; + }, + unpack: function(buffer) { + if (typeof buffer !== 'string' || buffer.length === 0) { + return null; + } + + const result = {}; + const dib = buffer.at(0); + result.encryption = dib & 0x1 ? true : false; + result.BTHome_version = dib >> 5; + + // Encrypted data is not handled. + if (result.BTHome_version !== 2 || result.encryption) { + return null; + } + + buffer = buffer.slice(1); + while (buffer.length > 0) { + const bth = BTH[buffer.at(0)]; + if (typeof bth === 'undefined') { + return null; + } + + buffer = buffer.slice(1); + let value = this.getBufValue(bth.t, buffer); + if (value === null) { + return null; + } + + if (typeof bth.f !== 'undefined') { + value = value * bth.f; + } + + result[bth.n] = value; + buffer = buffer.slice(getByteSize(bth.t)); + } + + return result; + }, +}; + +// ============================================================================ +// STARTUP +// ============================================================================ + +init(); diff --git a/examples-manifest.json b/examples-manifest.json index 8d1106f..bf483e6 100644 --- a/examples-manifest.json +++ b/examples-manifest.json @@ -459,20 +459,35 @@ "description": "Simple fix for outgoing Domoticz MQTTAD command 'GoToPosition'.\n Shelly firmware 0.x supported. Developed for ShellyTeacher4Domo." }, { - "fname": "the_pill/MODBUS/Deye/the_pill_mbsa_deye.shelly.js", + "fname": "the_pill/MODBUS/Deye/deye.shelly.js", "title": "Deye SG02LP1 MODBUS-RTU", - "description": "MODBUS-RTU example for reading Deye SG02LP1 solar inverter parameters over UART using the MODBUS-RTU master library." + "description": "MODBUS-RTU example for reading Deye SG02LP1 solar inverter" }, { - "fname": "the_pill/MODBUS/Deye/the_pill_mbsa_deye_vc.shelly.js", + "fname": "the_pill/MODBUS/Deye/deye_vc.shelly.js", "title": "Deye SG02LP1 MODBUS-RTU + Virtual Components", - "description": "MODBUS-RTU reader for Deye SG02LP1 solar inverter with Virtual Component updates. Reads parameters over UART (RS485) and pushes values to user-defined virtual number components." + "description": "MODBUS-RTU reader for Deye SG02LP1 solar inverter with" }, { - "fname": "the_pill/MODBUS/JK200-MBS/the_pill_mbsa_jk200.shelly.js", + "fname": "the_pill/MODBUS/JKESS/JK200-MBS/jk200.shelly.js", "title": "JK200 BMS MODBUS-RTU Reader", "description": "MODBUS-RTU reader for Jikong JK-PB series BMS over RS485." }, + { + "fname": "the_pill/MODBUS/JKESS/JK200-MBS/jk200_vc.shelly.js", + "title": "JK200 BMS MODBUS-RTU Reader + Virtual Components", + "description": "MODBUS-RTU reader for Jikong JK-PB series BMS over RS485 with" + }, + { + "fname": "the_pill/MODBUS/LinkedGo/ST802/st802_bms.shelly.js", + "title": "LinkedGo ST802 Thermostat - BMS Modbus RTU Client", + "description": "Modbus RTU master that simulates BMS (Building Management System)" + }, + { + "fname": "the_pill/MODBUS/LinkedGo/ST802/st802_bms_vc.shelly.js", + "title": "LinkedGo ST802 Thermostat - BMS Modbus RTU Client + Virtual Components", + "description": "Modbus RTU master that simulates BMS commands for the LinkedGo" + }, { "fname": "the_pill/UART/uart_test.shelly.js", "title": "UART test", diff --git a/http-integrations/README.md b/http-integrations/README.md index 902e5dc..1dd93ef 100644 --- a/http-integrations/README.md +++ b/http-integrations/README.md @@ -6,6 +6,7 @@ HTTP endpoints, notifications, metrics, and external service integrations. Use these when you need Shelly to talk to external services or expose data via HTTP. ## Scripts +- `finance-yahoo/stock-monitor.shelly.js` (see `finance-yahoo/README.md`) - `http_post_watts_to_thingspeak/http_post_watts_to_thingspeak.shelly.js` - `http-handlers/http-handlers.shelly.js` - `http-notify-on-power-threshold/http-notify-on-power-threshold.shelly.js` @@ -16,7 +17,7 @@ Use these when you need Shelly to talk to external services or expose data via H ## Guides +- `finance-yahoo/README.md` - Yahoo Finance stock monitor purpose, persona, and setup notes. - `telegram/README.md` - Telegram bot setup and command configuration. - diff --git a/http-integrations/finance-yahoo/README.md b/http-integrations/finance-yahoo/README.md new file mode 100644 index 0000000..c136ab7 --- /dev/null +++ b/http-integrations/finance-yahoo/README.md @@ -0,0 +1,35 @@ +# Yahoo Finance Stock Monitor + +Track a stock symbol from Yahoo Finance and publish key market fields to Shelly Virtual Components. + +## Problem (The Story) +You want a quick market snapshot on your Shelly dashboard, but most finance widgets are tied to external apps or cloud dashboards. This script fetches the latest daily quote data from Yahoo Finance and writes price, open/close, high/low, volume, and daily change directly into Virtual Components for local display and automation. + +## Persona +- Home automation user who wants market context on a Shelly dashboard +- DIY investor monitoring one or a few symbols without opening a broker app +- Maker building simple finance-aware automations from HTTP data + +## Files +- [`stock-monitor.shelly.js`](stock-monitor.shelly.js): Yahoo Finance poller + Virtual Component updater + +## What It Updates +- `number:200` current price +- `number:201` volume +- `number:202` open +- `number:203` close +- `number:204` low +- `number:205` high +- `text:200` symbol +- `text:201` daily change +- `text:202` last update timestamp + +## How It Works +1. Calls Yahoo Finance chart API for `STOCK_SYMBOL` +2. Parses the latest day metadata and quote fields +3. Writes values to Virtual Components +4. Repeats every 5 minutes + +## Notes +- Script status is currently under development. +- The default symbol in the script is `SLYG.DE`; edit `STOCK_SYMBOL` to your preferred ticker. diff --git a/http-integrations/finance-yahoo/stock-monitor.shelly.js b/http-integrations/finance-yahoo/stock-monitor.shelly.js new file mode 100644 index 0000000..ccfef50 --- /dev/null +++ b/http-integrations/finance-yahoo/stock-monitor.shelly.js @@ -0,0 +1,174 @@ +/** + * @title Yahoo Finance stock monitor with virtual components + * @description Polls Yahoo Finance chart API for a stock symbol and updates + * Virtual Components with current price, daily delta, and quote fields. + * @status under development + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/http-integrations/finance-yahoo/stock-monitor.shelly.js + */ + +/** + * Stock Price Monitor + * + * Fetches one-day quote data for STOCK_SYMBOL and writes values to Virtual + * Components. + * + * Virtual Components used: + * - number:200..205 Price, volume, open, close, low, high + * - text:200..202 Symbol, daily change, last updated + */ + +const STOCK_SYMBOL = 'SLYG.DE'; + +const vcComponents = { + group: { + id: 200, + key: 'stock_monitor', + name: 'Stock Monitor', + type: 'group', + }, + components: [ + { + id: 200, + key: 'price', + type: 'number', + name: 'Current Price', + unit: '€', + }, + { + id: 201, + key: 'volume', + type: 'number', + name: 'Volume', + unit: 'shares', + }, + { + id: 202, + key: 'open', + type: 'number', + name: 'Open', + unit: '€', + }, + { + id: 203, + key: 'close', + type: 'number', + name: 'Close', + unit: '€', + }, + { + id: 204, + key: 'low', + type: 'number', + name: 'Low', + unit: '€', + }, + { + id: 205, + key: 'high', + type: 'number', + name: 'High', + unit: '€', + }, + { + id: 200, + key: 'symbol', + type: 'text', + name: 'Stock Symbol', + default: STOCK_SYMBOL, + }, + { + id: 201, + key: 'delta', + type: 'text', + name: 'Change today', + }, + { + id: 202, + key: 'time', + type: 'text', + name: 'Last Updated', + webIcon: 13, + }, + ], +}; + +function getTimestamp(ts) { + return new Date(ts).toString().split('GMT')[0].trim(); +} + +function getDate(ts) { + const date = new Date(ts); + return ( + String(date.getDate()).padStart(2, "0") + "-" + + String(date.getMonth() + 1).padStart(2, "0") + ); +} + +function formatNum(n) { + const x = Number(n); + return Number.isFinite(x) ? Number(x.toFixed(2)) : 0; +} + +function getComponentByKey(key) { + for (let i = 0; i < vcComponents.components.length; i++) { + const comp = vcComponents.components[i]; + if (comp.key === key) { + return comp.type + ':' + comp.id; + } + } + return null; +} + +function setValue(key, value) { + const comp = getComponentByKey(key); + if (!comp) return; + Virtual.getHandle(comp).setValue(value); +} + +function updateStockPrice() { + const url = 'https://query1.finance.yahoo.com/v8/finance/chart/' + STOCK_SYMBOL + '?interval=1d&range=1d'; + Shelly.call('HTTP.GET', + { + url: url, + headers: { 'User-Agent': 'Mozilla/5.0' }, + }, + function (response) { + if (!response || response.code !== 200) { + console.log('Error: HTTP', response); + return; + } + try { + const data = JSON.parse(response.body); + const meta = data.chart.result[0].meta; + const price = meta.regularMarketPrice; + const prev = meta.chartPreviousClose; + const ts = meta.regularMarketTime * 1000; + const delta = price - prev; + const deltaPct = prev !== 0 ? (delta / prev) * 100 : 0; + const sign = delta > 0 ? '+' : delta < 0 ? '−' : ''; + const trend = delta > 0 ? '⬆️ ' : delta < 0 ? '🔻 ' : ''; + const deltaText = + trend + sign + + formatNum(Math.abs(delta)) + '€ (' + + sign + formatNum(Math.abs(deltaPct)) + '%) / ' + getDate(ts); + const quote = data.chart.result[0].indicators.quote[0]; + setValue('price', formatNum(price)); + setValue('time', getTimestamp(ts)); + setValue('delta', deltaText); + setValue('open', formatNum(quote.open[0])); + setValue('close', formatNum(quote.close[0])); + setValue('high', formatNum(quote.high[0])); + setValue('low', formatNum(quote.low[0])); + setValue('volume', quote.volume[0]); + } catch (err) { + console.log('Error parsing JSON:', err); + } + }, + ); +} + +// Run immediately +updateStockPrice(); + +// Run every 5 minutes +Timer.set(5 * 60 * 1000, true, updateStockPrice); diff --git a/skills/git-commit-merge-cleanup.md b/skills/git-commit-merge-cleanup.md new file mode 100644 index 0000000..50c6731 --- /dev/null +++ b/skills/git-commit-merge-cleanup.md @@ -0,0 +1,95 @@ +# Skill: Git Commit, Merge, Push, and Feature Cleanup + +## Description +Use this skill when finalizing repository changes with the team workflow used in this project. + +This skill enforces: +- branch from `dev` +- explicit user approval before commit/merge/push +- `--no-ff` merges +- delete all `feature/*` branches locally and remotely after merge flow is complete + +--- + +## Workflow + +### 1) Start from `dev` and create a feature branch +```bash +git checkout dev +git checkout -b feature/ +``` + +### 2) Ask before commit +Before asking, run a documentation check for touched areas and update docs if needed. + +Minimum review set: +- `CHANGELOG.md` (add current month entry at top if change is not recorded) +- nearest folder `README.md` for changed scripts +- root/category `README.md` indexes when files are added/renamed/removed +- script header metadata (`@title`, `@description`, `@status`, `@link`) when behavior or path changed + +Rule: +- If any of the above is outdated, update documentation first, then proceed to commit. + +Use prompt: +- `Changes are ready. May I commit them?` + +Then commit with the project message format: +```bash +git add +git commit -m "$(cat <<'EOF' +Short summary (imperative mood, max 50 chars) + +- Bullet point 1 +- Bullet point 2 + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +### 3) Ask before merge feature -> dev +Use prompt: +- `Feature is ready and tested. May I merge to dev?` + +Then merge: +```bash +git checkout dev +git merge feature/ --no-ff -m "Merge feature/ into dev" +``` + +### 4) Ask before push +Use prompt: +- `May I push dev to origin?` + +Then push: +```bash +git push origin dev +``` + +### 5) Cleanup feature branches (required) +After successful push, remove feature branches both local and remote. + +Delete local `feature/*` branches: +```bash +for b in $(git branch --format='%(refname:short)' | rg '^feature/'); do git branch -D "$b"; done +``` + +Delete remote `origin/feature/*` branches: +```bash +for rb in $(git ls-remote --heads origin 'refs/heads/feature/*' | awk '{print $2}' | sed 's#refs/heads/##'); do git push origin --delete "$rb"; done +``` + +Verify cleanup: +```bash +git branch --format='%(refname:short)' | rg '^feature/' || true +git ls-remote --heads origin 'refs/heads/feature/*' +``` + +--- + +## Guardrails +- Never commit, merge, or push without explicit user approval. +- Always use `--no-ff` when merging. +- Do not use destructive history rewrite commands (`reset --hard`, force-push) unless explicitly requested. +- If remote operations fail due to environment/network restrictions, rerun with required escalation/approval. diff --git a/skills/js-to-shelly-standardize.md b/skills/js-to-shelly-standardize.md new file mode 100644 index 0000000..a1baeb1 --- /dev/null +++ b/skills/js-to-shelly-standardize.md @@ -0,0 +1,79 @@ +# Skill: Standardize JS to Shelly Script + +## Description +Use this skill when a script exists as `*.js` (or non-standard name) and must be converted to repository-compliant `*.shelly.js` format with proper metadata headers. + +--- + +## Goal +- Rename script to kebab-case `*.shelly.js` +- Add required header fields: `@title`, `@description`, `@status`, `@link` +- Align code style with repository standards +- Update related documentation references + +--- + +## Workflow + +### 1) Rename the file +Use kebab-case and `.shelly.js` extension. + +Example: +```bash +mv path/old_name.js path/new-name.shelly.js +``` + +### 2) Add standard metadata header (required) +Use this block at the top of the script: + +```javascript +/** + * @title Human-readable title + * @description One or two sentences describing what the script does and key requirements. + * @status under development + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/path/to/new-name.shelly.js + */ +``` + +### 3) Add optional detailed documentation block +Add a second block when script behavior is non-trivial (hardware/protocol/components): + +```javascript +/** + * Short technical details + * + * Hardware/protocol/components: + * - Item 1 + * - Item 2 + */ +``` + +### 4) Standardize code style +- 2-space indentation +- single quotes +- semicolons +- LF line endings +- UTF-8 text +- no imports/includes (standalone Shelly script) + +### 5) Standardize structure (recommended) +Organize into sections when practical: +- `CONFIGURATION` +- `STATE` +- `HELPERS` +- `MAIN LOGIC` +- `EVENT HANDLERS` +- `INITIALIZATION` with `init();` + +### 6) Update docs (required before commit) +- Add/update nearest folder `README.md` entries for renamed/new script file +- Update category/root `README.md` indexes if needed +- Add `CHANGELOG.md` entry in current `YYYY-MM` section +- If `@status production`, ensure manifest/index workflow is run via tools + +--- + +## Guardrails +- Do not manually edit generated files (`examples-manifest.json`, `SHELLY_MJS.md`) +- If script path changes, always update `@link` +- Keep behavior unchanged unless user explicitly asks for functional refactor diff --git a/skills/manifest-verify-tools.md b/skills/manifest-verify-tools.md new file mode 100644 index 0000000..0e5a402 --- /dev/null +++ b/skills/manifest-verify-tools.md @@ -0,0 +1,82 @@ +# Skill: Manifest Verify Tools Workflow + +## Description +Use this skill when working with repository-generated files managed by scripts in `tools/`, especially: +- `examples-manifest.json` +- `SHELLY_MJS.md` + +This skill enforces a strict rule: +- **Never manually edit generated files.** +- **Always use the `tools/` scripts to regenerate and validate.** + +--- + +## Trigger Cases +Use this workflow when the user asks to: +- verify/check manifest or index consistency +- regenerate `SHELLY_MJS.md` +- sync production script metadata from headers +- fix CI failures related to manifest/header/sync checks + +--- + +## Canonical Pipeline (Run in Order) + +### 1) Sync manifest from script headers +```bash +python3 tools/sync-manifest-md.py --extract-metadata +``` + +### 2) Regenerate index markdown from manifest +```bash +python3 tools/sync-manifest-json.py ./examples-manifest.json +``` + +### 3) Run integrity checks (same gate used in CI) +```bash +python3 tools/check-manifest-integrity.py --check-headers --check-sync +``` + +--- + +## Auto-Fix Rules + +If step 3 fails, apply targeted fixes, then rerun step 2 and step 3. + +### Missing files referenced by manifest +Use: +```bash +python3 tools/sync-manifest-md.py --remove-missing +``` + +### File on disk but not in manifest +Use: +```bash +python3 tools/sync-manifest-md.py --extract-metadata +``` + +### Header problems +Fix script header fields (`@title`, `@description`, `@status`, `@link`) in source script, then rerun the pipeline. + +--- + +## Guardrails +- Do not hand-edit `examples-manifest.json`. +- Do not hand-edit `SHELLY_MJS.md`. +- Use forward-slash manifest path: `./examples-manifest.json`. +- Report exact files added/removed/fixed after each run. + +--- + +## Output Report Template +After execution, report: +- total manifest entries +- files added/removed/fixed +- final pass/fail of integrity check +- remaining blockers (if any) + +Short example: +- `Manifest entries: 102` +- `Removed stale entries: 3` +- `Integrity check: PASS` + diff --git a/skills/modbus-device-template.md b/skills/modbus-device-template.md new file mode 100644 index 0000000..fb55816 --- /dev/null +++ b/skills/modbus-device-template.md @@ -0,0 +1,311 @@ +# Modbus Device Script Template + +Canonical skeleton for writing a new Modbus RTU device driver that runs either +as a standalone Shelly script (Shelly API) or as plain JavaScript (software-only +mode with no hardware dependency). + +--- + +## Concepts + +| Term | Meaning | +|------|---------| +| `ENTITIES` | Table of every register the driver reads or writes | +| `CONFIG` | All tuneable parameters in one place at the top of the file | +| `vcId` | Shelly virtual-component identifier (`"number:200"`, `"boolean:0"`, …) | +| `handle` | Opaque timer / request handle returned by the poll loop | +| `vcHandle` | Opaque handle returned by `Shelly.addVirtualComponent` | +| `REGTYPE_INPUT` | Modbus Function Code 0x04 — Read Input Registers | +| `REGTYPE_HOLDING` | Modbus Function Code 0x03 — Read Holding Registers | +| `BE` / `LE` | Big-endian / little-endian byte order for 32-bit register pairs | + +--- + +## 1. File header + +Every driver file starts with a JSDoc block that identifies it in the manifest. + +```js +/** + * @title + * @description + * @status under development | testing | production + * @link + */ +``` + +--- + +## 2. CONFIG block + +All user-tuneable values in one object, at the very top of the script body. + +```js +var CONFIG = { + // --- Modbus transport --- + SLAVE_ID: 1, // Modbus slave address (1-247) + BAUD_RATE: 9600, // Serial baud rate + UART_MODE: "8N1", // Frame format: "8N1" | "8E1" | "8O1" + + // --- Polling --- + POLL_INTERVAL_MS: 5000, // How often to read all entities (ms) + RESPONSE_TIMEOUT: 1000, // Max time to wait for a slave reply (ms) + INTER_FRAME_DELAY: 50, // Silence between frames (ms) + + // --- Behaviour flags --- + DEBUG: false, // Print raw frames to the log + DRY_RUN: false, // Parse registers but skip virtual-component writes +}; +``` + +--- + +## 3. ENTITIES table + +One entry per logical value the driver exposes. The array drives both the poll +loop and the virtual-component registration — no other code needs to be edited +when adding or removing a register. + +```js +// ModbusController constants (defined by the shared library or inline below) +// +// ModbusController.REGTYPE_INPUT = 0x04 (Read Input Registers) +// ModbusController.REGTYPE_HOLDING = 0x03 (Read Holding Registers) +// ModbusController.BE = "BE" (big-endian word order) +// ModbusController.LE = "LE" (little-endian word order) + +var ENTITIES = [ + // + // --- --- + // + { + // Human-readable name shown in the Shelly app / logs + name: "", + + // SI unit string displayed alongside the value + units: "", // e.g. "W", "V", "A", "Hz", "%", "degC" + + // Register descriptor + reg: { + addr: 0, // Zero-based register address from the device datasheet + + rtype: ModbusController.REGTYPE_INPUT, + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + // REGTYPE_INPUT -> FC 0x04 (read-only process values) + // REGTYPE_HOLDING -> FC 0x03 (read/write configuration registers) + + itype: "u16", + // ^^^^ + // "u16" -> unsigned 16-bit (1 register) + // "i16" -> signed 16-bit (1 register, two's complement) + // "u32" -> unsigned 32-bit (2 consecutive registers) + // "i32" -> signed 32-bit (2 consecutive registers) + + bo: ModbusController.BE, // byte order within each 16-bit register + wo: ModbusController.BE, // word order for 32-bit pairs (high word first = BE) + }, + + // Multiply the raw integer by this factor to obtain the physical value. + // Example: raw = 2350, scale = 0.1 -> displayed = 235.0 V + scale: 1, + + // Access rights exported to callers + rights: "R", // "R" | "W" | "RW" + + // Shelly virtual-component slot that receives the scaled value. + // Format: ":" where type is number | boolean | text | enum + // Set to null to poll the register without publishing it. + vcId: "number:", // e.g. "number:200" + + // Runtime handles — always null in the static definition. + handle: null, // filled by the poll loop + vcHandle: null, // filled by Shelly.addVirtualComponent (Shelly mode) + }, + + // --- repeat for every register --- +]; +``` + +### Entity field quick-reference + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Display name | +| `units` | string | Physical unit | +| `reg.addr` | number | Register start address (0-based) | +| `reg.rtype` | const | `REGTYPE_INPUT` or `REGTYPE_HOLDING` | +| `reg.itype` | string | `"u16"` `"i16"` `"u32"` `"i32"` | +| `reg.bo` | const | Byte order inside a 16-bit word | +| `reg.wo` | const | Word order for 32-bit values | +| `scale` | number | Raw-to-physical multiplier | +| `rights` | string | `"R"` `"W"` `"RW"` | +| `vcId` | string\|null | Virtual component slot | +| `handle` | null | Populated at runtime | +| `vcHandle` | null | Populated at runtime | + +--- + +## 4. Runtime state + +```js +var STATE = { + uart: null, // UART handle (Shelly mode only) + rxBuffer: [], // Raw bytes accumulated from the serial port + pollTimer: null, // Repeating timer handle + entityIndex: 0, // Pointer to the entity currently being queried + ready: false, // True after init() completes +}; +``` + +--- + +## 5. Script skeleton + +```js +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function debug(msg) { + if (CONFIG.DEBUG) { print("[DBG] " + msg); } +} + +function applyScale(raw, entity) { + return raw * entity.scale; +} + +// --------------------------------------------------------------------------- +// Register read (software mode — replace with real Modbus call) +// --------------------------------------------------------------------------- + +function readEntity(entity, callback) { + // TODO: call ModbusController.read(entity.reg, function(err, raw) { ... }) + // Invoke callback(err, scaledValue) when done. + callback("not implemented", null); +} + +// --------------------------------------------------------------------------- +// Virtual component update (Shelly mode only) +// --------------------------------------------------------------------------- + +function publishToVC(entity, value) { + if (!CONFIG.DRY_RUN && entity.vcHandle !== null) { + // Shelly.setVirtualComponentValue(entity.vcHandle, value); + } + debug(entity.name + " = " + value + " " + entity.units); +} + +// --------------------------------------------------------------------------- +// Poll loop — walks ENTITIES one at a time, respecting INTER_FRAME_DELAY +// --------------------------------------------------------------------------- + +function pollNext() { + var entity = ENTITIES[STATE.entityIndex]; + + readEntity(entity, function(err, value) { + if (err) { + print("[ERR] " + entity.name + ": " + err); + } else { + publishToVC(entity, value); + } + + // Advance to next entity, then schedule the next poll or restart + STATE.entityIndex = (STATE.entityIndex + 1) % ENTITIES.length; + + if (STATE.entityIndex === 0) { + // Full cycle done — wait POLL_INTERVAL_MS before starting again + Timer.set(CONFIG.POLL_INTERVAL_MS, false, pollNext, null); + } else { + // Read next register after the inter-frame silence + Timer.set(CONFIG.INTER_FRAME_DELAY, false, pollNext, null); + } + }); +} + +// --------------------------------------------------------------------------- +// Virtual component registration (Shelly mode only) +// --------------------------------------------------------------------------- + +function registerVirtualComponents(callback) { + var i = 0; + function next() { + if (i >= ENTITIES.length) { callback(); return; } + var e = ENTITIES[i]; + if (e.vcId === null) { i++; next(); return; } + // Shelly.addVirtualComponent(e.vcId, { name: e.name }, function(handle) { + // e.vcHandle = handle; + // i++; + // next(); + // }); + i++; + next(); // remove this line when Shelly API is wired in + } + next(); +} + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- + +function init() { + print("[INIT] Starting " + "" + " driver"); + print("[INIT] Slave ID: " + CONFIG.SLAVE_ID); + print("[INIT] Poll interval: " + CONFIG.POLL_INTERVAL_MS + " ms"); + + // 1. Open UART (Shelly mode) + // STATE.uart = Shelly.openUART({ baud: CONFIG.BAUD_RATE, mode: CONFIG.UART_MODE }); + + // 2. Register virtual components, then start polling + registerVirtualComponents(function() { + STATE.ready = true; + STATE.entityIndex = 0; + print("[INIT] Ready. Polling " + ENTITIES.length + " entities."); + pollNext(); + }); +} + +init(); +``` + +--- + +## 6. Mode selection (Shelly vs software) + +The same skeleton runs in both contexts by conditionally branching at the +places marked with `// Shelly mode` comments. + +| Feature | Shelly mode | Software mode | +|---------|-------------|---------------| +| UART open | `Shelly.openUART(...)` | Node.js / system serial port | +| Timer | `Timer.set(...)` | `setTimeout(...)` | +| Virtual component | `Shelly.addVirtualComponent(...)` | omit / log only | +| Logging | `print(...)` | `console.log(...)` | + +To support both in one file, wrap the platform-specific calls: + +```js +var Platform = (typeof Shelly !== "undefined") + ? { + setTimer: function(ms, cb) { Timer.set(ms, false, cb, null); }, + log: function(msg) { print(msg); }, + } + : { + setTimer: function(ms, cb) { setTimeout(cb, ms); }, + log: function(msg) { console.log(msg); }, + }; +``` + +--- + +## 7. Checklist for a new device + +- [ ] Fill in the JSDoc header (`@title`, `@description`, `@status`, `@link`) +- [ ] Set `CONFIG.SLAVE_ID` and serial parameters for the target device +- [ ] Populate `ENTITIES` from the device datasheet — one entry per register +- [ ] Choose `reg.itype` and `reg.bo`/`reg.wo` carefully for 32-bit registers +- [ ] Assign non-overlapping `vcId` values (`"number:200"` upward by convention) +- [ ] Wire `ModbusController.read` into `readEntity()` +- [ ] Wire `Shelly.addVirtualComponent` into `registerVirtualComponents()` (Shelly mode) +- [ ] Set `CONFIG.DEBUG: true` during bring-up, `false` in production +- [ ] Verify scaled values match expected physical readings +- [ ] Update `@status` to `production` and add an entry to `CHANGELOG.md` diff --git a/skills/modbus-vc-deploy.md b/skills/modbus-vc-deploy.md new file mode 100644 index 0000000..3959f17 --- /dev/null +++ b/skills/modbus-vc-deploy.md @@ -0,0 +1,616 @@ +# Skill: Deploy a MODBUS + Virtual Component Script + +This skill walks through the complete workflow for any `*_vc.shelly.js` +Modbus device script: read the VC requirements from the file, create the +Virtual Components on the Shelly device, upload the script, run it, and +verify correct behaviour. + +See also: [`skills/shelly-script-deploy.md`](shelly-script-deploy.md) for the +generic script upload reference. + +--- + +## Prerequisites + +- Shelly **gen-3** device (Plus, Pro, Mini gen-3, or newer) — Virtual + Components require gen-3 firmware. +- Firmware ≥ 1.3.0. +- Device reachable on the local network (same VLAN / Wi-Fi segment). +- Python 3 available on your workstation (for `tools/put_script.py`). +- `curl` available (for VC creation and verification). + +--- + +## Step 1 — Read the VC requirements from the script + +Open the `*_vc.shelly.js` file and look for the **Virtual Component mapping** +table in the file-header comment, and for `vcId` fields in the `ENTITIES` +array. + +### What to look for + +``` +// Virtual Component mapping (pre-create with skills/modbus-vc-deploy.md): +// number:200 Room Temperature degC +// number:201 Humidity % +// ... +// group:200 ST802 Thermostat (group) +``` + +Collect: +| Field | Meaning | +|-------|---------| +| `type` | `number`, `text`, `boolean`, `button`, `group` | +| `id` | Numeric slot (≥ 200 by convention) | +| `name` | Human-readable label (from `entity.name` in ENTITIES) | +| `units`| Physical unit (from `entity.units`) | + +### Quick grep + +```bash +grep 'vcId' the_pill/MODBUS/.../the_*_vc.shelly.js | grep -v null +``` + +--- + +## Step 2 — Verify device reachability + +```bash +export DEVICE=192.168.1.100 # replace with your device IP + +curl -s "http://${DEVICE}/rpc/Shelly.GetDeviceInfo" | python3 -m json.tool +``` + +Expected response includes: +```json +{ + "id": "shellyplus1-AABBCC112233", + "app": "Plus1", + "ver": "1.4.4", + ... +} +``` + +If `app` contains "Plus", "Pro", or "Mini" with gen-3 firmware you are good. + +--- + +## Step 3 — Create Virtual Components + +### 3a. Create number VCs + +One `curl` call per data VC. Adjust `name` and `unit` to match the entity. + +```bash +# Template: +# curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" \ +# -H "Content-Type: application/json" \ +# -d '{"type":"number","id":,"config":{"name":"","persisted":false,"unit":"","min":-1000000,"max":1000000}}' + +# --- Deye SG02LP1 (number:200-208) --- +for cfg in \ + '200|Total Power|W' \ + '201|Battery Power|W' \ + '202|PV1 Power|W' \ + '203|Total Grid Power|W' \ + '204|Battery SOC|%' \ + '205|PV1 Voltage|V' \ + '206|Grid Voltage L1|V' \ + '207|Current L1|A' \ + '208|AC Frequency|Hz' +do + id=$(echo $cfg | cut -d'|' -f1) + name=$(echo $cfg | cut -d'|' -f2) + unit=$(echo $cfg | cut -d'|' -f3) + curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" \ + -H "Content-Type: application/json" \ + -d "{\"type\":\"number\",\"id\":${id},\"config\":{\"name\":\"${name}\",\"persisted\":false,\"unit\":\"${unit}\",\"min\":-1000000,\"max\":1000000}}" + echo "" +done +``` + +### 3b. Per-device VC tables + +#### Deye SG02LP1 (`the_pill_mbsa_deye_vc.shelly.js`) + +| curl id | Name | Unit | +|---------|------|------| +| 200 | Total Power | W | +| 201 | Battery Power | W | +| 202 | PV1 Power | W | +| 203 | Total Grid Power | W | +| 204 | Battery SOC | % | +| 205 | PV1 Voltage | V | +| 206 | Grid Voltage L1 | V | +| 207 | Current L1 | A | +| 208 | AC Frequency | Hz | + +```bash +# Deye – individual curl commands +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":200,"config":{"name":"Total Power","persisted":false,"unit":"W","min":-100000,"max":100000}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":201,"config":{"name":"Battery Power","persisted":false,"unit":"W","min":-100000,"max":100000}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":202,"config":{"name":"PV1 Power","persisted":false,"unit":"W","min":0,"max":100000}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":203,"config":{"name":"Total Grid Power","persisted":false,"unit":"W","min":-100000,"max":100000}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":204,"config":{"name":"Battery SOC","persisted":false,"unit":"%","min":0,"max":100}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":205,"config":{"name":"PV1 Voltage","persisted":false,"unit":"V","min":0,"max":1000}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":206,"config":{"name":"Grid Voltage L1","persisted":false,"unit":"V","min":0,"max":300}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":207,"config":{"name":"Current L1","persisted":false,"unit":"A","min":-100,"max":100}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":208,"config":{"name":"AC Frequency","persisted":false,"unit":"Hz","min":0,"max":100}}' +``` + +#### JK200 BMS (`the_pill_mbsa_jk200_vc.shelly.js`) + +| id | Name | Unit | +|----|------|------| +| 200 | MOSFET Temperature | degC | +| 201 | Pack Voltage | V | +| 202 | Pack Power | W | +| 203 | Pack Current | A | +| 204 | Temperature 1 | degC | +| 205 | Temperature 2 | degC | +| 206 | Alarm Bitmask | - | +| 207 | Balance Current | mA | +| 208 | State of Charge | % | + +```bash +# JK200 BMS +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":200,"config":{"name":"MOSFET Temperature","persisted":false,"unit":"degC","min":-50,"max":150}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":201,"config":{"name":"Pack Voltage","persisted":false,"unit":"V","min":0,"max":1000}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":202,"config":{"name":"Pack Power","persisted":false,"unit":"W","min":-1000,"max":1000}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":203,"config":{"name":"Pack Current","persisted":false,"unit":"A","min":-1000,"max":1000}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":204,"config":{"name":"Temperature 1","persisted":false,"unit":"degC","min":-50,"max":150}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":205,"config":{"name":"Temperature 2","persisted":false,"unit":"degC","min":-50,"max":150}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":206,"config":{"name":"Alarm Bitmask","persisted":false,"unit":"-","min":0,"max":65535}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":207,"config":{"name":"Balance Current","persisted":false,"unit":"mA","min":-10000,"max":10000}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":208,"config":{"name":"State of Charge","persisted":false,"unit":"%","min":0,"max":100}}' +``` + +#### CWT-MB308V (`mb308v_vc.shelly.js`) + +Mixed layout: 2 relay toggle buttons (INPUT), 2 DI displays (OUTPUT), +2 AO sliders (INPUT, persisted), 2 AI progress bars (OUTPUT). + +| type | id | Name | Direction | Notes | +|------|----|------|-----------|-------| +| button | 200 | Relay 0 | INPUT | press → toggle DO 0 | +| button | 201 | Relay 1 | INPUT | press → toggle DO 1 | +| number | 200 | Digital Input 0 | OUTPUT | live 0/1 display | +| number | 201 | Digital Input 1 | OUTPUT | live 0/1 display | +| number | 202 | Analog Output 0 | INPUT | slider 0-24000, persisted | +| number | 203 | Analog Output 1 | INPUT | slider 0-24000, persisted | +| number | 204 | Analog Input 0 | OUTPUT | progress bar 0-10216 | +| number | 205 | Analog Input 1 | OUTPUT | progress bar 0-10216 | +| group | 200 | MB308V Demo | — | contains all above | + +```bash +# MB308V — relay toggle buttons +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"button","id":200,"config":{"name":"Relay 0"}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"button","id":201,"config":{"name":"Relay 1"}}' + +# MB308V — DI displays (read-only, script writes) +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":200,"config":{"name":"Digital Input 0","persisted":false,"unit":"","min":0,"max":1}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":201,"config":{"name":"Digital Input 1","persisted":false,"unit":"","min":0,"max":1}}' + +# MB308V — AO sliders (user sets, script reads; persisted so value survives reboot) +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":202,"config":{"name":"Analog Output 0","persisted":true,"unit":"raw","min":0,"max":24000}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":203,"config":{"name":"Analog Output 1","persisted":true,"unit":"raw","min":0,"max":24000}}' + +# MB308V — AI progress bars (read-only, script writes) +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":204,"config":{"name":"Analog Input 0","persisted":false,"unit":"raw","min":0,"max":10216}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":205,"config":{"name":"Analog Input 1","persisted":false,"unit":"raw","min":0,"max":10216}}' +``` + +> **Group membership note**: the `Group.Set` value array for MB308V must +> include both VC types: `"button:200"`, `"button:201"` plus `"number:200"` +> through `"number:205"` (6 numbers + 2 buttons = 8 members total). + +#### LinkedGo ST802 (`st802_bms_vc.shelly.js`) + +| id | Name | Unit | +|----|------|------| +| 200 | Room Temperature | degC | +| 201 | Humidity | % | +| 202 | Floor Temperature | degC | +| 203 | Relay State | - | +| 204 | Alarm | 0/1 | +| 205 | Mode | 0-7 | +| 206 | Fan Speed | 0-5 | +| 207 | Setpoint | degC | +| 208 | Power | 0/1 | + +```bash +# ST802 +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":200,"config":{"name":"Room Temperature","persisted":false,"unit":"degC","min":-20,"max":60}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":201,"config":{"name":"Humidity","persisted":false,"unit":"%","min":0,"max":100}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":202,"config":{"name":"Floor Temperature","persisted":false,"unit":"degC","min":-20,"max":60}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":203,"config":{"name":"Relay State","persisted":false,"unit":"-","min":0,"max":63}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":204,"config":{"name":"Alarm","persisted":false,"unit":"","min":0,"max":1}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":205,"config":{"name":"Mode","persisted":false,"unit":"","min":0,"max":7}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":206,"config":{"name":"Fan Speed","persisted":false,"unit":"","min":0,"max":5}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":207,"config":{"name":"Setpoint","persisted":false,"unit":"degC","min":5,"max":35}}' +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"number","id":208,"config":{"name":"Power","persisted":false,"unit":"","min":0,"max":1}}' +``` + +### 3c. Create the group VC + +After all number/button VCs are created, bundle them into a group. +Run the block for your specific device: + +**Important visibility rule:** every VC that should be visible inside the UI +group must be included in the `Group.Set` `value` array. If a component is +created but not listed in the group members, it may appear hidden from the +group view. + +```bash +# --- Deye SG02LP1 --- +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"group","id":200,"config":{"name":"Deye SG02LP1"}}' +curl -s -X POST "http://${DEVICE}/rpc/Group.Set" -H "Content-Type: application/json" \ + -d '{"id":200,"value":["number:200","number:201","number:202","number:203","number:204","number:205","number:206","number:207","number:208"]}' + +# --- JK200 BMS --- +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"group","id":200,"config":{"name":"JK200 BMS"}}' +curl -s -X POST "http://${DEVICE}/rpc/Group.Set" -H "Content-Type: application/json" \ + -d '{"id":200,"value":["number:200","number:201","number:202","number:203","number:204","number:205","number:206","number:207","number:208"]}' + +# --- CWT-MB308V (mixed: buttons + numbers) --- +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"group","id":200,"config":{"name":"MB308V Demo"}}' +curl -s -X POST "http://${DEVICE}/rpc/Group.Set" -H "Content-Type: application/json" \ + -d '{"id":200,"value":["button:200","button:201","number:200","number:201","number:202","number:203","number:204","number:205"]}' + +# --- LinkedGo ST802 --- +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Add" -H "Content-Type: application/json" \ + -d '{"type":"group","id":200,"config":{"name":"ST802 Thermostat"}}' +curl -s -X POST "http://${DEVICE}/rpc/Group.Set" -H "Content-Type: application/json" \ + -d '{"id":200,"value":["number:200","number:201","number:202","number:203","number:204","number:205","number:206","number:207","number:208"]}' +``` + +### 3d. Verify components were created + +```bash +curl -s "http://${DEVICE}/rpc/Shelly.GetComponents" | python3 -m json.tool | grep '"key"' +``` + +Expected output lists each VC: +``` +"key": "number:200", +"key": "number:201", +... +"key": "group:200", +``` + +Also verify group membership contains all intended components: + +```bash +curl -s -X POST "http://${DEVICE}/rpc/Group.GetStatus" \ + -H "Content-Type: application/json" \ + -d '{"id":200}' +``` + +Expected: +- `value` includes every created `number:*` / `button:*` VC that should be + visible in the group. + +--- + +## Step 4 — Upload the script + +Use the existing deploy tool: + +```bash +python3 tools/put_script.py ${DEVICE} the_pill/MODBUS/Deye/the_pill_mbsa_deye_vc.shelly.js +``` + +The tool will: +1. Stop any running script in slot 1 (default) +2. Upload the file in 1 kB chunks +3. Set the script name to the filename +4. Start the script + +If `tools/put_script.py` is not available, use chunked curl manually +(see `skills/shelly-script-deploy.md` for the full procedure). + +--- + +## Step 5 — Start and monitor + +### Check script list + +```bash +curl -s "http://${DEVICE}/rpc/Script.List" | python3 -m json.tool +``` + +Note the script `id` (usually `1`). + +### Start (if not auto-started by put_script.py) + +```bash +curl -s -X POST "http://${DEVICE}/rpc/Script.Start" \ + -H "Content-Type: application/json" \ + -d '{"id":1}' +``` + +### Stream the log + +```bash +curl -s -N "http://${DEVICE}/debug/log" +``` + +Press `Ctrl+C` to stop streaming. + +### Expected log output — good behaviour + +**Deye:** +``` +Deye SG02LP1 - MODBUS-RTU Reader + Virtual Components +====================================================== +[DEYE] VC handle for Total Power -> number:200 +... +--- Deye SG02LP1 --- +Total Power: 1250 [W] +Battery Power: -300 [W] +... +``` + +**JK200 BMS:** +``` +JK200 BMS - MODBUS-RTU Reader + Virtual Components +=================================================== +[JK200] VC handle for MOSFET Temperature -> number:200 +... +--- JK200 BMS --- + Cells (16): ... + Pack: 48.123 V | -12.456 A | -598.765 W + SOC: 87 % + ... +``` + +**MB308V:** +``` +CWT-MB308V MODBUS IO Module + Virtual Components +================================================= + button:200/201 -> relay toggle (DO 0/1) + number:200/201 -> DI 0/1 display + number:202/203 -> AO 0/1 sliders + number:204/205 -> AI 0/1 progress bars + group:200 -> MB308V Demo + +[MB308V] VC out: DI 0 -> number:200 +[MB308V] VC out: DI 1 -> number:201 +[MB308V] VC out: AI 0 -> number:204 +[MB308V] VC out: AI 1 -> number:205 +[MB308V] VC in: Relay 0 toggle -> button:200 +[MB308V] VC in: Relay 1 toggle -> button:201 +[MB308V] VC in: AO 0 slider -> number:202 +[MB308V] VC in: AO 1 slider -> number:203 +Polling every 5s... + +[DI] DI0:0 DI1:1 +[AI] AI0:4.12mA AI1:8.35mA +``` + +**ST802:** +``` +LinkedGo ST802 - BMS Modbus RTU Client + Virtual Components +[ST802] VC handle for Room Temperature -> number:200 +... +[ST802] Room: 21.5degC Humidity: 55% Floor: 19.0degC +[ST802] Mode: Heating +[ST802] Fan: Auto +``` + +### Signs of problems + +| Log message | Cause | Fix | +|-------------|-------|-----| +| `VC handle for X -> number:NNN` missing | VC not created | Repeat Step 3 | +| `ERROR: UART not available` | UART pin conflict | Check Shelly UART config | +| `Timeout` repeatedly | RS485 wiring, baud, slave ID | Check hardware | +| `CRC error` | Noise on bus or wrong baud rate | Check termination | +| `Exception 0x02` | Wrong register address | Check device manual | + +--- + +## Step 6 — Verify VC values via HTTP + +After at least one poll cycle completes, read values directly: + +```bash +# Read a single VC +curl -s "http://${DEVICE}/rpc/Virtual.GetStatus?key=number:200" | python3 -m json.tool +``` + +Expected response: +```json +{ + "id": 200, + "value": 21.5 +} +``` + +Read all VCs in one call: +```bash +curl -s "http://${DEVICE}/rpc/Shelly.GetStatus" | python3 -m json.tool | grep -A2 '"number:' +``` + +--- + +## Automated Python helper + +The snippet below discovers `vcId` values from a `*_vc.shelly.js` file, +creates all VCs on the device, then uploads and starts the script. + +```python +#!/usr/bin/env python3 +""" +modbus_vc_setup.py -- auto-provision VCs and deploy a *_vc.shelly.js script. + +Usage: + python3 modbus_vc_setup.py + +Example: + python3 modbus_vc_setup.py 192.168.1.100 \ + the_pill/MODBUS/Deye/the_pill_mbsa_deye_vc.shelly.js +""" +import sys, re, json, urllib.request, subprocess + +def rpc(ip, method, params=None): + url = f"http://{ip}/rpc/{method}" + data = json.dumps(params or {}).encode() + req = urllib.request.Request(url, data=data, + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()) + +def parse_vc_ids(path): + """Extract unique non-null vcId strings from the JS file.""" + text = open(path).read() + return sorted(set(re.findall(r'vcId:\s*"([^"]+)"', text))) + +def vc_type_id(vc_str): + t, i = vc_str.split(":") + return t, int(i) + +def vc_name_from_file(path, vc_str): + """Find the 'name' field of the ENTITY whose vcId matches vc_str.""" + pattern = r'name:\s*"([^"]+)"[^}]*vcId:\s*"' + re.escape(vc_str) + '"' + m = re.search(pattern, open(path).read(), re.DOTALL) + return m.group(1) if m else vc_str + +def main(): + if len(sys.argv) < 3: + print(__doc__) + sys.exit(1) + + ip, script = sys.argv[1], sys.argv[2] + vc_ids = parse_vc_ids(script) + + if not vc_ids: + print("No vcId values found in", script) + sys.exit(0) + + print(f"Found {len(vc_ids)} VCs to create: {vc_ids}") + + # Step 1: create each VC + group_members = [] + for vc in vc_ids: + t, i = vc_type_id(vc) + if t == "group": + continue # create group last + name = vc_name_from_file(script, vc) + print(f" Virtual.Add {vc} '{name}'") + try: + rpc(ip, "Virtual.Add", { + "type": t, "id": i, + "config": {"name": name, "persisted": False, + "min": -1000000, "max": 1000000} + }) + except Exception as e: + print(f" WARNING: {e}") + group_members.append(vc) + + # Step 2: create group if present + group_ids = [vc for vc in vc_ids if vc.startswith("group:")] + for g in group_ids: + _, gid = vc_type_id(g) + print(f" Virtual.Add {g} (group)") + try: + rpc(ip, "Virtual.Add", { + "type": "group", "id": gid, + "config": {"name": "Modbus Device"} + }) + rpc(ip, "Group.Set", {"id": gid, "value": group_members}) + except Exception as e: + print(f" WARNING: {e}") + + # Step 3: upload script + print(f"\nUploading {script} ...") + subprocess.run(["python3", "tools/put_script.py", ip, script], check=True) + print("Done. Stream log with:") + print(f" curl -s -N http://{ip}/debug/log") + +if __name__ == "__main__": + main() +``` + +Save as `tools/modbus_vc_setup.py` and run: + +```bash +python3 tools/modbus_vc_setup.py 192.168.1.100 \ + the_pill/MODBUS/Deye/the_pill_mbsa_deye_vc.shelly.js +``` + +--- + +## Cleanup — Remove all VCs + +If you need to start over, delete all VCs created for the device. + +**Deye / JK200 / ST802** (9 × number + 1 group): +```bash +for id in 200 201 202 203 204 205 206 207 208; do + curl -s -X POST "http://${DEVICE}/rpc/Virtual.Delete" \ + -H "Content-Type: application/json" \ + -d "{\"key\":\"number:${id}\"}" +done +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Delete" \ + -H "Content-Type: application/json" \ + -d '{"key":"group:200"}' +``` + +**MB308V** (2 × button + 6 × number + 1 group): +```bash +# Remove buttons +for id in 200 201; do + curl -s -X POST "http://${DEVICE}/rpc/Virtual.Delete" \ + -H "Content-Type: application/json" \ + -d "{\"key\":\"button:${id}\"}" +done +# Remove number VCs (200-205) +for id in 200 201 202 203 204 205; do + curl -s -X POST "http://${DEVICE}/rpc/Virtual.Delete" \ + -H "Content-Type: application/json" \ + -d "{\"key\":\"number:${id}\"}" +done +# Remove group +curl -s -X POST "http://${DEVICE}/rpc/Virtual.Delete" \ + -H "Content-Type: application/json" \ + -d '{"key":"group:200"}' +``` diff --git a/skills/rs485-for-the-pill.md b/skills/rs485-for-the-pill.md new file mode 100644 index 0000000..e2c2f79 --- /dev/null +++ b/skills/rs485-for-the-pill.md @@ -0,0 +1,41 @@ +# Skill: RS485 Communication for "The Pill" (5-Terminal Add-on) + +## **Description** +This skill provides the technical framework for configuring **The Pill** to communicate via **RS485** using the **5-Terminal Add-on**. It specifically maps the transition from standard UART to a Half-Duplex RS485 interface, utilizing **IO3** for direction control. + +--- + +## **Technical Wiring & Signal Mapping** +In RS485 mode, the terminal functions shift from independent Transmit/Receive lines to a differential pair system with active flow control. + +| 5-Terminal Add-on Pin | RS485 Function | 3rd Party Device Pin | Direction | Description | +| :--- | :--- | :--- | :---: | :--- | +| **GND** | **GND** | GND | <---> | Common ground reference for signal stability. | +| **IO1 (TX)** | **Data B / (-)** | B / D- | <---> | Inverting differential signal line. | +| **IO2 (RX)** | **Data A / (+)** | A / D+ | <---> | Non-inverting differential signal line. | +| **IO3** | **RI / DO** | DE / RE | <---- | **Direction Control:** High = Transmit / Low = Receive. | +| **5V** | **VCC** | 5V | ----> | Power supply for the 3rd party transceiver. | + + + +--- + +## **Implementation Requirements** + +### **1. Physical Layer (Hardware)** +* **Differential Pair:** IO1 and IO2 form the balanced line. Ensure these wires are twisted together if the cable run is long to minimize electromagnetic interference (EMI). +* **Flow Control:** Connect **IO3** to the **Data Enable (DE)** and **Receiver Enable (RE)** pins of your RS485 transceiver. + +### **2. Software Logic (Half-Duplex)** +Since RS485 is half-duplex, you cannot "talk" and "listen" at the same time. The firmware must manage the state of **IO3**: +* **Transmit Mode:** Set `IO3` to **HIGH** immediately before sending the data packet. +* **Receive Mode:** Set `IO3` to **LOW** immediately after the final byte has been physically shifted out of the UART buffer. + +### **3. Termination & Bias** +* **120Ω Resistor:** If the bus is longer than 10 meters, place a 120Ω resistor across IO1 and IO2 at the physical end of the bus to prevent signal reflections. +* **Common Ground:** Always connect the GND pin to prevent common-mode voltage spikes from damaging the hardware. + +--- + +## **Quick Integration Guide** +> **AI Instruction:** "To enable RS485 on The Pill, wire the A/B lines to IO2 and IO1. Use **IO3** as your toggle: Pull it **HIGH** to 'Talk' and **LOW** to 'Listen'. If you experience data corruption over long distances, ensure you have a common ground and a 120Ω termination resistor." \ No newline at end of file diff --git a/skills/shelly-script-deploy.md b/skills/shelly-script-deploy.md new file mode 100644 index 0000000..984f7fc --- /dev/null +++ b/skills/shelly-script-deploy.md @@ -0,0 +1,392 @@ +# Shelly Script Deploy & Monitor Skill + +Step-by-step procedures for uploading `.shelly.js` scripts to a Shelly gen-3 +device and reading back its live log output. Synthesised from hands-on testing +with a Shelly Pill (S3SN-0U53X, gen 3, no auth). + +--- + +## Prerequisites + +- Python 3 (stdlib only — no pip installs needed) +- Device reachable on the local network +- Auth disabled on the device, or credentials known + +--- + +## 1. Verify device reachability + +```bash +curl -s http:///rpc/Shelly.GetDeviceInfo +``` + +Expected fields: `id`, `model`, `gen`, `fw_id`, `auth_en`. +If `auth_en` is `true` add `--user admin:` to every subsequent call. + +--- + +## 2. List existing script slots + +```bash +curl -s http:///rpc/Script.List +``` + +Returns an array of `{id, name, enable, running}` objects. +Note the `id` of the slot you want to use (or create a new one in step 3). + +--- + +## 3. Prepare the script file + +### 3a. Check for non-ASCII characters + +The Shelly firmware JSON parser rejects non-ASCII bytes inside the `code` field +and returns `HTTP 500: "Missing or bad argument 'code'!"`. +Common offenders in JS source: `deg` signs (`°`), dashes (`--`, `->`), arrows (`->`). + +Run this check before every upload: + +```python +with open("script.shelly.js") as f: + code = f.read() + +bad = [(i, repr(c)) for i, c in enumerate(code) if ord(c) > 127] +if bad: + print(f"Non-ASCII chars found: {len(bad)}") + for pos, ch in bad[:20]: + line = code[:pos].count('\n') + 1 + print(f" line {line}: {ch}") +``` + +### 3b. Replace non-ASCII with ASCII equivalents + +```python +replacements = [ + ('\u00b0', 'deg'), # degree sign + ('\u2013', '-'), # en dash + ('\u2014', '--'), # em dash + ('\u2192', '->'), # rightwards arrow +] +for src, dst in replacements: + code = code.replace(src, dst) +``` + +Save the cleaned code back to disk before uploading. + +--- + +## 4. Manage the script slot + +### Create a new slot + +```bash +curl -s -X POST http:///rpc/Script.Create \ + -H "Content-Type: application/json" \ + -d '{"name":"my_script"}' +# returns {"id": N} +``` + +### Delete a slot (to start clean) + +```bash +curl -s -X POST http:///rpc/Script.Delete \ + -H "Content-Type: application/json" \ + -d '{"id": N}' +``` + +--- + +## 5. Upload in chunks + +### Why chunks? + +The device HTTP server rejects bodies larger than ~8 KB (`HTTP 413`). +Real script code contains JSON-special characters (`"`, `\n`, etc.) that inflate +the JSON-encoded payload, so the safe working chunk size is **4000 source chars** +(produces payloads of ~4.3 KB, well under the limit). + +### Upload script + +```python +import json, urllib.request + +DEVICE = "http://" +SCRIPT_ID = N # from Script.Create +CHUNK = 4000 # safe for real JS code + +with open("script.shelly.js") as f: + code = f.read() + +total = len(code) +offset = 0 +chunk_n = 0 + +while offset < total: + chunk = code[offset:offset + CHUNK] + payload = json.dumps({ + "id": SCRIPT_ID, + "code": chunk, + "append": offset > 0 # False for first chunk, True for all subsequent + }).encode() + + req = urllib.request.Request( + DEVICE + "/rpc/Script.PutCode", + data = payload, + headers = {"Content-Type": "application/json"} + ) + with urllib.request.urlopen(req, timeout=10) as r: + resp = json.loads(r.read().decode()) + + chunk_n += 1 + print(f"Chunk {chunk_n}: offset={offset} -> stored={resp['len']} chars") + offset += CHUNK + +# Verify: stored length must equal source length +stored = resp["len"] +print(f"\nSource: {total} | Stored: {stored} | Match: {stored == total}") +if stored != total: + raise RuntimeError("Upload truncated — delete slot and retry") +``` + +### Common upload errors + +| HTTP code | Body message | Cause | Fix | +|-----------|-------------|-------|-----| +| 413 | Payload Too Large | Chunk too large | Reduce `CHUNK` | +| 500 | Missing or bad argument 'code'! | Non-ASCII char in chunk | Run step 3a/3b | +| 500 | (other) | Slot ID does not exist | Run Script.Create first | + +--- + +## 6. Start and stop + +```bash +# Start +curl -s -X POST http:///rpc/Script.Start \ + -H "Content-Type: application/json" -d '{"id": N}' + +# Stop +curl -s -X POST http:///rpc/Script.Stop \ + -H "Content-Type: application/json" -d '{"id": N}' +``` + +--- + +## 7. Check runtime status + +```bash +curl -s "http:///rpc/Script.GetStatus?id=N" +``` + +Key fields: + +| Field | Meaning | +|-------|---------| +| `running` | `true` / `false` | +| `errors` | List of error type strings, e.g. `["syntax_error"]` | +| `error_msg` | Human-readable error with line/col | +| `mem_used` | Bytes of JS heap in use | +| `mem_peak` | Peak heap usage | +| `mem_free` | Remaining JS heap | +| `cpu` | CPU % used by the script | + +If `syntax_error` appears immediately after upload, the most common cause is a +**truncated upload** (stored length did not match source length in step 5). + +--- + +## 8. Stream live log output + +The device exposes a streaming HTTP endpoint. Script `print()` calls appear here +alongside firmware messages. + +### Stream and filter with Python + +```python +import urllib.request, threading, time, json + +DEVICE = "http://" +TAG = "[MY_SCRIPT]" # prefix used in your print() calls + +lines = [] +done = threading.Event() + +def stream(): + try: + with urllib.request.urlopen(DEVICE + "/debug/log", timeout=30) as r: + while not done.is_set(): + line = r.readline() + if not line: + break + lines.append(line.decode("utf-8", errors="replace").rstrip()) + except Exception as e: + lines.append(f"[stream ended: {e}]") + +# 1. Start the log stream first +t = threading.Thread(target=stream, daemon=True) +t.start() +time.sleep(0.4) # give the stream time to connect + +# 2. Restart the script so init() output is captured +for method in ("Script.Stop", "Script.Start"): + payload = json.dumps({"id": N}).encode() + req = urllib.request.Request( + DEVICE + f"/rpc/{method}", + data=payload, headers={"Content-Type": "application/json"} + ) + try: + urllib.request.urlopen(req, timeout=5) + except Exception: + pass + time.sleep(0.4) + +# 3. Collect for as long as needed +time.sleep(15) +done.set() + +# 4. Print results, highlighting script lines +for l in lines: + marker = ">>>" if TAG in l else " " + print(marker, l) +``` + +### Notes on the log stream + +- The stream is a persistent HTTP connection; it blocks until the device closes it. +- Only **one** external client can stream at a time — if another client is already + connected (e.g. the Shelly app at another IP), the new connection still receives + new events but may show a "Streaming logs to X.X.X.X" notice first. +- Script `print()` output appears as: ` [timestamp] I ` +- Firmware messages appear as: ` [timestamp] I I : ` + +--- + +## 9. Full deploy-and-monitor workflow (one script) + +```python +#!/usr/bin/env python3 +""" +Deploy a Shelly script and monitor its startup output. +Usage: python deploy.py +""" +import json, sys, time, threading, urllib.request + +DEVICE = "http://" + sys.argv[1] +SLOT = int(sys.argv[2]) +SCRIPT = sys.argv[3] +CHUNK = 4000 +LOG_SEC = 15 + +# -- load & sanitise -- +with open(SCRIPT) as f: + code = f.read() + +for src, dst in [('\u00b0','deg'),('\u2013','-'),('\u2014','--'),('\u2192','->')]: + code = code.replace(src, dst) + +bad = [c for c in code if ord(c) > 127] +if bad: + sys.exit(f"Non-ASCII chars remaining: {set(bad)}") + +# -- helpers -- +def rpc(method, params): + payload = json.dumps(params).encode() + req = urllib.request.Request( + DEVICE + "/rpc/" + method, + data=payload, headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + sys.exit(f"HTTP {e.code} on {method}: {e.read().decode()}") + +# -- log stream -- +log_lines = [] +log_done = threading.Event() + +def _stream(): + try: + with urllib.request.urlopen(DEVICE + "/debug/log", timeout=LOG_SEC+5) as r: + while not log_done.is_set(): + l = r.readline() + if not l: + break + log_lines.append(l.decode("utf-8", errors="replace").rstrip()) + except Exception: + pass + +log_thread = threading.Thread(target=_stream, daemon=True) +log_thread.start() +time.sleep(0.4) + +# -- stop existing -- +try: rpc("Script.Stop", {"id": SLOT}) +except SystemExit: pass +time.sleep(0.3) + +# -- upload -- +total, offset, last_resp = len(code), 0, {} +print(f"Uploading {total} chars to slot {SLOT}...") +while offset < total: + chunk = code[offset:offset+CHUNK] + last_resp = rpc("Script.PutCode", {"id": SLOT, "code": chunk, "append": offset > 0}) + print(f" {offset+len(chunk)}/{total}", end="\r") + offset += CHUNK +print() + +stored = last_resp.get("len", -1) +if stored != total: + sys.exit(f"Upload mismatch: source={total} stored={stored}") +print(f"Upload verified: {stored} chars stored.") + +# -- start -- +rpc("Script.Start", {"id": SLOT}) +print(f"Script started. Collecting log for {LOG_SEC}s...\n") +time.sleep(LOG_SEC) +log_done.set() + +# -- print log -- +print("--- Log output ---") +for l in log_lines: + print(l) + +# -- final status -- +status = rpc("Script.GetStatus", {"id": SLOT}) +print(f"\n--- Script status ---") +print(f" running : {status.get('running')}") +print(f" errors : {status.get('errors')}") +print(f" error_msg: {status.get('error_msg')}") +print(f" mem_used : {status.get('mem_used')} B") +print(f" mem_free : {status.get('mem_free')} B") +``` + +--- + +## Quick-reference cheatsheet + +```bash +# Device info +curl -s http://DEVICE/rpc/Shelly.GetDeviceInfo + +# List scripts +curl -s http://DEVICE/rpc/Script.List + +# Create slot +curl -s -X POST http://DEVICE/rpc/Script.Create \ + -H "Content-Type: application/json" -d '{"name":"my_script"}' + +# Delete slot +curl -s -X POST http://DEVICE/rpc/Script.Delete \ + -H "Content-Type: application/json" -d '{"id":N}' + +# Start / Stop +curl -s -X POST http://DEVICE/rpc/Script.Start -H "Content-Type: application/json" -d '{"id":N}' +curl -s -X POST http://DEVICE/rpc/Script.Stop -H "Content-Type: application/json" -d '{"id":N}' + +# Status +curl -s "http://DEVICE/rpc/Script.GetStatus?id=N" + +# Stream log (Ctrl+C to stop) +curl -s -N http://DEVICE/debug/log +``` diff --git a/the_pill/MODBUS/ComWinTop/README.md b/the_pill/MODBUS/ComWinTop/README.md index acce189..8c03f8c 100644 --- a/the_pill/MODBUS/ComWinTop/README.md +++ b/the_pill/MODBUS/ComWinTop/README.md @@ -1,155 +1,25 @@ -# ComWinTop CWT-MB308V GPIO Expander +# ComWinTop CWT-MB308V MODBUS Examples -> **Under Development** - This script is currently under development and may not be fully functional. +Examples for ComWinTop MB308V over RS485 MODBUS-RTU on The Pill. -Script for communicating with the **ComWinTop CWT-MB308V** IO module via MODBUS-RTU over RS485/UART using The Pill. +## Problem (The Story) +A project needs many analog/digital channels, but The Pill has limited local IO. MB308V expands IO over RS485 and these scripts make relay, input, and analog channels accessible through Shelly logic and optional virtual components. -## Files - -### [mb308v.shelly.js](mb308v.shelly.js) - -**CWT-MB308V GPIO Expander Example** - Complete example for the ComWinTop MB308V IO module. - ---- - -## Hardware Requirements - -- Shelly device with UART (e.g., **The Pill**) -- RS485 transceiver module (e.g., MAX485, SP485) -- ComWinTop CWT-MB308V IO module (7-35VDC supply) - -### Device Specifications - -| Feature | Detail | -|---|---| -| Analog Inputs (AI) | 8 channels — 4-20mA / 0-5V / 0-10V (configurable per channel) | -| Analog Outputs (AO) | 4 channels — 0-10V / 4-20mA | -| Digital Inputs (DI) | 8 channels — dry contact / NPN | -| Digital Outputs (DO) | 12 relay outputs | - -### Wiring +## Persona +- Panel builder adding remote IO to an automation cabinet +- Building automation integrator needing DI/DO/AI/AO in one module +- Technician who wants quick field diagnostics from Shelly logs -**RS485 module to Shelly (The Pill):** - -| RS485 Module | Shelly / The Pill | -|---|---| -| RO (Receiver Output) | RX (GPIO) | -| DI (Driver Input) | TX (GPIO) | -| VCC | 3.3V or 5V | -| GND | GND | - -**RS485 module to CWT-MB308V:** +## Files +- [`mb308v.shelly.js`](mb308v.shelly.js): core MB308V MODBUS example +- [`mb308v_vc.shelly.js`](mb308v_vc.shelly.js): same integration with Virtual Components -| RS485 Module | CWT-MB308V | +## RS485 Wiring (The Pill 5-Terminal Add-on) +| The Pill Pin | MB308V Side | |---|---| -| A (D+) | A (D+) | -| B (D-) | B (D-) | - -**Power:** Connect 7-35VDC to the MB308V power terminals separately. - -**UART Settings:** 9600 baud, 8N1, Slave ID: 1 (default) - ---- - -## Register Map - -| Type | Function Code | Address Range | Count | -|---|---|---|---| -| Digital Outputs (DO) | FC 0x01 (read) / FC 0x05 (write) | 0–11 | 12 coils | -| Digital Inputs (DI) | FC 0x02 (read) | 0–7 | 8 inputs | -| Analog Outputs (AO) | FC 0x03 (read) / FC 0x06 (write) | 0–3 | 4 registers | -| Analog Inputs (AI) | FC 0x04 (read) | 0–7 | 8 registers | - ---- - -## API Methods - -```javascript -readDigitalInputs(callback) // Read 8 DI -readDigitalOutputs(callback) // Read 12 DO (relays) -writeDigitalOutput(channel, value, cb) // Set relay (0-11, true/false) -readAnalogInputs(callback) // Read 8 AI -readAnalogOutputs(callback) // Read 4 AO -writeAnalogOutput(channel, value, cb) // Set AO (0-3, 0-24000) - -// Conversion helpers -aiToMilliamps(raw) // Convert AI to mA (4-20mA mode) -aiToVoltage(raw) // Convert AI to V (0-10V mode) -milliampsToAo(mA) // Convert mA to AO value -voltageToAo(volts) // Convert V to AO value -``` - ---- - -## Usage Examples - -**Read all digital inputs:** - -```javascript -readDigitalInputs(function(err, inputs) { - if (err) { - print("Error: " + err); - return; - } - for (var i = 0; i < inputs.length; i++) { - print("DI" + i + ": " + (inputs[i] ? "ON" : "OFF")); - } -}); -``` - -**Control relay output:** - -```javascript -// Turn ON relay 0 -writeDigitalOutput(0, true, function(err, success) { - if (success) print("Relay 0 ON"); -}); - -// Turn OFF relay 5 -writeDigitalOutput(5, false, function(err, success) { - if (success) print("Relay 5 OFF"); -}); -``` - -**Read analog inputs (4-20mA sensors):** - -```javascript -readAnalogInputs(function(err, values) { - if (err) return; - for (var i = 0; i < values.length; i++) { - var mA = aiToMilliamps(values[i]); - print("AI" + i + ": " + mA.toFixed(2) + " mA"); - } -}); -``` - -**Set analog output (0-10V mode):** - -```javascript -// Set AO0 to 5V -var rawValue = voltageToAo(5.0); -writeAnalogOutput(0, rawValue, function(err, success) { - if (success) print("AO0 set to 5V"); -}); -``` - ---- - -## Configuration - -```javascript -var CONFIG = { - BAUD_RATE: 9600, // 9600, 19200, 38400, 115200 - MODE: "8N1", // "8N1", "8E1", "8O1" - RESPONSE_TIMEOUT: 1000, // ms - DEBUG: true -}; -``` - ---- - -## References +| `IO1 (TX)` -> `B (D-)` | RS485 B | +| `IO2 (RX)` -> `A (D+)` | RS485 A | +| `IO3` -> `DE/RE` | transceiver direction | +| `GND` -> `GND` | common reference | -- [ComWinTop CWT-MB308V Product Page](https://store.comwintop.com/products/cwt-mb308v-8ai-4ao-8di-12do-rs485-rs232-ethernet-modbus-rtu-tcp-io-acquisition-module) -- [MB308V Python Driver (reference)](https://github.com/bgerp/ztm/blob/master/Zontromat/devices/vendors/cwt/mb308v/mb308v.py) -- [MODBUS Protocol Specification](https://modbus.org/specs.php) +Default communication: `9600`, `8N1`, slave `1`. diff --git a/the_pill/MODBUS/ComWinTop/mb308v.shelly.js b/the_pill/MODBUS/ComWinTop/mb308v.shelly.js index 29585cd..fd8b7bb 100644 --- a/the_pill/MODBUS/ComWinTop/mb308v.shelly.js +++ b/the_pill/MODBUS/ComWinTop/mb308v.shelly.js @@ -18,12 +18,12 @@ * - 8 Digital Inputs (DI): Dry contact / NPN * - 12 Digital Outputs (DO): Relay outputs * - * Hardware connection: - * - RS485 Module A (D+) -> MB308V A (D+) - * - RS485 Module B (D-) -> MB308V B (D-) - * - RS485 Module RO -> Shelly RX (GPIO) - * - RS485 Module DI -> Shelly TX (GPIO) - * - Power: 7-35VDC to MB308V + * The Pill 5-Terminal Add-on wiring: + * IO1 (TX) ─── B (D-) ──> MB308V B (D-) + * IO2 (RX) ─── A (D+) ──> MB308V A (D+) + * IO3 ─── DE/RE ── direction control (automatic) + * GND ─── GND ──> MB308V GND + * Power: 7-35VDC to MB308V (separate supply) * * Default settings: 9600 baud, 8N1, Slave ID: 1 * @@ -48,25 +48,70 @@ var CONFIG = { }; /* === CWT-MB308V REGISTER MAP === */ -var MB308V = { - // Digital Outputs (Relays) - 12 coils - DO_COUNT: 12, - DO_START_ADDR: 0, - - // Digital Inputs - 8 inputs - DI_COUNT: 8, - DI_START_ADDR: 0, - - // Analog Outputs - 4 registers (0-24000 = 0-10V or 4-20mA) - AO_COUNT: 4, - AO_START_ADDR: 0, - AO_MAX_VALUE: 24000, - - // Analog Inputs - 8 registers (0-10216 typical for 4-20mA) - AI_COUNT: 8, - AI_START_ADDR: 0, - AI_MAX_VALUE: 10216 -}; + +// Calibration constants for analog channel conversion +var AI_MAX_VALUE = 10216; // Raw full-scale for AI (4-20mA or 0-5V/0-10V) +var AO_MAX_VALUE = 24000; // Raw full-scale for AO (0-10V or 4-20mA) + +var ENTITIES = [ + // + // --- Digital Inputs (DI 0-7, FC 0x02 Read Discrete Inputs) --- + // + { name: "DI 0", units: "-", reg: { addr: 0, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "DI 1", units: "-", reg: { addr: 1, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "DI 2", units: "-", reg: { addr: 2, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "DI 3", units: "-", reg: { addr: 3, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "DI 4", units: "-", reg: { addr: 4, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "DI 5", units: "-", reg: { addr: 5, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "DI 6", units: "-", reg: { addr: 6, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "DI 7", units: "-", reg: { addr: 7, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + // + // --- Digital Outputs / Relays (DO 0-11, FC 0x01 Read Coils) --- + // + { name: "DO 0", units: "-", reg: { addr: 0, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 1", units: "-", reg: { addr: 1, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 2", units: "-", reg: { addr: 2, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 3", units: "-", reg: { addr: 3, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 4", units: "-", reg: { addr: 4, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 5", units: "-", reg: { addr: 5, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 6", units: "-", reg: { addr: 6, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 7", units: "-", reg: { addr: 7, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 8", units: "-", reg: { addr: 8, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 9", units: "-", reg: { addr: 9, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 10", units: "-", reg: { addr: 10, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 11", units: "-", reg: { addr: 11, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + // + // --- Analog Inputs (AI 0-7, FC 0x04 Read Input Registers) --- + // Raw range: 0 - AI_MAX_VALUE (10216 for 4-20mA mode) + // + { name: "AI 0", units: "raw", reg: { addr: 0, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "AI 1", units: "raw", reg: { addr: 1, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "AI 2", units: "raw", reg: { addr: 2, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "AI 3", units: "raw", reg: { addr: 3, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "AI 4", units: "raw", reg: { addr: 4, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "AI 5", units: "raw", reg: { addr: 5, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "AI 6", units: "raw", reg: { addr: 6, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "AI 7", units: "raw", reg: { addr: 7, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + // + // --- Analog Outputs (AO 0-3, FC 0x03 Read/Write Holding Registers) --- + // Raw range: 0 - AO_MAX_VALUE (24000 = full-scale 0-10V or 4-20mA) + // + { name: "AO 0", units: "raw", reg: { addr: 0, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "AO 1", units: "raw", reg: { addr: 1, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "AO 2", units: "raw", reg: { addr: 2, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "AO 3", units: "raw", reg: { addr: 3, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, +]; + +// Return all entities whose register type matches rtype +function entitiesByRtype(rtype) { + var result = []; + for (var i = 0; i < ENTITIES.length; i++) { + if (ENTITIES[i].reg.rtype === rtype) { + result.push(ENTITIES[i]); + } + } + return result; +} /* === MODBUS FUNCTION CODES === */ var FC = { @@ -300,14 +345,15 @@ function clearTimeout() { * @param {function} callback - callback(error, inputs[8]) */ function readDigitalInputs(callback) { - var data = [0x00, 0x00, 0x00, MB308V.DI_COUNT]; + var diEntities = entitiesByRtype(0x02); + var data = [0x00, diEntities[0].reg.addr, 0x00, diEntities.length]; sendRequest(FC.READ_DISCRETE_INPUTS, data, function(err, response) { if (err) { callback(err, null); return; } var inputs = []; - for (var i = 0; i < MB308V.DI_COUNT; i++) { + for (var i = 0; i < diEntities.length; i++) { var byteIdx = Math.floor(i / 8) + 1; var bitIdx = i % 8; if (byteIdx < response.length) { @@ -323,14 +369,15 @@ function readDigitalInputs(callback) { * @param {function} callback - callback(error, relays[12]) */ function readDigitalOutputs(callback) { - var data = [0x00, 0x00, 0x00, MB308V.DO_COUNT]; + var doEntities = entitiesByRtype(0x01); + var data = [0x00, doEntities[0].reg.addr, 0x00, doEntities.length]; sendRequest(FC.READ_COILS, data, function(err, response) { if (err) { callback(err, null); return; } var relays = []; - for (var i = 0; i < MB308V.DO_COUNT; i++) { + for (var i = 0; i < doEntities.length; i++) { var byteIdx = Math.floor(i / 8) + 1; var bitIdx = i % 8; if (byteIdx < response.length) { @@ -348,11 +395,12 @@ function readDigitalOutputs(callback) { * @param {function} callback - callback(error, success) */ function writeDigitalOutput(channel, value, callback) { - if (channel < 0 || channel >= MB308V.DO_COUNT) { + var doEntities = entitiesByRtype(0x01); + if (channel < 0 || channel >= doEntities.length) { callback("Invalid channel: " + channel, false); return; } - var data = [0x00, channel & 0xFF, value ? 0xFF : 0x00, 0x00]; + var data = [0x00, doEntities[channel].reg.addr & 0xFF, value ? 0xFF : 0x00, 0x00]; sendRequest(FC.WRITE_SINGLE_COIL, data, function(err, response) { callback(err, !err); }); @@ -363,7 +411,8 @@ function writeDigitalOutput(channel, value, callback) { * @param {function} callback - callback(error, values[8]) */ function readAnalogInputs(callback) { - var data = [0x00, 0x00, 0x00, MB308V.AI_COUNT]; + var aiEntities = entitiesByRtype(0x04); + var data = [0x00, aiEntities[0].reg.addr, 0x00, aiEntities.length]; sendRequest(FC.READ_INPUT_REGISTERS, data, function(err, response) { if (err) { callback(err, null); @@ -383,7 +432,8 @@ function readAnalogInputs(callback) { * @param {function} callback - callback(error, values[4]) */ function readAnalogOutputs(callback) { - var data = [0x00, 0x00, 0x00, MB308V.AO_COUNT]; + var aoEntities = entitiesByRtype(0x03); + var data = [0x00, aoEntities[0].reg.addr, 0x00, aoEntities.length]; sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, response) { if (err) { callback(err, null); @@ -401,18 +451,19 @@ function readAnalogOutputs(callback) { /** * Write single Analog Output * @param {number} channel - AO channel (0-3) - * @param {number} value - Value (0-24000) + * @param {number} value - Value (0-AO_MAX_VALUE) * @param {function} callback - callback(error, success) */ function writeAnalogOutput(channel, value, callback) { - if (channel < 0 || channel >= MB308V.AO_COUNT) { + var aoEntities = entitiesByRtype(0x03); + if (channel < 0 || channel >= aoEntities.length) { callback("Invalid channel: " + channel, false); return; } if (value < 0) value = 0; - if (value > MB308V.AO_MAX_VALUE) value = MB308V.AO_MAX_VALUE; + if (value > AO_MAX_VALUE) value = AO_MAX_VALUE; - var data = [0x00, channel & 0xFF, (value >> 8) & 0xFF, value & 0xFF]; + var data = [0x00, aoEntities[channel].reg.addr & 0xFF, (value >> 8) & 0xFF, value & 0xFF]; sendRequest(FC.WRITE_SINGLE_REGISTER, data, function(err, response) { callback(err, !err); }); @@ -420,12 +471,12 @@ function writeAnalogOutput(channel, value, callback) { /** * Convert raw AI value to milliamps (4-20mA mode) - * @param {number} raw - Raw value (0-10216) + * @param {number} raw - Raw value (0-AI_MAX_VALUE) * @returns {number} Current in mA */ function aiToMilliamps(raw) { - // 0 = 4mA, 10216 = 20mA (typical) - return 4.0 + (raw / MB308V.AI_MAX_VALUE) * 16.0; + // 0 = 4mA, AI_MAX_VALUE = 20mA (typical) + return 4.0 + (raw / AI_MAX_VALUE) * 16.0; } /** @@ -434,7 +485,7 @@ function aiToMilliamps(raw) { * @returns {number} Voltage in V */ function aiToVoltage(raw) { - return (raw / MB308V.AI_MAX_VALUE) * 10.0; + return (raw / AI_MAX_VALUE) * 10.0; } /** @@ -445,7 +496,7 @@ function aiToVoltage(raw) { function milliampsToAo(mA) { if (mA < 4) mA = 4; if (mA > 20) mA = 20; - return Math.round(((mA - 4) / 16.0) * MB308V.AO_MAX_VALUE); + return Math.round(((mA - 4) / 16.0) * AO_MAX_VALUE); } /** @@ -456,7 +507,7 @@ function milliampsToAo(mA) { function voltageToAo(volts) { if (volts < 0) volts = 0; if (volts > 10) volts = 10; - return Math.round((volts / 10.0) * MB308V.AO_MAX_VALUE); + return Math.round((volts / 10.0) * AO_MAX_VALUE); } /* === DEMO POLLING === */ diff --git a/the_pill/MODBUS/ComWinTop/mb308v_vc.shelly.js b/the_pill/MODBUS/ComWinTop/mb308v_vc.shelly.js new file mode 100644 index 0000000..b7fa1e0 --- /dev/null +++ b/the_pill/MODBUS/ComWinTop/mb308v_vc.shelly.js @@ -0,0 +1,516 @@ +/** + * @title CWT-MB308V MODBUS example + Virtual Components + * @description Example integration for the ComWinTop MB308V IO module over + * MODBUS-RTU with Virtual Component integration. Exposes 2 relay buttons, + * 2 digital input displays, 2 analog output sliders, and 2 analog input + * progress bars grouped in the Shelly web UI. + * @status under development + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/MODBUS/ComWinTop/mb308v_vc.shelly.js + */ + +/** + * CWT-MB308V MODBUS IO Module + Virtual Components (demo layout) + * + * Demonstrates the Virtual Component UI for the ComWinTop CWT-MB308V + * GPIO expander via MODBUS-RTU: + * + * button:200 Relay 0 toggle -- press to flip DO 0 + * button:201 Relay 1 toggle -- press to flip DO 1 + * number:200 Digital Input 0 -- live 0/1 display (read-only) + * number:201 Digital Input 1 -- live 0/1 display (read-only) + * number:202 Analog Output 0 -- slider 0-24000 (writable) + * number:203 Analog Output 1 -- slider 0-24000 (writable) + * number:204 Analog Input 0 -- progress bar 0-10216 (read-only) + * number:205 Analog Input 1 -- progress bar 0-10216 (read-only) + * group:200 MB308V Demo -- groups all above + * + * The Pill 5-Terminal Add-on wiring: + * IO1 (TX) ─── B (D-) ──> MB308V B (D-) + * IO2 (RX) ─── A (D+) ──> MB308V A (D+) + * IO3 ─── DE/RE ── direction control (automatic) + * GND ─── GND ──> MB308V GND + * Power: 7-35VDC to MB308V (separate supply) + * + * Default settings: 9600 baud, 8N1, Slave ID: 1 + * + * Pre-create VCs with skills/modbus-vc-deploy.md before uploading. + * + * Reference: https://github.com/bgerp/ztm/blob/master/Zontromat/devices/vendors/cwt/mb308v/mb308v.py + */ + +/* === CONFIG === */ +var CONFIG = { + BAUD_RATE: 9600, + MODE: "8N1", + SLAVE_ID: 1, + RESPONSE_TIMEOUT: 1000, + POLL_INTERVAL: 5000, + DEBUG: true +}; + +/* === CWT-MB308V REGISTER MAP === */ + +var AI_MAX_VALUE = 10216; // Raw full-scale for AI (4-20mA or 0-5V/0-10V) +var AO_MAX_VALUE = 24000; // Raw full-scale for AO (0-10V or 4-20mA) + +/* + * ENTITIES documents all channels. + * + * vcId assignment for OUTPUT VCs (script writes value via updateVc): + * DI 0 -> number:200 DI 1 -> number:201 + * AI 0 -> number:204 AI 1 -> number:205 + * + * INPUT VCs (user sets value, script reads / reacts): + * DO 0 -> button:200 DO 1 -> button:201 (managed via event handler) + * AO 0 -> number:202 AO 1 -> number:203 (polled each cycle) + * These are kept null in ENTITIES; handles are stored in state.vc.* + */ +var ENTITIES = [ + // + // --- Digital Inputs (DI 0-7, FC 0x02) --- + // + { name: "DI 0", units: "-", reg: { addr: 0, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: "number:200", handle: null, vcHandle: null }, + { name: "DI 1", units: "-", reg: { addr: 1, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: "number:201", handle: null, vcHandle: null }, + { name: "DI 2", units: "-", reg: { addr: 2, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "DI 3", units: "-", reg: { addr: 3, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "DI 4", units: "-", reg: { addr: 4, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "DI 5", units: "-", reg: { addr: 5, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "DI 6", units: "-", reg: { addr: 6, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "DI 7", units: "-", reg: { addr: 7, rtype: 0x02, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + // + // --- Digital Outputs / Relays (DO 0-11, FC 0x01) --- + // Controlled via button:200 / button:201 (see state.vc.btn) + // + { name: "DO 0", units: "-", reg: { addr: 0, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 1", units: "-", reg: { addr: 1, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 2", units: "-", reg: { addr: 2, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 3", units: "-", reg: { addr: 3, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 4", units: "-", reg: { addr: 4, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 5", units: "-", reg: { addr: 5, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 6", units: "-", reg: { addr: 6, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 7", units: "-", reg: { addr: 7, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 8", units: "-", reg: { addr: 8, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 9", units: "-", reg: { addr: 9, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 10", units: "-", reg: { addr: 10, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "DO 11", units: "-", reg: { addr: 11, rtype: 0x01, itype: "bool", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + // + // --- Analog Inputs (AI 0-7, FC 0x04) --- + // + { name: "AI 0", units: "raw", reg: { addr: 0, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: "number:204", handle: null, vcHandle: null }, + { name: "AI 1", units: "raw", reg: { addr: 1, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: "number:205", handle: null, vcHandle: null }, + { name: "AI 2", units: "raw", reg: { addr: 2, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "AI 3", units: "raw", reg: { addr: 3, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "AI 4", units: "raw", reg: { addr: 4, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "AI 5", units: "raw", reg: { addr: 5, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "AI 6", units: "raw", reg: { addr: 6, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { name: "AI 7", units: "raw", reg: { addr: 7, rtype: 0x04, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + // + // --- Analog Outputs (AO 0-3, FC 0x03) --- + // User-driven via number:202 / number:203 sliders (see state.vc.ao) + // + { name: "AO 0", units: "raw", reg: { addr: 0, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "AO 1", units: "raw", reg: { addr: 1, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "AO 2", units: "raw", reg: { addr: 2, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { name: "AO 3", units: "raw", reg: { addr: 3, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, +]; + +function entitiesByRtype(rtype) { + var result = []; + for (var i = 0; i < ENTITIES.length; i++) { + if (ENTITIES[i].reg.rtype === rtype) result.push(ENTITIES[i]); + } + return result; +} + +/* === MODBUS FUNCTION CODES === */ +var FC = { + READ_COILS: 0x01, + READ_DISCRETE_INPUTS: 0x02, + READ_HOLDING_REGISTERS: 0x03, + READ_INPUT_REGISTERS: 0x04, + WRITE_SINGLE_COIL: 0x05, + WRITE_SINGLE_REGISTER: 0x06 +}; + +/* === CRC-16 TABLE (MODBUS polynomial 0xA001) === */ +var CRC_TABLE = [ + 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, + 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440, + 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40, + 0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841, + 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40, + 0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41, + 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641, + 0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040, + 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240, + 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441, + 0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41, + 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840, + 0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41, + 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40, + 0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640, + 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041, + 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240, + 0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441, + 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41, + 0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840, + 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41, + 0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40, + 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640, + 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041, + 0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241, + 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440, + 0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40, + 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841, + 0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40, + 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41, + 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641, + 0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040 +]; + +/* === STATE === */ +var state = { + uart: null, + rxBuffer: [], + isReady: false, + pendingRequest: null, + responseTimer: null, + pollTimer: null, + // Relay toggle states (DO 0 and DO 1) + doState: [false, false], + // Last AO values written to hardware (-1 = not yet sent) + lastAo: [-1, -1], + // Virtual component handles for interactive VCs + vc: { + btn: [null, null], // button:200, button:201 (relay toggles) + ao: [null, null], // number:202, number:203 (AO sliders) + } +}; + +/* === HELPERS === */ + +function toHex(n) { + n = n & 0xFF; + return (n < 16 ? "0" : "") + n.toString(16).toUpperCase(); +} + +function bytesToHex(bytes) { + var hex = ""; + for (var i = 0; i < bytes.length; i++) { + hex += toHex(bytes[i]); + if (i < bytes.length - 1) hex += " "; + } + return hex; +} + +function debug(msg) { + if (CONFIG.DEBUG) print("[MB308V] " + msg); +} + +function calcCRC(bytes) { + var crc = 0xFFFF; + for (var i = 0; i < bytes.length; i++) { + crc = (crc >> 8) ^ CRC_TABLE[(crc ^ bytes[i]) & 0xFF]; + } + return crc; +} + +function bytesToStr(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] & 0xFF); + return s; +} + +function buildFrame(slaveAddr, functionCode, data) { + var frame = [slaveAddr & 0xFF, functionCode & 0xFF]; + if (data) { + for (var i = 0; i < data.length; i++) frame.push(data[i] & 0xFF); + } + var crc = calcCRC(frame); + frame.push(crc & 0xFF); + frame.push((crc >> 8) & 0xFF); + return frame; +} + +/* === VIRTUAL COMPONENT (output: script -> VC) === */ + +function updateVc(entity, value) { + if (!entity || !entity.vcHandle) return; + entity.vcHandle.setValue(value); + debug(entity.name + " -> " + value); +} + +/* === MODBUS CORE === */ + +function sendRequest(functionCode, data, callback) { + if (!state.isReady) { callback("Not initialized", null); return; } + if (state.pendingRequest) { callback("Request pending", null); return; } + + var frame = buildFrame(CONFIG.SLAVE_ID, functionCode, data); + debug("TX: " + bytesToHex(frame)); + + state.pendingRequest = { functionCode: functionCode, callback: callback }; + state.rxBuffer = []; + + state.responseTimer = Timer.set(CONFIG.RESPONSE_TIMEOUT, false, function() { + if (state.pendingRequest) { + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + debug("Timeout"); + cb("Timeout", null); + } + }); + + state.uart.write(bytesToStr(frame)); +} + +function onReceive(data) { + if (!data || data.length === 0) return; + for (var i = 0; i < data.length; i++) state.rxBuffer.push(data.charCodeAt(i) & 0xFF); + processResponse(); +} + +function processResponse() { + if (!state.pendingRequest) { state.rxBuffer = []; return; } + if (state.rxBuffer.length < 5) return; + + var fc = state.rxBuffer[1]; + + if (fc & 0x80) { + if (state.rxBuffer.length >= 5) { + var excFrame = state.rxBuffer.slice(0, 5); + var crc = calcCRC(excFrame.slice(0, 3)); + var recv = excFrame[3] | (excFrame[4] << 8); + if (crc === recv) { + clearTimer(); + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + state.rxBuffer = []; + cb("Exception: 0x" + toHex(state.rxBuffer[2]), null); + } + } + return; + } + + var expectedLen = getExpectedLength(fc); + if (expectedLen === 0 || state.rxBuffer.length < expectedLen) return; + + var frame = state.rxBuffer.slice(0, expectedLen); + var crc = calcCRC(frame.slice(0, expectedLen - 2)); + var recvCrc = frame[expectedLen - 2] | (frame[expectedLen - 1] << 8); + + if (crc !== recvCrc) { debug("CRC error"); return; } + + debug("RX: " + bytesToHex(frame)); + clearTimer(); + + var responseData = frame.slice(2, expectedLen - 2); + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + state.rxBuffer = []; + cb(null, responseData); +} + +function getExpectedLength(fc) { + switch (fc) { + case FC.READ_COILS: + case FC.READ_DISCRETE_INPUTS: + case FC.READ_HOLDING_REGISTERS: + case FC.READ_INPUT_REGISTERS: + return (state.rxBuffer.length >= 3) ? 3 + state.rxBuffer[2] + 2 : 0; + case FC.WRITE_SINGLE_COIL: + case FC.WRITE_SINGLE_REGISTER: + return 8; + default: + return 0; + } +} + +function clearTimer() { + if (state.responseTimer) { Timer.clear(state.responseTimer); state.responseTimer = null; } +} + +/* === MB308V API === */ + +function readDigitalInputs(callback) { + var diEnt = entitiesByRtype(0x02); + sendRequest(FC.READ_DISCRETE_INPUTS, [0x00, diEnt[0].reg.addr, 0x00, diEnt.length], function(err, resp) { + if (err) { callback(err, null); return; } + var inputs = []; + for (var i = 0; i < diEnt.length; i++) { + var byteIdx = Math.floor(i / 8) + 1; + var bitIdx = i % 8; + inputs.push((byteIdx < resp.length) ? (resp[byteIdx] >> bitIdx) & 0x01 : 0); + } + callback(null, inputs); + }); +} + +function readAnalogInputs(callback) { + var aiEnt = entitiesByRtype(0x04); + sendRequest(FC.READ_INPUT_REGISTERS, [0x00, aiEnt[0].reg.addr, 0x00, aiEnt.length], function(err, resp) { + if (err) { callback(err, null); return; } + var values = []; + for (var i = 1; i < resp.length - 1; i += 2) values.push((resp[i] << 8) | resp[i + 1]); + callback(null, values); + }); +} + +function writeDigitalOutput(channel, value, callback) { + var doEnt = entitiesByRtype(0x01); + if (channel < 0 || channel >= doEnt.length) { + if (callback) callback("Invalid channel: " + channel, false); + return; + } + var data = [0x00, doEnt[channel].reg.addr & 0xFF, value ? 0xFF : 0x00, 0x00]; + sendRequest(FC.WRITE_SINGLE_COIL, data, function(err) { + if (callback) callback(err, !err); + }); +} + +function writeAnalogOutput(channel, value, callback) { + var aoEnt = entitiesByRtype(0x03); + if (channel < 0 || channel >= aoEnt.length) { + if (callback) callback("Invalid channel: " + channel, false); + return; + } + if (value < 0) value = 0; + if (value > AO_MAX_VALUE) value = AO_MAX_VALUE; + var data = [0x00, aoEnt[channel].reg.addr & 0xFF, (value >> 8) & 0xFF, value & 0xFF]; + sendRequest(FC.WRITE_SINGLE_REGISTER, data, function(err) { + if (callback) callback(err, !err); + }); +} + +function aiToMilliamps(raw) { return 4.0 + (raw / AI_MAX_VALUE) * 16.0; } +function aiToVoltage(raw) { return (raw / AI_MAX_VALUE) * 10.0; } +function milliampsToAo(mA) { if (mA < 4) mA = 4; if (mA > 20) mA = 20; return Math.round(((mA - 4) / 16.0) * AO_MAX_VALUE); } +function voltageToAo(volts) { if (volts < 0) volts = 0; if (volts > 10) volts = 10; return Math.round((volts / 10.0) * AO_MAX_VALUE); } + +/* === RELAY TOGGLE (called from button event handler) === */ + +function toggleRelay(channel) { + state.doState[channel] = !state.doState[channel]; + var newState = state.doState[channel]; + debug("Relay " + channel + " -> " + (newState ? "ON" : "OFF")); + writeDigitalOutput(channel, newState, function(err) { + if (err) { + debug("Relay " + channel + " write error: " + err); + state.doState[channel] = !state.doState[channel]; // revert + } + }); +} + +/* === POLL === */ + +function pollAllInputs() { + // --- Read DI 0 and DI 1 (bulk read all 8, publish first 2) --- + readDigitalInputs(function(err, inputs) { + if (err) { + debug("DI Error: " + err); + } else { + var diEnt = entitiesByRtype(0x02); + updateVc(diEnt[0], inputs[0]); // DI 0 -> number:200 + updateVc(diEnt[1], inputs[1]); // DI 1 -> number:201 + print("[DI] DI0:" + inputs[0] + " DI1:" + inputs[1]); + } + + // --- Check AO sliders for user changes, write hardware if changed --- + Timer.set(100, false, function() { + for (var i = 0; i < 2; i++) { + if (!state.vc.ao[i]) continue; + var sliderVal = state.vc.ao[i].getValue(); + if (sliderVal !== state.lastAo[i]) { + state.lastAo[i] = sliderVal; + debug("AO " + i + " slider -> " + sliderVal); + writeAnalogOutput(i, sliderVal, null); + } + } + + // --- Read AI 0 and AI 1 (bulk read all 8, publish first 2) --- + Timer.set(100, false, function() { + readAnalogInputs(function(err, values) { + if (err) { + debug("AI Error: " + err); + return; + } + var aiEnt = entitiesByRtype(0x04); + updateVc(aiEnt[0], values[0]); // AI 0 -> number:204 + updateVc(aiEnt[1], values[1]); // AI 1 -> number:205 + var mA0 = aiToMilliamps(values[0]).toFixed(2); + var mA1 = aiToMilliamps(values[1]).toFixed(2); + print("[AI] AI0:" + mA0 + "mA AI1:" + mA1 + "mA"); + }); + }); + }); + }); +} + +/* === INITIALIZATION === */ + +function init() { + print("CWT-MB308V MODBUS IO Module + Virtual Components"); + print("================================================="); + print(" button:200/201 -> relay toggle (DO 0/1)"); + print(" number:200/201 -> DI 0/1 display"); + print(" number:202/203 -> AO 0/1 sliders"); + print(" number:204/205 -> AI 0/1 progress bars"); + print(" group:200 -> MB308V Demo"); + print(""); + + // --- OUTPUT VCs (script writes values) --- + // Init handles for entities that have vcId set (DI 0/1 and AI 0/1) + for (var i = 0; i < ENTITIES.length; i++) { + var ent = ENTITIES[i]; + if (ent.vcId) { + ent.vcHandle = Virtual.getHandle(ent.vcId); + debug("VC out: " + ent.name + " -> " + ent.vcId); + } + } + + // --- INPUT VCs (user drives these, script reads / reacts) --- + // Button handles (relay toggles) + state.vc.btn[0] = Virtual.getHandle("button:200"); + state.vc.btn[1] = Virtual.getHandle("button:201"); + debug("VC in: Relay 0 toggle -> button:200"); + debug("VC in: Relay 1 toggle -> button:201"); + + // AO slider handles – read getValue() on each poll cycle + state.vc.ao[0] = Virtual.getHandle("number:202"); + state.vc.ao[1] = Virtual.getHandle("number:203"); + debug("VC in: AO 0 slider -> number:202"); + debug("VC in: AO 1 slider -> number:203"); + + // Seed lastAo from current slider values so we don't write 0 on first boot + if (state.vc.ao[0]) state.lastAo[0] = state.vc.ao[0].getValue(); + if (state.vc.ao[1]) state.lastAo[1] = state.vc.ao[1].getValue(); + + // --- Button event handler --- + // Catches push events from button:200 and button:201 to toggle relays. + Shelly.addEventHandler(function(event, ud) { + if (event.name !== "push" && event.name !== "single_push") return; + debug("Button event: " + event.component + " / " + event.name); + if (event.component === "button:200") toggleRelay(0); + else if (event.component === "button:201") toggleRelay(1); + }, null); + + // --- UART --- + state.uart = UART.get(); + if (!state.uart) { print("ERROR: UART not available"); return; } + if (!state.uart.configure({ baud: CONFIG.BAUD_RATE, mode: CONFIG.MODE })) { + print("ERROR: UART configuration failed"); + return; + } + state.uart.recv(onReceive); + state.isReady = true; + + debug("UART: " + CONFIG.BAUD_RATE + " baud, " + CONFIG.MODE); + debug("Slave ID: " + CONFIG.SLAVE_ID); + print("Polling every " + (CONFIG.POLL_INTERVAL / 1000) + "s..."); + print(""); + + Timer.set(500, false, pollAllInputs); + state.pollTimer = Timer.set(CONFIG.POLL_INTERVAL, true, pollAllInputs); +} + +init(); diff --git a/the_pill/MODBUS/Deye/README.md b/the_pill/MODBUS/Deye/README.md index 54ee690..977e4d5 100644 --- a/the_pill/MODBUS/Deye/README.md +++ b/the_pill/MODBUS/Deye/README.md @@ -1,148 +1,29 @@ -# Deye SG02LP1 Solar Inverter - MODBUS-RTU Reader +# Deye SG02LP1 MODBUS Examples -> **Under Development** - These scripts are currently under development and may not be fully functional. +Read-only Deye inverter telemetry over RS485 MODBUS-RTU using The Pill. -Scripts for reading live data from a **Deye SG02LP1 hybrid solar inverter** over MODBUS-RTU via RS485/UART using The Pill. +## Problem (The Story) +Energy dashboards and automations need live PV, battery, and grid values. Many installs expose this only through vendor tools. These scripts poll key Deye registers locally and make data available in logs or Virtual Components. -## Files - -### [the_pill_mbsa_deye.shelly.js](the_pill_mbsa_deye.shelly.js) - -**Basic reader** - Polls the inverter every 10 seconds and prints all parameter values to the Shelly script console. - -### [the_pill_mbsa_deye_vc.shelly.js](the_pill_mbsa_deye_vc.shelly.js) - -**Reader + Virtual Components** - Same polling logic, but also pushes each value into a Shelly Virtual Number component so values are accessible via the Shelly RPC API and the app. - ---- - -## Hardware Requirements - -- Shelly device with UART (e.g., **The Pill**) -- RS485 transceiver module (e.g., MAX485, SP485) -- Deye SG02LP1 hybrid inverter with RS485 port - -### Wiring - -**RS485 module to Shelly (The Pill):** - -| RS485 Module | Shelly / The Pill | -|---|---| -| RO (Receiver Output) | RX (GPIO) | -| DI (Driver Input) | TX (GPIO) | -| VCC | 3.3V or 5V | -| GND | GND | - -**RS485 module to Deye inverter:** - -| RS485 Module | Deye RS485 Port | -|---|---| -| A (D+) | A (D+) | -| B (D-) | B (D-) | -| GND | GND (optional, for noise rejection) | - -**UART Settings:** 9600 baud, 8N1 - -> The Deye RS485 port is typically a 3-pin or RJ45 connector on the communication board. Consult your inverter manual for the exact pinout. - ---- - -## Monitored Parameters - -Both scripts read the same 9 registers from the inverter: +## Persona +- Home energy enthusiast tracking PV/battery/grid flows +- Installer integrating inverter values into local automations +- Engineer validating inverter behavior without cloud dependency -| Parameter | Register | Type | Scale | Units | -|---|---|---|---|---| -| Total Power | 175 | i16 | 1 | W | -| Battery Power | 190 | i16 | 1 | W | -| PV1 Power | 186 | u16 | 1 | W | -| Total Grid Power | 169 | i16 | 10 | W | -| Battery SOC | 184 | u16 | 1 | % | -| PV1 Voltage | 109 | u16 | 0.1 | V | -| Grid Voltage L1 | 150 | u16 | 0.1 | V | -| Current L1 | 164 | i16 | 0.01 | A | -| AC Frequency | 192 | u16 | 0.01 | Hz | - -`i16` registers are treated as signed 16-bit integers; `u16` as unsigned. The raw register value is multiplied by `scale` to get the physical value. - ---- - -## Virtual Component Mapping (\_vc variant only) +## Files +- [`deye.shelly.js`](deye.shelly.js): console telemetry reader +- [`deye_vc.shelly.js`](deye_vc.shelly.js): telemetry + Virtual Components -The `_vc` script maps each parameter to a pre-existing Shelly Virtual Number component. You must create these components on the device before running the script (via the Shelly app or RPC). +## Screenshot +![Deye VC Screenshot](screenshot.png) +This screenshot shows the Deye Virtual Components dashboard for PV, battery, grid, voltage, current, frequency, and SOC monitoring. -| Parameter | Virtual Component ID | +## RS485 Wiring (The Pill 5-Terminal Add-on) +| The Pill Pin | Deye RS485 | |---|---| -| Total Power | `number:200` | -| Battery Power | `number:201` | -| PV1 Power | `number:202` | -| Total Grid Power | `number:203` | -| Battery SOC | `number:204` | -| PV1 Voltage | `number:205` | -| Grid Voltage L1 | `number:206` | -| Current L1 | `number:207` | -| AC Frequency | `number:208` | - -Values are updated every poll cycle. If a component handle cannot be obtained (component not created), that parameter is polled but not pushed. - ---- - -## Configuration - -Both scripts share the same `CONFIG` block at the top of the file: - -```javascript -var CONFIG = { - BAUD_RATE: 9600, // UART baud rate (must match inverter setting) - MODE: "8N1", // UART framing - SLAVE_ID: 1, // MODBUS slave address of the inverter - RESPONSE_TIMEOUT: 1000, // ms to wait for a register response - POLL_INTERVAL: 10000, // ms between full poll cycles (10 s) - DEBUG: true // print TX/RX frames and value changes to console -}; -``` - -> If you have multiple devices on the RS485 bus, change `SLAVE_ID` to match your inverter's configured address. - ---- - -## Console Output Example - -``` -Deye SG02LP1 - MODBUS-RTU Reader -================================= -[DEYE] UART: 9600 baud, 8N1 -[DEYE] Slave ID: 1 - -Polling 9 parameters every 10s... - ---- Deye SG02LP1 --- -Total Power: 1450 [W] -Battery Power: -320 [W] -PV1 Power: 2100 [W] -Total Grid Power: 0 [W] -Battery SOC: 87 [%] -PV1 Voltage: 342.5 [V] -Grid Voltage L1: 231.2 [V] -Current L1: 6.28 [A] -AC Frequency: 50.01 [Hz] -``` - ---- - -## Implementation Notes - -- Registers are read **one at a time** (FC 0x03, quantity = 1) with a 50 ms inter-request delay for bus stability. -- CRC-16 is computed via a pre-computed lookup table (MODBUS polynomial 0xA001). -- The MODBUS exception response (function code | 0x80) is detected and surfaced as an error string. -- A 1-second response timeout is enforced per request; timed-out parameters are logged as `ERROR (Timeout)`. -- There is no write capability - these scripts are read-only. - ---- - -## References +| `IO1 (TX)` -> `B (D-)` | `B` / `D-` | +| `IO2 (RX)` -> `A (D+)` | `A` / `D+` | +| `IO3` -> `DE/RE` | transceiver direction | +| `GND` -> `GND` | recommended | -- [Deye Inverter Product Page](https://www.deyeinverter.com/) -- [MODBUS RTU Protocol Specification](https://modbus.org/specs.php) -- [MODBUS over Serial Line](https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf) -- [Shelly Virtual Components](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Virtual) +Default communication in examples: `9600`, `8N1`. diff --git a/the_pill/MODBUS/Deye/the_pill_mbsa_deye.shelly.js b/the_pill/MODBUS/Deye/deye.shelly.js similarity index 77% rename from the_pill/MODBUS/Deye/the_pill_mbsa_deye.shelly.js rename to the_pill/MODBUS/Deye/deye.shelly.js index 88aa3fa..4653a02 100644 --- a/the_pill/MODBUS/Deye/the_pill_mbsa_deye.shelly.js +++ b/the_pill/MODBUS/Deye/deye.shelly.js @@ -3,7 +3,7 @@ * @description MODBUS-RTU example for reading Deye SG02LP1 solar inverter * parameters over UART using the MODBUS-RTU master library. * @status production - * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/MODBUS/Deye/the_pill_mbsa_deye.shelly.js + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/MODBUS/Deye/deye.shelly.js */ /** @@ -18,11 +18,11 @@ * - PV1 Voltage, Grid Voltage L1, Current L1 * - AC Frequency * - * Hardware connection: - * - RS485 Module TX -> Shelly RX (GPIO) - * - RS485 Module RX -> Shelly TX (GPIO) - * - RS485 Module A/B -> Inverter RS485 A/B - * - GND -> GND + * The Pill 5-Terminal Add-on wiring: + * IO1 (TX) ─── B (D-) ──> Inverter RS485 B (D-) + * IO2 (RX) ─── A (D+) ──> Inverter RS485 A (D+) + * IO3 ─── DE/RE ── direction control (HIGH=TX, LOW=RX) + * GND ─── GND ──> Inverter GND */ /* === CONFIG === */ @@ -37,15 +37,111 @@ var CONFIG = { /* === DEYE REGISTER MAP === */ var ENTITIES = [ - { name: "Total Power", units: "W", addr: 175, itype: "i16", scale: 1 }, - { name: "Battery Power", units: "W", addr: 190, itype: "i16", scale: 1 }, - { name: "PV1 Power", units: "W", addr: 186, itype: "u16", scale: 1 }, - { name: "Total Grid Power",units: "W", addr: 169, itype: "i16", scale: 10 }, - { name: "Battery SOC", units: "%", addr: 184, itype: "u16", scale: 1 }, - { name: "PV1 Voltage", units: "V", addr: 109, itype: "u16", scale: 0.1 }, - { name: "Grid Voltage L1", units: "V", addr: 150, itype: "u16", scale: 0.1 }, - { name: "Current L1", units: "A", addr: 164, itype: "i16", scale: 0.01 }, - { name: "AC Frequency", units: "Hz", addr: 192, itype: "u16", scale: 0.01 } + // + // --- Solar / Battery summary --- + // + { + name: "Total Power", + units: "W", + reg: { addr: 175, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 1, + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + { + name: "Battery Power", + units: "W", + reg: { addr: 190, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 1, + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + { + name: "PV1 Power", + units: "W", + reg: { addr: 186, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, + scale: 1, + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + // + // --- Grid --- + // + { + name: "Total Grid Power", + units: "W", + reg: { addr: 169, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 10, + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + // + // --- Battery --- + // + { + name: "Battery SOC", + units: "%", + reg: { addr: 184, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, + scale: 1, + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + // + // --- DC Input --- + // + { + name: "PV1 Voltage", + units: "V", + reg: { addr: 109, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, + scale: 0.1, + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + // + // --- AC Output --- + // + { + name: "Grid Voltage L1", + units: "V", + reg: { addr: 150, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, + scale: 0.1, + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + { + name: "Current L1", + units: "A", + reg: { addr: 164, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 0.01, + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + { + name: "AC Frequency", + units: "Hz", + reg: { addr: 192, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, + scale: 0.01, + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, ]; /* === MODBUS FUNCTION CODES === */ @@ -314,11 +410,11 @@ function pollEntities() { } var entity = ENTITIES[index]; - readRegister(entity.addr, function(err, raw) { + readRegister(entity.reg.addr, function(err, raw) { if (err) { results.push(entity.name + ": ERROR (" + err + ")"); } else { - var value = entity.itype === "i16" ? toSigned16(raw) : raw; + var value = entity.reg.itype === "i16" ? toSigned16(raw) : raw; value = value * entity.scale; results.push(entity.name + ": " + value + " [" + entity.units + "]"); } diff --git a/the_pill/MODBUS/Deye/the_pill_mbsa_deye_vc.shelly.js b/the_pill/MODBUS/Deye/deye_vc.shelly.js similarity index 76% rename from the_pill/MODBUS/Deye/the_pill_mbsa_deye_vc.shelly.js rename to the_pill/MODBUS/Deye/deye_vc.shelly.js index 6c1d9a8..ccc3652 100644 --- a/the_pill/MODBUS/Deye/the_pill_mbsa_deye_vc.shelly.js +++ b/the_pill/MODBUS/Deye/deye_vc.shelly.js @@ -4,7 +4,15 @@ * Virtual Component updates. Reads parameters over UART (RS485) and * pushes values to user-defined virtual number components. * @status production - * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/MODBUS/Deye/the_pill_mbsa_deye_vc.shelly.js + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/MODBUS/Deye/deye_vc.shelly.js + */ + +/* + * The Pill 5-Terminal Add-on wiring: + * IO1 (TX) ─── B (D-) ──> Inverter RS485 B (D-) + * IO2 (RX) ─── A (D+) ──> Inverter RS485 A (D+) + * IO3 ─── DE/RE ── direction control (automatic) + * GND ─── GND ──> Inverter GND */ /* === CONFIG === */ @@ -19,15 +27,111 @@ var CONFIG = { /* === DEYE REGISTER MAP + VIRTUAL COMPONENT MAPPING === */ var ENTITIES = [ - { name: "Total Power", units: "W", addr: 175, itype: "i16", scale: 1, vcId: "number:200", vcHandle: null }, - { name: "Battery Power", units: "W", addr: 190, itype: "i16", scale: 1, vcId: "number:201", vcHandle: null }, - { name: "PV1 Power", units: "W", addr: 186, itype: "u16", scale: 1, vcId: "number:202", vcHandle: null }, - { name: "Total Grid Power",units: "W", addr: 169, itype: "i16", scale: 10, vcId: "number:203", vcHandle: null }, - { name: "Battery SOC", units: "%", addr: 184, itype: "u16", scale: 1, vcId: "number:204", vcHandle: null }, - { name: "PV1 Voltage", units: "V", addr: 109, itype: "u16", scale: 0.1, vcId: "number:205", vcHandle: null }, - { name: "Grid Voltage L1", units: "V", addr: 150, itype: "u16", scale: 0.1, vcId: "number:206", vcHandle: null }, - { name: "Current L1", units: "A", addr: 164, itype: "i16", scale: 0.01, vcId: "number:207", vcHandle: null }, - { name: "AC Frequency", units: "Hz", addr: 192, itype: "u16", scale: 0.01, vcId: "number:208", vcHandle: null } + // + // --- Solar / Battery summary --- + // + { + name: "Total Power", + units: "W", + reg: { addr: 175, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 1, + rights: "R", + vcId: "number:200", + handle: null, + vcHandle: null, + }, + { + name: "Battery Power", + units: "W", + reg: { addr: 190, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 1, + rights: "R", + vcId: "number:201", + handle: null, + vcHandle: null, + }, + { + name: "PV1 Power", + units: "W", + reg: { addr: 186, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, + scale: 1, + rights: "R", + vcId: "number:202", + handle: null, + vcHandle: null, + }, + // + // --- Grid --- + // + { + name: "Total Grid Power", + units: "W", + reg: { addr: 169, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 10, + rights: "R", + vcId: "number:203", + handle: null, + vcHandle: null, + }, + // + // --- Battery --- + // + { + name: "Battery SOC", + units: "%", + reg: { addr: 184, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, + scale: 1, + rights: "R", + vcId: "number:204", + handle: null, + vcHandle: null, + }, + // + // --- DC Input --- + // + { + name: "PV1 Voltage", + units: "V", + reg: { addr: 109, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, + scale: 0.1, + rights: "R", + vcId: "number:205", + handle: null, + vcHandle: null, + }, + // + // --- AC Output --- + // + { + name: "Grid Voltage L1", + units: "V", + reg: { addr: 150, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, + scale: 0.1, + rights: "R", + vcId: "number:206", + handle: null, + vcHandle: null, + }, + { + name: "Current L1", + units: "A", + reg: { addr: 164, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 0.01, + rights: "R", + vcId: "number:207", + handle: null, + vcHandle: null, + }, + { + name: "AC Frequency", + units: "Hz", + reg: { addr: 192, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, + scale: 0.01, + rights: "R", + vcId: "number:208", + handle: null, + vcHandle: null, + }, ]; /* === MODBUS FUNCTION CODES === */ @@ -290,11 +394,11 @@ function pollEntities() { } var entity = ENTITIES[index]; - readRegister(entity.addr, function(err, raw) { + readRegister(entity.reg.addr, function(err, raw) { if (err) { results.push(entity.name + ": ERROR (" + err + ")"); } else { - var value = entity.itype === "i16" ? toSigned16(raw) : raw; + var value = entity.reg.itype === "i16" ? toSigned16(raw) : raw; value = value * entity.scale; results.push(entity.name + ": " + value + " [" + entity.units + "]"); updateVc(entity, value); diff --git a/the_pill/MODBUS/Deye/screenshot.png b/the_pill/MODBUS/Deye/screenshot.png new file mode 100644 index 0000000..985cf2e Binary files /dev/null and b/the_pill/MODBUS/Deye/screenshot.png differ diff --git a/the_pill/MODBUS/HTTP-Bridge/README.md b/the_pill/MODBUS/HTTP-Bridge/README.md new file mode 100644 index 0000000..d23b54d --- /dev/null +++ b/the_pill/MODBUS/HTTP-Bridge/README.md @@ -0,0 +1,310 @@ +# MODBUS-RTU HTTP Bridge + +Expose any MODBUS RTU slave device over HTTP using The Pill's UART (RS485). A single HTTP endpoint accepts a register descriptor as JSON, talks to the slave over MODBUS RTU, and returns the result as JSON. + +## Problem (The Story) + +Many MODBUS RTU devices are buried on an RS485 bus with no network access. External systems (dashboards, PLCs, scripts, home automation platforms) need a simple, technology-agnostic way to read or write individual registers without implementing the full RTU stack. This bridge makes any register on any connected slave reachable over plain HTTP. + +## Persona + +- Integrator connecting RS485 devices to HTTP-based home automation +- Developer prototyping MODBUS reads without writing embedded code +- Engineer commissioning a slave device register-by-register over the network + +## Files + +- [`modbus_http_bridge.shelly.js`](modbus_http_bridge.shelly.js): HTTP bridge script + +## RS485 Wiring (The Pill 5-Terminal Add-on) + +| The Pill Pin | RS485 Bus | +|---|---| +| `IO1 (TX)` | `B (D-)` | +| `IO2 (RX)` | `A (D+)` | +| `IO3` | `DE/RE` (transceiver direction) | +| `GND` | `GND` | +| `5V` | `VCC` (optional, if device needs power) | + +Default UART: `9600 baud`, `8N1`. + +## Configuration + +Edit `CONFIG` at the top of the script: + +```js +var CONFIG = { + BAUD_RATE: 9600, // baud rate for the RS485 bus + MODE: "8N1", // frame format: "8N1", "8E1", "8O1" + DEFAULT_SLAVE: 1, // MODBUS slave address used when not specified in request + RESPONSE_TIMEOUT: 1000, // ms - how long to wait for the slave to reply + DEBUG: true // print TX/RX frames to the console log +}; +``` + +## Endpoint + +After uploading and running the script, the endpoint is available at: + +``` +http:///script//modbus +``` + +Both **GET** and **POST** are supported. + +## Register Descriptor + +Every request carries a **register descriptor** — a JSON object that describes the register to access: + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | string | no | Human-friendly register name | +| `units` | string | no | Physical unit (e.g. `"W"`, `"°C"`) | +| `scale` | number | no | Multiplier applied to raw value for `human_readable`. Default `1`. | +| `rights` | string | no | `"R"` read-only, `"RW"` read-write. Default `"R"`. | +| `reg.addr` | number | **yes** | Register address (0–65535) | +| `reg.rtype` | string | **yes** | Register type — see table below | +| `reg.itype` | string | no | Data type — see table below. Default `"u16"`. | +| `reg.bo` | string | no | Byte order within each 16-bit register: `"BE"` or `"LE"`. Default `"BE"`. | +| `reg.wo` | string | no | Word order for 32-bit types: `"BE"` (high word first) or `"LE"`. Default `"BE"`. | +| `value` | number\|null | no | `null` → read operation. A number + `"rights":"RW"` → write operation (raw register value). | +| `human_readable` | number\|null | — | Filled in the response (`value × scale`). | + +### Register types (`rtype`) + +| Value | MODBUS FC (read) | Description | +|---|---|---| +| `"holding"` | FC 03 | Holding registers (read/write) | +| `"input"` | FC 04 | Input registers (read-only) | +| `"coil"` | FC 01 | Coils / output bits (read/write) | +| `"discrete"` | FC 02 | Discrete inputs / input bits (read-only) | + +### Data types (`itype`) + +| Value | Size | Notes | +|---|---|---| +| `"u16"` | 1 register | Unsigned 16-bit | +| `"i16"` | 1 register | Signed 16-bit (two's complement) | +| `"u32"` | 2 registers | Unsigned 32-bit; uses `wo` for word order | +| `"i32"` | 2 registers | Signed 32-bit; uses `wo` for word order | +| `"f32"` | 2 registers | IEEE 754 single-precision float; uses `wo` | + +### Write support + +| `rtype` | `itype` | MODBUS FC used | +|---|---|---| +| `"holding"` | `"u16"` / `"i16"` | FC 06 – Write Single Register | +| `"holding"` | `"u32"` / `"i32"` / `"f32"` | FC 16 – Write Multiple Registers | +| `"coil"` | — | FC 05 – Write Single Coil | + +> Input registers and discrete inputs are read-only by the MODBUS standard. + +## Usage Examples + +### Read a holding register (POST) + +```bash +curl -X POST http:///script//modbus \ + -H 'Content-Type: application/json' \ + -d '{ + "register": { + "name": "Active Power", + "units": "W", + "scale": 1, + "rights": "R", + "reg": {"addr": 0, "rtype": "holding", "itype": "u16", "bo": "BE", "wo": "BE"}, + "value": null, + "human_readable": null + } + }' +``` + +Response: + +```json +{ + "name": "Active Power", + "units": "W", + "scale": 1, + "rights": "R", + "reg": {"addr": 0, "rtype": "holding", "itype": "u16", "bo": "BE", "wo": "BE"}, + "value": 1234, + "human_readable": 1234 +} +``` + +--- + +### Read a 32-bit input register with scale (POST) + +```bash +curl -X POST http:///script//modbus \ + -H 'Content-Type: application/json' \ + -d '{ + "register": { + "name": "Voltage", + "units": "V", + "scale": 0.1, + "rights": "R", + "reg": {"addr": 100, "rtype": "input", "itype": "u32", "bo": "BE", "wo": "BE"}, + "value": null, + "human_readable": null + } + }' +``` + +Response (`value` is raw, `human_readable = value × scale`): + +```json +{ + "name": "Voltage", + "units": "V", + "scale": 0.1, + "rights": "R", + "reg": {"addr": 100, "rtype": "input", "itype": "u32", "bo": "BE", "wo": "BE"}, + "value": 2300, + "human_readable": 230.0 +} +``` + +--- + +### Write a holding register (POST) + +Set `value` to the raw register value and `rights` to `"RW"`: + +```bash +curl -X POST http:///script//modbus \ + -H 'Content-Type: application/json' \ + -d '{ + "register": { + "name": "Setpoint", + "units": "°C", + "scale": 0.1, + "rights": "RW", + "reg": {"addr": 8, "rtype": "holding", "itype": "u16", "bo": "BE", "wo": "BE"}, + "value": 215, + "human_readable": null + } + }' +``` + +Response confirms the written value: + +```json +{ + "name": "Setpoint", + "units": "°C", + "scale": 0.1, + "rights": "RW", + "reg": {"addr": 8, "rtype": "holding", "itype": "u16", "bo": "BE", "wo": "BE"}, + "value": 215, + "human_readable": 21.5 +} +``` + +--- + +### Read a coil (POST) + +```bash +curl -X POST http:///script//modbus \ + -H 'Content-Type: application/json' \ + -d '{ + "register": { + "name": "Relay 1", + "units": "", + "scale": 1, + "rights": "RW", + "reg": {"addr": 0, "rtype": "coil", "itype": "u16", "bo": "BE", "wo": "BE"}, + "value": null, + "human_readable": null + } + }' +``` + +--- + +### Write a coil (POST) + +```bash +curl -X POST http:///script//modbus \ + -H 'Content-Type: application/json' \ + -d '{ + "register": { + "name": "Relay 1", + "units": "", + "scale": 1, + "rights": "RW", + "reg": {"addr": 0, "rtype": "coil", "itype": "u16", "bo": "BE", "wo": "BE"}, + "value": 1, + "human_readable": null + } + }' +``` + +--- + +### Specify a slave address (POST) + +Add `"slave"` at the top level to override the default slave ID: + +```bash +curl -X POST http:///script//modbus \ + -H 'Content-Type: application/json' \ + -d '{ + "slave": 5, + "register": { + "name": "Status", + "units": "", + "scale": 1, + "rights": "R", + "reg": {"addr": 0, "rtype": "holding", "itype": "u16", "bo": "BE", "wo": "BE"}, + "value": null, + "human_readable": null + } + }' +``` + +--- + +### Read via GET + +For a GET request, URL-encode the descriptor JSON and pass it as the `register` query parameter. The optional `slave` parameter overrides the default slave ID. + +```bash +# URL-encode the JSON first, then: +curl 'http:///script//modbus?slave=1®ister=' +``` + +Example with Python to build the URL: + +```python +import json, urllib.parse, requests + +reg = { + "name": "W", "units": "W", "scale": 1, "rights": "R", + "reg": {"addr": 0, "rtype": "holding", "itype": "u16", "bo": "BE", "wo": "BE"}, + "value": None, "human_readable": None +} +url = f"http:///script//modbus?register={urllib.parse.quote(json.dumps(reg))}" +print(requests.get(url).json()) +``` + +--- + +## Error Responses + +All errors return a JSON object with an `error` field: + +| HTTP code | Meaning | +|---|---| +| `400` | Bad request — missing or malformed descriptor | +| `500` | MODBUS error — timeout, exception code, CRC, etc. | +| `503` | Bus busy (previous request still pending) or UART not initialized | + +```json +{"error": "Timeout"} +{"error": "MODBUS exception 0x02"} +{"error": "Bus busy, try again"} +``` diff --git a/the_pill/MODBUS/HTTP-Bridge/modbus_http_bridge.shelly.js b/the_pill/MODBUS/HTTP-Bridge/modbus_http_bridge.shelly.js new file mode 100644 index 0000000..459199a --- /dev/null +++ b/the_pill/MODBUS/HTTP-Bridge/modbus_http_bridge.shelly.js @@ -0,0 +1,772 @@ +/** + * @title MODBUS-RTU HTTP Bridge + * @description HTTP endpoint that bridges MODBUS RTU over UART. Accepts a + * register descriptor as JSON, performs MODBUS RTU read/write via The Pill + * UART, and returns the result as JSON. + * @status under development + * @link https://github.com/orlin369/shelly-script-examples/blob/main/the_pill/MODBUS/HTTP-Bridge/modbus_http_bridge.shelly.js + */ + +/** + * MODBUS-RTU HTTP Bridge + * + * Exposes an HTTP endpoint that accepts a MODBUS register descriptor and + * performs the corresponding RTU read or write via UART (RS485). + * + * Endpoint: + * GET http:///script//modbus?register=[&slave=] + * POST http:///script//modbus + * Body (JSON): {"register": , "slave": } + * Body (JSON): (register descriptor directly) + * + * Register descriptor format: + * { + * "name": "Active Power", + * "units": "W", + * "scale": 1, + * "rights": "RW", // "R" = read-only, "RW" = read-write + * "reg": { + * "addr": 0, // register address (0-65535) + * "rtype": "holding", // "holding" | "input" | "coil" | "discrete" + * "itype": "u16", // "u16" | "i16" | "u32" | "i32" | "f32" + * "bo": "BE", // byte order within register: "BE" or "LE" + * "wo": "BE" // word order for 32-bit types: "BE" or "LE" + * }, + * "value": null, // null => read; number => write (raw register value) + * "human_readable": null // filled on response (value * scale) + * } + * + * Response on success: + * Same descriptor with "value" and "human_readable" populated. + * Response on error: + * {"error": ""} + * + * Write rules: + * - If "value" is not null and "rights" == "RW" => write operation. + * - Otherwise => read operation. + * - For coils: value 0 = OFF, any non-zero = ON. + * + * The Pill 5-Terminal Add-on wiring: + * IO1 (TX) --- B (D-) --> Device RS485 B (D-) + * IO2 (RX) --- A (D+) --> Device RS485 A (D+) + * IO3 --- DE/RE -- direction control + * GND --- GND --> Device GND + * 5V --- 5V --> Device VCC (if needed) + */ + +/* === CONFIG === */ +var CONFIG = { + BAUD_RATE: 115200, + MODE: '8N1', // "8N1", "8E1", "8O1" + DEFAULT_SLAVE: 1, // default MODBUS slave address + RESPONSE_TIMEOUT: 1000, // ms - max wait for slave response + DEBUG: true +}; + +/* === MODBUS FUNCTION CODES === */ +var FC = { + READ_COILS: 0x01, + READ_DISCRETE_INPUTS: 0x02, + READ_HOLDING_REGISTERS: 0x03, + READ_INPUT_REGISTERS: 0x04, + WRITE_SINGLE_COIL: 0x05, + WRITE_SINGLE_REGISTER: 0x06, + WRITE_MULTIPLE_REGISTERS: 0x10 +}; + +/* === CRC-16 TABLE (MODBUS polynomial 0xA001) === */ +var CRC_TABLE = [ + 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, + 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440, + 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40, + 0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841, + 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40, + 0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41, + 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641, + 0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040, + 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240, + 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441, + 0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41, + 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840, + 0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41, + 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40, + 0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640, + 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041, + 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240, + 0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441, + 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41, + 0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840, + 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41, + 0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40, + 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640, + 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041, + 0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241, + 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440, + 0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40, + 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841, + 0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40, + 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41, + 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641, + 0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040 +]; + +/* === STATE === */ +var state = { + uart: null, + rxBuffer: [], + isReady: false, + pendingRequest: null, + responseTimer: null +}; + +/* === HELPERS === */ + +function toHex(n) { + n = n & 0xFF; + return (n < 16 ? "0" : "") + n.toString(16).toUpperCase(); +} + +function bytesToHex(bytes) { + var hex = ""; + for (var i = 0; i < bytes.length; i++) { + hex += toHex(bytes[i]); + if (i < bytes.length - 1) hex += " "; + } + return hex; +} + +function debug(msg) { + if (CONFIG.DEBUG) { + print("[BRIDGE] " + msg); + } +} + +function calcCRC(bytes) { + var crc = 0xFFFF; + for (var i = 0; i < bytes.length; i++) { + var index = (crc ^ bytes[i]) & 0xFF; + crc = (crc >> 8) ^ CRC_TABLE[index]; + } + return crc; +} + +function bytesToStr(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) { + s += String.fromCharCode(bytes[i] & 0xFF); + } + return s; +} + +function buildFrame(slaveAddr, functionCode, data) { + var frame = [slaveAddr & 0xFF, functionCode & 0xFF]; + if (data) { + for (var i = 0; i < data.length; i++) { + frame.push(data[i] & 0xFF); + } + } + var crc = calcCRC(frame); + frame.push(crc & 0xFF); // CRC low byte + frame.push((crc >> 8) & 0xFF); // CRC high byte + return frame; +} + +/* === VALUE DECODING === */ + +/** + * Reconstruct IEEE 754 single-precision float from 32-bit integer bits. + */ +function float32FromBits(bits) { + if ((bits & 0x7FFFFFFF) === 0) return 0.0; + var sign = (bits >>> 31) ? -1 : 1; + var exp = (bits >>> 23) & 0xFF; + var mant = bits & 0x7FFFFF; + if (exp === 0xFF) { + return sign * Infinity; + } + if (exp === 0) { + // Subnormal + return sign * mant * Math.pow(2, -149); + } + return sign * (1 + mant / 8388608) * Math.pow(2, exp - 127); +} + +/** + * Decode register bytes to a numeric value. + * + * @param {number[]} bytes - Raw bytes from the MODBUS response (register data only, + * no byteCount prefix, no CRC). + * @param {string} itype - "u16" | "i16" | "u32" | "i32" | "f32" + * @param {string} bo - byte order within each 16-bit register: "BE" | "LE" + * @param {string} wo - word order for 32-bit types: "BE" (high word first) | "LE" + * @returns {number|null} + */ +function decodeValue(bytes, itype, bo, wo) { + var raw, b0, b1, b2, b3, bits32; + + if (itype === "u16" || itype === "i16") { + if (bytes.length < 2) return null; + if (bo === "LE") { + raw = (bytes[1] << 8) | bytes[0]; + } else { + raw = (bytes[0] << 8) | bytes[1]; + } + raw = raw & 0xFFFF; + if (itype === "i16" && raw >= 0x8000) raw -= 0x10000; + return raw; + } + + if (itype === "u32" || itype === "i32" || itype === "f32") { + if (bytes.length < 4) return null; + + // Apply word order: wo="BE" => bytes[0..1]=high word, bytes[2..3]=low word + // wo="LE" => bytes[0..1]=low word, bytes[2..3]=high word + if (wo === "LE") { + // Swap word positions + var tmp0 = bytes[0]; var tmp1 = bytes[1]; + bytes[0] = bytes[2]; bytes[1] = bytes[3]; + bytes[2] = tmp0; bytes[3] = tmp1; + } + + // Apply byte order within each 16-bit word + if (bo === "LE") { + b0 = bytes[1]; b1 = bytes[0]; + b2 = bytes[3]; b3 = bytes[2]; + } else { + b0 = bytes[0]; b1 = bytes[1]; + b2 = bytes[2]; b3 = bytes[3]; + } + + bits32 = (((b0 << 24) | (b1 << 16) | (b2 << 8) | b3) >>> 0); + + if (itype === "u32") return bits32; + if (itype === "i32") return bits32 >= 0x80000000 ? bits32 - 4294967296 : bits32; + if (itype === "f32") return float32FromBits(bits32); + } + + return null; +} + +/* === VALUE ENCODING (for writes) === */ + +/** + * Convert IEEE 754 float to 32-bit integer bits. + */ +function float32ToBits(f) { + if (f === 0) return 0; + var sign = (f < 0) ? 1 : 0; + if (sign) f = -f; + var exp = Math.floor(Math.log(f) / 0.6931471805599453); // log(2) + var mant = f / Math.pow(2, exp) - 1; + exp += 127; + if (exp <= 0) return 0; + if (exp >= 255) return (sign << 31) | (0xFF << 23); + var mantBits = Math.round(mant * 8388608) & 0x7FFFFF; + return ((sign << 31) | (exp << 23) | mantBits) >>> 0; +} + +/** + * Encode a numeric value to register bytes for writing. + * + * @param {number} value - Raw value to encode (integer for u/i types, float for f32) + * @param {string} itype - "u16" | "i16" | "u32" | "i32" | "f32" + * @param {string} bo - byte order within each 16-bit register: "BE" | "LE" + * @param {string} wo - word order for 32-bit types: "BE" | "LE" + * @returns {number[]} Array of bytes (2 bytes for 16-bit, 4 bytes for 32-bit) + */ +function encodeValue(value, itype, bo, wo) { + var raw16, bits32, b0, b1, b2, b3, hb, lb, result; + + if (itype === "u16" || itype === "i16") { + raw16 = value & 0xFFFF; + if (bo === "LE") { + return [raw16 & 0xFF, (raw16 >> 8) & 0xFF]; + } else { + return [(raw16 >> 8) & 0xFF, raw16 & 0xFF]; + } + } + + if (itype === "u32" || itype === "i32" || itype === "f32") { + if (itype === "f32") { + bits32 = float32ToBits(value); + } else { + bits32 = (value >>> 0); + } + + // Extract big-endian bytes from bits32 + b0 = (bits32 >> 24) & 0xFF; + b1 = (bits32 >> 16) & 0xFF; + b2 = (bits32 >> 8) & 0xFF; + b3 = bits32 & 0xFF; + + // Apply byte order within each word + if (bo === "LE") { + // Swap within each 16-bit word + hb = [b1, b0]; // high word bytes swapped + lb = [b3, b2]; // low word bytes swapped + } else { + hb = [b0, b1]; + lb = [b2, b3]; + } + + // Apply word order: wo="BE" => high word first + if (wo === "BE") { + result = [hb[0], hb[1], lb[0], lb[1]]; + } else { + result = [lb[0], lb[1], hb[0], hb[1]]; + } + return result; + } + + return []; +} + +/* === MODBUS CORE === */ + +function sendRequest(slaveId, functionCode, data, callback) { + if (!state.isReady) { + callback("MODBUS not initialized", null); + return; + } + if (state.pendingRequest) { + callback("Request pending, try again", null); + return; + } + + var frame = buildFrame(slaveId, functionCode, data); + debug("TX [slave=" + slaveId + "]: " + bytesToHex(frame)); + + state.pendingRequest = { + functionCode: functionCode, + callback: callback + }; + state.rxBuffer = []; + + state.responseTimer = Timer.set(CONFIG.RESPONSE_TIMEOUT, false, function() { + if (state.pendingRequest) { + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + debug("Timeout"); + cb("Timeout", null); + } + }); + + state.uart.write(bytesToStr(frame)); +} + +function onReceive(data) { + if (!data || data.length === 0) return; + for (var i = 0; i < data.length; i++) { + state.rxBuffer.push(data.charCodeAt(i) & 0xFF); + } + processResponse(); +} + +function processResponse() { + if (!state.pendingRequest) { + state.rxBuffer = []; + return; + } + if (state.rxBuffer.length < 5) return; + + var fc = state.rxBuffer[1]; + + // Exception response: high bit set on FC + if (fc & 0x80) { + if (state.rxBuffer.length >= 5) { + var excSlice = state.rxBuffer.slice(0, 5); + var excCrc = calcCRC(excSlice.slice(0, 3)); + var excRecv = excSlice[3] | (excSlice[4] << 8); + if (excCrc === excRecv) { + clearResponseTimeout(); + var exCode = state.rxBuffer[2]; + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + state.rxBuffer = []; + cb("MODBUS exception 0x" + toHex(exCode), null); + } + } + return; + } + + // Determine expected frame length + var expectedLen = 0; + if ((fc === FC.READ_COILS || + fc === FC.READ_DISCRETE_INPUTS || + fc === FC.READ_HOLDING_REGISTERS || + fc === FC.READ_INPUT_REGISTERS) && state.rxBuffer.length >= 3) { + // slave(1) + FC(1) + byteCount(1) + data(N) + CRC(2) + expectedLen = 3 + state.rxBuffer[2] + 2; + } else if (fc === FC.WRITE_SINGLE_COIL || + fc === FC.WRITE_SINGLE_REGISTER) { + // Echo: slave(1) + FC(1) + addr(2) + value(2) + CRC(2) = 8 + expectedLen = 8; + } else if (fc === FC.WRITE_MULTIPLE_REGISTERS) { + // slave(1) + FC(1) + addr(2) + quantity(2) + CRC(2) = 8 + expectedLen = 8; + } + + if (expectedLen === 0 || state.rxBuffer.length < expectedLen) return; + + var frame = state.rxBuffer.slice(0, expectedLen); + var crc = calcCRC(frame.slice(0, expectedLen - 2)); + var recvCrc = frame[expectedLen - 2] | (frame[expectedLen - 1] << 8); + + if (crc !== recvCrc) { + debug("CRC error: expected " + toHex(crc & 0xFF) + toHex((crc >> 8) & 0xFF) + + " got " + toHex(recvCrc & 0xFF) + toHex((recvCrc >> 8) & 0xFF)); + // Discard one byte and retry + state.rxBuffer.shift(); + processResponse(); + return; + } + + debug("RX: " + bytesToHex(frame)); + clearResponseTimeout(); + + // Extract payload (after slave+FC, before CRC) + var payload = frame.slice(2, expectedLen - 2); + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + state.rxBuffer = []; + cb(null, payload); +} + +function clearResponseTimeout() { + if (state.responseTimer) { + Timer.clear(state.responseTimer); + state.responseTimer = null; + } +} + +/* === MODBUS API === */ + +/** + * Read a register/coil and return decoded value via callback. + * @param {number} slave - MODBUS slave address + * @param {object} reg - reg descriptor: {addr, rtype, itype, bo, wo} + * @param {function} callback - callback(error, value) + */ +function modbusRead(slave, reg, callback) { + var fc, regCount; + + if (reg.rtype === "holding") { + fc = FC.READ_HOLDING_REGISTERS; + } else if (reg.rtype === "input") { + fc = FC.READ_INPUT_REGISTERS; + } else if (reg.rtype === "coil") { + fc = FC.READ_COILS; + } else if (reg.rtype === "discrete") { + fc = FC.READ_DISCRETE_INPUTS; + } else { + callback("Unknown rtype: " + reg.rtype, null); + return; + } + + // 32-bit types need 2 registers + regCount = (reg.itype === "u32" || reg.itype === "i32" || reg.itype === "f32") ? 2 : 1; + + // Coils/discrete use bit count, not register count + var quantity = regCount; + + var reqData = [ + (reg.addr >> 8) & 0xFF, + reg.addr & 0xFF, + (quantity >> 8) & 0xFF, + quantity & 0xFF + ]; + + sendRequest(slave, fc, reqData, function(err, payload) { + if (err) { + callback(err, null); + return; + } + + var value; + if (reg.rtype === "coil" || reg.rtype === "discrete") { + // payload: [byteCount, byte0, ...] bit 0 of byte0 = coil 0 + if (!payload || payload.length < 2) { + callback("Short coil response", null); + return; + } + value = (payload[1] & 0x01) ? 1 : 0; + } else { + // payload: [byteCount, regHi, regLo, ...] + if (!payload || payload.length < 3) { + callback("Short register response", null); + return; + } + // Skip byteCount byte at payload[0] + var regBytes = payload.slice(1); + value = decodeValue(regBytes, reg.itype, reg.bo, reg.wo); + if (value === null) { + callback("Decode failed (insufficient bytes)", null); + return; + } + } + callback(null, value); + }); +} + +/** + * Write a value to a register/coil. + * @param {number} slave - MODBUS slave address + * @param {object} reg - reg descriptor: {addr, rtype, itype, bo, wo} + * @param {number} value - Raw value to write + * @param {function} callback - callback(error) + */ +function modbusWrite(slave, reg, value, callback) { + var fc, reqData, encoded; + + if (reg.rtype === "coil") { + fc = FC.WRITE_SINGLE_COIL; + reqData = [ + (reg.addr >> 8) & 0xFF, + reg.addr & 0xFF, + value ? 0xFF : 0x00, + 0x00 + ]; + sendRequest(slave, fc, reqData, function(err) { + callback(err || null); + }); + return; + } + + if (reg.rtype !== "holding") { + callback("Write only supported for 'holding' and 'coil' register types"); + return; + } + + if (reg.itype === "u16" || reg.itype === "i16") { + fc = FC.WRITE_SINGLE_REGISTER; + encoded = encodeValue(value, reg.itype, reg.bo, reg.wo); + reqData = [ + (reg.addr >> 8) & 0xFF, + reg.addr & 0xFF, + encoded[0], + encoded[1] + ]; + sendRequest(slave, fc, reqData, function(err) { + callback(err || null); + }); + return; + } + + if (reg.itype === "u32" || reg.itype === "i32" || reg.itype === "f32") { + fc = FC.WRITE_MULTIPLE_REGISTERS; + encoded = encodeValue(value, reg.itype, reg.bo, reg.wo); + // FC 0x10: addr(2) + quantity(2) + byteCount(1) + data(4) + reqData = [ + (reg.addr >> 8) & 0xFF, + reg.addr & 0xFF, + 0x00, 0x02, // quantity: 2 registers + 0x04, // byte count: 4 bytes + encoded[0], encoded[1], encoded[2], encoded[3] + ]; + sendRequest(slave, fc, reqData, function(err) { + callback(err || null); + }); + return; + } + + callback("Unsupported itype for write: " + reg.itype); +} + +/* === HTTP UTILITIES === */ + +/** + * URL-decode a percent-encoded string (handles %XX and + => space). + */ +function urlDecode(s) { + var result = ""; + var i = 0; + while (i < s.length) { + var c = s[i]; + if (c === "+") { + result += " "; + i++; + } else if (c === "%" && i + 2 < s.length) { + var hex = s[i + 1] + s[i + 2]; + result += String.fromCharCode(parseInt(hex, 16)); + i += 3; + } else { + result += c; + i++; + } + } + return result; +} + +/** + * Parse query string into a key/value object. + * Handles values with embedded '=' characters. + */ +function parseQS(qs) { + var params = {}; + if (!qs || qs.length === 0) return params; + var parts = qs.split("&"); + for (var i = 0; i < parts.length; i++) { + var eqIdx = parts[i].indexOf("="); + if (eqIdx < 0) { + params[parts[i]] = null; + } else { + var key = parts[i].substring(0, eqIdx); + var val = parts[i].substring(eqIdx + 1); + params[key] = val; + } + } + return params; +} + +/** + * Send a JSON error response. + */ +function sendError(response, code, msg) { + response.code = code; + response.body = JSON.stringify({ error: msg }); + response.send(); +} + +/* === HTTP HANDLER === */ + +function httpHandler(request, response) { + var descriptor = null; + var slave = CONFIG.DEFAULT_SLAVE; + + // --- Parse input --- + if (request.method === "POST" && request.body && request.body.length > 0) { + var body; + try { + body = JSON.parse(request.body); + } catch (e) { + sendError(response, 400, "Invalid JSON body: " + e); + return; + } + // Accept {"register": {...}, "slave": N} or the descriptor directly + if (body.register !== undefined) { + descriptor = body.register; + if (body.slave !== undefined) slave = body.slave; + } else if (body.reg !== undefined) { + descriptor = body; + } else { + sendError(response, 400, "Body must contain 'register' key or be a register descriptor"); + return; + } + } else { + // GET: parse from query string + var params = parseQS(request.query); + if (!params.register) { + sendError(response, 400, "Missing 'register' query parameter"); + return; + } + var regJson = urlDecode(params.register); + try { + descriptor = JSON.parse(regJson); + } catch (e) { + sendError(response, 400, "Invalid JSON in 'register': " + e); + return; + } + if (params.slave) slave = parseInt(params.slave, 10); + } + + // --- Validate descriptor --- + if (!descriptor || !descriptor.reg) { + sendError(response, 400, "Descriptor missing 'reg' field"); + return; + } + var reg = descriptor.reg; + if (reg.addr === undefined || reg.addr === null) { + sendError(response, 400, "reg.addr is required"); + return; + } + if (!reg.rtype) { + sendError(response, 400, "reg.rtype is required"); + return; + } + if (!reg.itype) reg.itype = "u16"; + if (!reg.bo) reg.bo = "BE"; + if (!reg.wo) reg.wo = "BE"; + if (!descriptor.scale || descriptor.scale === 0) descriptor.scale = 1; + + // --- Check MODBUS state --- + if (!state.isReady) { + sendError(response, 503, "MODBUS not initialized"); + return; + } + if (state.pendingRequest) { + sendError(response, 503, "Bus busy, try again"); + return; + } + + // --- Determine read or write --- + var isWrite = (descriptor.value !== null && + descriptor.value !== undefined && + descriptor.rights === "RW"); + + if (isWrite) { + debug("WRITE slave=" + slave + " addr=" + reg.addr + + " itype=" + reg.itype + " value=" + descriptor.value); + + modbusWrite(slave, reg, descriptor.value, function(err) { + if (err) { + sendError(response, 500, err); + return; + } + descriptor.human_readable = descriptor.value * descriptor.scale; + response.code = 200; + response.body = JSON.stringify(descriptor); + response.send(); + }); + } else { + debug("READ slave=" + slave + " addr=" + reg.addr + " itype=" + reg.itype); + + modbusRead(slave, reg, function(err, value) { + if (err) { + sendError(response, 500, err); + return; + } + descriptor.value = value; + descriptor.human_readable = value * descriptor.scale; + response.code = 200; + response.body = JSON.stringify(descriptor); + response.send(); + }); + } +} + +/* === INITIALIZATION === */ + +function init() { + print("MODBUS-RTU HTTP Bridge"); + print("======================"); + + state.uart = UART.get(); + if (!state.uart) { + print("ERROR: UART not available"); + return; + } + + if (!state.uart.configure({ baud: CONFIG.BAUD_RATE, mode: CONFIG.MODE })) { + print("ERROR: UART configuration failed"); + return; + } + + state.uart.recv(onReceive); + state.isReady = true; + + HTTPServer.registerEndpoint("modbus", httpHandler); + + print("UART: " + CONFIG.BAUD_RATE + " baud, " + CONFIG.MODE); + print("Default slave: " + CONFIG.DEFAULT_SLAVE); + print("Endpoint: GET/POST /script//modbus"); + print(""); + print("Example (GET):"); + print(" curl 'http:///script//modbus?register=%7B%22name%22%3A%22W%22%2C%22units%22%3A%22W%22%2C%22scale%22%3A1%2C%22rights%22%3A%22R%22%2C%22reg%22%3A%7B%22addr%22%3A0%2C%22rtype%22%3A%22holding%22%2C%22itype%22%3A%22u16%22%2C%22bo%22%3A%22BE%22%2C%22wo%22%3A%22BE%22%7D%2C%22value%22%3Anull%2C%22human_readable%22%3Anull%7D'"); + print(""); + print("Example (POST):"); + print(" curl -X POST 'http:///script//modbus' \\"); + print(" -H 'Content-Type: application/json' \\"); + print(" -d '{\"register\":{\"name\":\"W\",\"units\":\"W\",\"scale\":1,\"rights\":\"R\",\"reg\":{\"addr\":0,\"rtype\":\"holding\",\"itype\":\"u16\",\"bo\":\"BE\",\"wo\":\"BE\"},\"value\":null,\"human_readable\":null}}'"); +} + +init(); diff --git a/the_pill/MODBUS/JK200-MBS/README.md b/the_pill/MODBUS/JK200-MBS/README.md deleted file mode 100644 index 79a9f41..0000000 --- a/the_pill/MODBUS/JK200-MBS/README.md +++ /dev/null @@ -1,184 +0,0 @@ -# JK200 BMS - MODBUS-RTU Reader - -Script for reading live data from a **Jikong JK-PB series BMS** (commonly called JK200 for the 200A variants) over MODBUS-RTU via RS485/UART using The Pill. - -Compatible models: JK-PB2A8S20P, JK-PB2A16S20P, JK-PB2A20S20P (and other PB-series variants). - -## Files - -### [the_pill_mbsa_jk200.shelly.js](the_pill_mbsa_jk200.shelly.js) - -Reads two register blocks per poll cycle and prints a full status report to the Shelly script console: - -- All individual cell voltages with min/max/delta -- Pack voltage, current, power -- State of Charge (SOC) -- MOSFET temperature, battery temperature sensors 1 & 2 -- Balance current -- Active alarm flags - ---- - -## Enable MODBUS on the BMS - -By default the JK BMS communicates over its own proprietary protocol. To activate RS485 MODBUS slave mode: - -1. Open the **JiKong BMS** app and connect via Bluetooth. -2. Go to **Settings -> Device Address**. -3. Set the address to any value from **1 to 15** (0 = disabled). -4. The chosen address becomes the MODBUS slave ID. - -Communication: **115200 baud, 8N1** (protocol "001 - JK BMS RS485 Modbus V1.0"). - ---- - -## Hardware Requirements - -- Shelly device with UART (e.g., **The Pill**) -- RS485 transceiver module (e.g., MAX485, SP485) -- Jikong JK-PB series BMS with RS485 connector - -### Wiring - -**RS485 module to Shelly (The Pill):** - -| RS485 Module | Shelly / The Pill | -|---|---| -| RO (Receiver Output) | RX (GPIO) | -| DI (Driver Input) | TX (GPIO) | -| VCC | 3.3V or 5V | -| GND | GND | - -**RS485 module to JK BMS:** - -| RS485 Module | JK BMS RS485 Port | -|---|---| -| A (D+) | A (D+) | -| B (D-) | B (D-) | - -> The JK BMS RS485 port is a 4-pin JST-style connector. Typical pinout: GND, A, B, +5V. Consult your BMS manual for the exact connector layout -- not all units are identical. - ---- - -## Register Map - -The JK BMS uses **stride-1 addressing** for 16-bit fields and **stride-2** for 32-bit fields — no padding registers are inserted between values: - -| Value width | MODBUS registers used | Layout | -|---|---|---| -| U_WORD / S_WORD (16-bit) | 1 | `[data]` | -| U_DWORD / S_DWORD (32-bit) | 2 | `[hi, lo]` | - -> Note: The V1.0 protocol specification describes stride-2 WORDs and stride-4 DWORDs with padding. The actual device behaviour at 115200 baud omits all padding registers. - -### Block A — Cell Voltages (`FC 0x03`, start `0x1200`, qty `CELL_COUNT`) - -| Address | Parameter | Type | Unit | -|---|---|---|---| -| 0x1200 | Cell 1 voltage | U_WORD | mV | -| 0x1201 | Cell 2 voltage | U_WORD | mV | -| ... | ... | ... | ... | -| 0x1200 + (N-1) | Cell N voltage | U_WORD | mV | - -Read quantity = `CELL_COUNT` registers. Cell N voltage = `registers[N-1]`. - -### Block B — Key Parameters (`FC 0x03`, start `0x128A`, qty 30) - -| Offset | Address | Parameter | Type | Unit | Notes | -|---|---|---|---|---|---| -| regs[0] | 0x128A | MOSFET temperature | S_WORD | 0.1 degC | | -| regs[1..2] | 0x128B-C | (reserved) | -- | -- | | -| regs[3..4] | 0x128D-E | Pack voltage | U_DWORD | mV | `regs[3]*65536 + regs[4]` | -| regs[5..6] | 0x128F-90 | Pack power | S_DWORD | mW | + = charging | -| regs[7..8] | 0x1291-92 | Pack current | S_DWORD | mA | + = charging | -| regs[9] | 0x1293 | Temperature 1 | S_WORD | 0.1 degC | | -| regs[10] | 0x1294 | Temperature 2 | S_WORD | 0.1 degC | | -| regs[11..12] | 0x1295-96 | Alarm bitmask | U_DWORD | -- | see below | -| regs[13] | 0x1297 | Balance current | S_WORD | mA | | -| regs[14] | 0x1298 | State of Charge | U_WORD | % | | - -### Alarm Bitmask - -| Bit | Meaning | -|---|---| -| 0 | Cell undervoltage | -| 1 | Cell overvoltage | -| 2 | Discharge overcurrent | -| 3 | Charge overcurrent | -| 4 | Low temperature (charge) | -| 5 | High temperature (discharge) | -| 6 | MOS overtemperature | -| 7 | Short circuit | -| 8 | Cell delta too large | -| 9 | Pack undervoltage | -| 10 | Pack overvoltage | -| 11 | Low SOC | -| 15 | Manual shutdown | - ---- - -## Configuration - -```javascript -var CONFIG = { - BAUD_RATE: 115200, // JK BMS RS485 Modbus V1.0 operates at 115200 baud - MODE: '8N1', - SLAVE_ID: 1, // must match BMS Device Address setting - CELL_COUNT: 16, // 8 / 10 / 12 / 14 / 16 / 20 / 24 - RESPONSE_TIMEOUT: 2000, // ms - INTER_READ_DELAY: 100, // ms between block A and block B reads - POLL_INTERVAL: 10000, // ms between full poll cycles - DEBUG: false, // true = print raw TX/RX frames -}; -``` - -> Set `CELL_COUNT` to match your battery pack. Common values: 8 (24 V), 16 (48 V), 20 (60 V), 24 (72 V). - ---- - -## Console Output Example - -``` -JK200 BMS - MODBUS-RTU Reader -============================== -Cells: 16 | Poll: 10 s - ---- JK200 BMS --- - Cells (16): - 1: 3.420 V - 2: 3.419 V - 3: 3.421 V - 4: 3.418 V (min) - ... - 6: 3.428 V (max) - ... - 16: 3.415 V - Delta: 0.013 V | Min: 3.415 V (cell 16) | Max: 3.428 V (cell 6) - Pack: 54.667 V | 0.585 A | 31.979 W - SOC: 63 % - Temp: MOS 22.5 C | T1 20.3 C | T2 21.1 C - Balance: 0.000 A - Alarms: none -``` - ---- - -## Implementation Notes - -- Only FC 0x03 (Read Holding Registers) is used -- the script is **read-only**. -- Two bulk reads per poll: block A (cell voltages) then block B (parameters), with a 100 ms inter-read delay for bus stability. -- CRC-16 is computed via lookup table (MODBUS polynomial 0xA001). -- MODBUS exception responses are detected (FC | 0x80) and surfaced as error strings. -- A configurable response timeout (default 2 s) guards each request. -- Signed 32-bit values (power, current) are assembled from two 16-bit registers using integer arithmetic, avoiding bitshift overflow in mJS. -- All source characters are ASCII -- mJS (Shelly scripting runtime) does not support Unicode in script source. - ---- - -## References - -- [JK BMS RS485 Modbus V1.0 Protocol](https://github.com/ciciban/jkbms-PB2A16S20P) -- [ESPHome JK-BMS integration (syssi)](https://github.com/syssi/esphome-jk-bms) -- [ESPHome JK-BMS MODBUS YAML example](https://github.com/syssi/esphome-jk-bms/blob/main/esp32-jk-pb-modbus-example.yaml) -- [JK BMS RS485 Modbus V1.1 PDF](https://github.com/syssi/esphome-jk-bms/blob/main/docs/pb2a16s20p/BMS%20RS485%20Modbus%20V1.1.pdf) -- [MODBUS Protocol Specification](https://modbus.org/specs.php) diff --git a/the_pill/MODBUS/JKESS/JK200-MBS/README.md b/the_pill/MODBUS/JKESS/JK200-MBS/README.md new file mode 100644 index 0000000..82bc410 --- /dev/null +++ b/the_pill/MODBUS/JKESS/JK200-MBS/README.md @@ -0,0 +1,29 @@ +# JK200 BMS MODBUS Examples + +MODBUS-RTU scripts for JK-PB series BMS (commonly called JK200 variants). + +## Problem (The Story) +You need reliable pack voltage/current/SOC/cell telemetry and alarm visibility from JK BMS on a local bus. These scripts read JK MODBUS registers and expose battery health in a format suitable for local automation. + +## Persona +- DIY battery builder running JK BMS +- Energy integrator connecting BMS data to Shelly automations +- Service technician diagnosing cell imbalance and protection trips + +## Files +- [`jk200.shelly.js`](jk200.shelly.js): console reader +- [`jk200_vc.shelly.js`](jk200_vc.shelly.js): reader + Virtual Components + +## Screenshot +![JK200 VC Screenshot](screenshot.png) +This view shows the JK200 Virtual Components page with pack-level telemetry, temperatures, alarm bitmask, balance current, and SOC values. + +## RS485 Wiring (The Pill 5-Terminal Add-on) +| The Pill Pin | JK BMS Side | +|---|---| +| `IO1 (TX)` -> `B (D-)` | RS485 B | +| `IO2 (RX)` -> `A (D+)` | RS485 A | +| `IO3` -> `DE/RE` | transceiver direction | +| `GND` -> `GND` | common reference | + +Default communication in JK examples: `115200`, `8N1`. diff --git a/the_pill/MODBUS/JK200-MBS/the_pill_mbsa_jk200.shelly.js b/the_pill/MODBUS/JKESS/JK200-MBS/jk200.shelly.js similarity index 77% rename from the_pill/MODBUS/JK200-MBS/the_pill_mbsa_jk200.shelly.js rename to the_pill/MODBUS/JKESS/JK200-MBS/jk200.shelly.js index 6ffb0e9..80b4a16 100644 --- a/the_pill/MODBUS/JK200-MBS/the_pill_mbsa_jk200.shelly.js +++ b/the_pill/MODBUS/JKESS/JK200-MBS/jk200.shelly.js @@ -3,7 +3,7 @@ * @description MODBUS-RTU reader for Jikong JK-PB series BMS over RS485. * Reads cell voltages, pack voltage, current, SOC, temperatures and alarms. * @status production - * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/MODBUS/JK200-MBS/the_pill_mbsa_jk200.shelly.js + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/MODBUS/JKESS/JK200-MBS/jk200.shelly.js */ /** @@ -17,13 +17,12 @@ * Any non-zero address activates RS485 Modbus slave mode. * Default: 9600 baud, 8N1. * - * Hardware Connection (via RS485 transceiver, e.g. MAX485): - * RS485 A (D+) <-> BMS RS485 A (D+) - * RS485 B (D-) <-> BMS RS485 B (D-) - * RS485 RO -> Shelly RX (GPIO) - * RS485 DI -> Shelly TX (GPIO) - * RS485 VCC -> 3.3V or 5V - * RS485 GND -> GND + * The Pill 5-Terminal Add-on wiring: + * IO1 (TX) ─── B (D-) ──> BMS RS485 B (D-) + * IO2 (RX) ─── A (D+) ──> BMS RS485 A (D+) + * IO3 ─── DE/RE ── direction control (automatic) + * GND ─── GND ──> BMS GND + * 5V ─── 5V ──> BMS VCC (if 5V powered) * * Addressing scheme (actual JK BMS RS485 Modbus V1.0 at 115200 baud): * - Supports only FC 0x03 (Read Holding Registers). @@ -65,13 +64,129 @@ var CONFIG = { DEBUG: true, }; -/* === REGISTER MAP === */ +/* === BLOCK READ COORDINATES === */ var REG = { - CELLS_BASE: 0x1200, - MAIN_BASE: 0x128A, - MAIN_QTY: 30, + CELLS_BASE: 0x1200, // Cell voltage block start address + MAIN_BASE: 0x128A, // Main parameter block start address + MAIN_QTY: 30, // Main block register count (reads ahead for safety) }; +/* === ENTITIES (Main block - logical values at actual register addresses) === + * + * Cell voltages are NOT enumerated here because their count is dynamic + * (CONFIG.CELL_COUNT). The cell entity template is: + * addr = REG.CELLS_BASE + cellIndex, rtype 0x03, itype "u16", units "mV" + * See parseCellBlock() for extraction logic. + * + * Main block is read as one bulk FC 0x03 from REG.MAIN_BASE, qty REG.MAIN_QTY. + * Each entity below documents its actual register address; the block offset is + * (reg.addr - REG.MAIN_BASE). Word-order within 32-bit pairs: high word first. + */ +var ENTITIES = [ + // + // --- Temperatures --- + // + { + name: "MOSFET Temperature", + units: "degC", + reg: { addr: 0x128A, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 0.1, // raw in 0.1 degC units + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + // + // --- Pack parameters --- + // + { + name: "Pack Voltage", + units: "mV", + reg: { addr: 0x128D, rtype: 0x03, itype: "u32", bo: "BE", wo: "BE" }, + scale: 1, // raw in mV (hi word at 0x128D, lo at 0x128E) + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + { + name: "Pack Power", + units: "mW", + reg: { addr: 0x128F, rtype: 0x03, itype: "i32", bo: "BE", wo: "BE" }, + scale: 1, // raw in mW; positive = charge, negative = discharge + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + { + name: "Pack Current", + units: "mA", + reg: { addr: 0x1291, rtype: 0x03, itype: "i32", bo: "BE", wo: "BE" }, + scale: 1, // raw in mA; positive = charge, negative = discharge + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + // + // --- Cell temperatures --- + // + { + name: "Temperature 1", + units: "degC", + reg: { addr: 0x1293, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 0.1, + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + { + name: "Temperature 2", + units: "degC", + reg: { addr: 0x1294, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 0.1, + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + // + // --- Status --- + // + { + name: "Alarm Bitmask", + units: "-", + reg: { addr: 0x1295, rtype: 0x03, itype: "u32", bo: "BE", wo: "BE" }, + scale: 1, // bitmask; see ALARM_LABELS for bit definitions + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + { + name: "Balance Current", + units: "mA", + reg: { addr: 0x1297, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 1, + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, + { + name: "State of Charge", + units: "%", + reg: { addr: 0x1298, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, + scale: 1, + rights: "R", + vcId: null, + handle: null, + vcHandle: null, + }, +]; + /* === ALARM BIT LABELS === */ var ALARM_LABELS = [ 'Cell undervoltage', // bit 0 @@ -384,17 +499,18 @@ function parseCellBlock(regs) { /* === PARSE MAIN BLOCK (start 0x128A, qty 30) === */ -// Actual register offsets at 115200 baud (stride-1 WORDs, stride-2 DWORDs): -// [0] 0x128A MOSFET temp S_WORD 0.1 degC -// [1..2] 0x128B-C (reserved) -// [3..4] 0x128D-E Pack voltage U_DWORD mV hi=regs[3], lo=regs[4] -// [5..6] 0x128F-90 Pack power S_DWORD mW hi=regs[5], lo=regs[6] -// [7..8] 0x1291-92 Pack current S_DWORD mA hi=regs[7], lo=regs[8] -// [9] 0x1293 Temp 1 S_WORD 0.1 degC -// [10] 0x1294 Temp 2 S_WORD 0.1 degC -// [11..12] 0x1295-96 Alarm bits U_DWORD bitmask hi=regs[11], lo=regs[12] -// [13] 0x1297 Balance curr S_WORD mA -// [14] 0x1298 SOC U_WORD % +// Register layout is documented in ENTITIES above. +// Block offsets used below: offset = (reg.addr - REG.MAIN_BASE). +// regs[0] -> MOSFET Temperature (0x128A, i16, *0.1 degC) +// regs[1..2] -> reserved (0x128B-C) +// regs[3..4] -> Pack Voltage (0x128D, u32 hi/lo, mV) +// regs[5..6] -> Pack Power (0x128F, i32 hi/lo, mW) +// regs[7..8] -> Pack Current (0x1291, i32 hi/lo, mA) +// regs[9] -> Temperature 1 (0x1293, i16, *0.1 degC) +// regs[10] -> Temperature 2 (0x1294, i16, *0.1 degC) +// regs[11..12]-> Alarm Bitmask (0x1295, u32 hi/lo) +// regs[13] -> Balance Current (0x1297, i16, mA) +// regs[14] -> State of Charge (0x1298, u16, %) function parseMainBlock(regs) { return { mosFetTemp: toSigned16(regs[0]), // 0.1 degC diff --git a/the_pill/MODBUS/JKESS/JK200-MBS/jk200_vc.shelly.js b/the_pill/MODBUS/JKESS/JK200-MBS/jk200_vc.shelly.js new file mode 100644 index 0000000..df98dec --- /dev/null +++ b/the_pill/MODBUS/JKESS/JK200-MBS/jk200_vc.shelly.js @@ -0,0 +1,686 @@ +/** + * @title JK200 BMS MODBUS-RTU Reader + Virtual Components + * @description MODBUS-RTU reader for Jikong JK-PB series BMS over RS485 with + * Virtual Component updates. Reads pack voltage, current, SOC, temperatures + * and alarms and pushes values to user-defined virtual number components. + * @status production + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/MODBUS/JKESS/JK200-MBS/jk200_vc.shelly.js + */ + +/** + * JK200 BMS - MODBUS-RTU Reader + Virtual Components for Shelly (The Pill) + * + * Compatible with Jikong JK-PB series BMS: + * JK-PB2A8S20P, JK-PB2A16S20P, JK-PB2A20S20P (and other PB variants). + * + * To enable MODBUS on the BMS: + * Open the JK BMS app -> Settings -> Device Address -> set to 1-15. + * Any non-zero address activates RS485 Modbus slave mode. + * Default: 9600 baud, 8N1. + * + * The Pill 5-Terminal Add-on wiring: + * IO1 (TX) ─── B (D-) ──> BMS RS485 B (D-) + * IO2 (RX) ─── A (D+) ──> BMS RS485 A (D+) + * IO3 ─── DE/RE ── direction control (automatic) + * GND ─── GND ──> BMS GND + * 5V ─── 5V ──> BMS VCC (if 5V powered) + * + * Addressing scheme (actual JK BMS RS485 Modbus V1.0 at 115200 baud): + * - Supports only FC 0x03 (Read Holding Registers). + * - U_WORD (16-bit): stride 1 -- 1 register, no padding. + * - U_DWORD (32-bit): stride 2 -- 2 registers (hi, lo), no trailing padding. + * - S_WORD / S_DWORD: same strides, interpreted as signed (two's complement). + * + * Two register blocks are read per poll cycle: + * Block A -- Cell voltages: FC 0x03, start 0x1200, qty CELL_COUNT + * Block B -- Key parameters: FC 0x03, start 0x128A, qty 30 + * + * Block B actual register layout (qty 30, start 0x128A, stride-1 WORDs): + * Offset Reg addr Field Type Unit + * 0 0x128A MOSFET temp S_WORD 0.1 degC + * 1-2 0x128B-C (reserved) + * 3-4 0x128D-E Pack voltage U_DWORD mV (hi, lo) + * 5-6 0x128F-90 Pack power S_DWORD mW (hi, lo, converted to W) + * 7-8 0x1291-92 Pack current S_DWORD mA (hi, lo, converted to A) + * 9 0x1293 Temperature 1 S_WORD 0.1 degC + * 10 0x1294 Temperature 2 S_WORD 0.1 degC + * 11-12 0x1295-96 Alarm bitmask U_DWORD -- (hi, lo) + * 13 0x1297 Balance current S_WORD mA + * 14 0x1298 State of Charge U_WORD % + * + * Virtual Component mapping (pre-create with skills/modbus-vc-deploy.md): + * number:200 MOSFET Temperature degC + * number:201 Pack Voltage V + * number:202 Pack Power W + * number:203 Pack Current A + * number:204 Temperature 1 degC + * number:205 Temperature 2 degC + * number:206 Alarm Bitmask - + * number:207 Balance Current mA + * number:208 State of Charge % + * group:200 JK200 BMS (group) + * + * References: + * JK BMS RS485 Modbus V1.0: https://github.com/ciciban/jkbms-PB2A16S20P + * ESPHome integration: https://github.com/syssi/esphome-jk-bms + */ + +/* === CONFIG === */ +var CONFIG = { + BAUD_RATE: 115200, + MODE: '8N1', + SLAVE_ID: 1, + CELL_COUNT: 16, // 8, 10, 12, 14, 16, 20, 24 -- match your pack + RESPONSE_TIMEOUT: 2000, // ms; larger for bulk reads at 9600 baud + INTER_READ_DELAY: 100, // ms between block A and block B reads + POLL_INTERVAL: 10000, // ms between full poll cycles + DEBUG: true, +}; + +/* === BLOCK READ COORDINATES === */ +var REG = { + CELLS_BASE: 0x1200, // Cell voltage block start address + MAIN_BASE: 0x128A, // Main parameter block start address + MAIN_QTY: 30, // Main block register count (reads ahead for safety) +}; + +/* === ENTITIES (Main block - logical values at actual register addresses) === + * + * Cell voltages are NOT enumerated here because their count is dynamic + * (CONFIG.CELL_COUNT). The cell entity template is: + * addr = REG.CELLS_BASE + cellIndex, rtype 0x03, itype "u16", units "mV" + * See parseCellBlock() for extraction logic. + * + * Main block is read as one bulk FC 0x03 from REG.MAIN_BASE, qty REG.MAIN_QTY. + * Each entity below documents its actual register address; the block offset is + * (reg.addr - REG.MAIN_BASE). Word-order within 32-bit pairs: high word first. + */ +var ENTITIES = [ + // + // --- Temperatures --- + // + { + name: "MOSFET Temperature", + units: "degC", + reg: { addr: 0x128A, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 0.1, // raw in 0.1 degC units + rights: "R", + vcId: "number:200", + handle: null, + vcHandle: null, + }, + // + // --- Pack parameters --- + // + { + name: "Pack Voltage", + units: "V", + reg: { addr: 0x128D, rtype: 0x03, itype: "u32", bo: "BE", wo: "BE" }, + scale: 0.001, // raw in mV -> V (hi word at 0x128D, lo at 0x128E) + rights: "R", + vcId: "number:201", + handle: null, + vcHandle: null, + }, + { + name: "Pack Power", + units: "W", + reg: { addr: 0x128F, rtype: 0x03, itype: "i32", bo: "BE", wo: "BE" }, + scale: 0.001, // raw in mW -> W; positive = charge, negative = discharge + rights: "R", + vcId: "number:202", + handle: null, + vcHandle: null, + }, + { + name: "Pack Current", + units: "A", + reg: { addr: 0x1291, rtype: 0x03, itype: "i32", bo: "BE", wo: "BE" }, + scale: 0.001, // raw in mA -> A; positive = charge, negative = discharge + rights: "R", + vcId: "number:203", + handle: null, + vcHandle: null, + }, + // + // --- Cell temperatures --- + // + { + name: "Temperature 1", + units: "degC", + reg: { addr: 0x1293, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 0.1, + rights: "R", + vcId: "number:204", + handle: null, + vcHandle: null, + }, + { + name: "Temperature 2", + units: "degC", + reg: { addr: 0x1294, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 0.1, + rights: "R", + vcId: "number:205", + handle: null, + vcHandle: null, + }, + // + // --- Status --- + // + { + name: "Alarm Bitmask", + units: "-", + reg: { addr: 0x1295, rtype: 0x03, itype: "u32", bo: "BE", wo: "BE" }, + scale: 1, // bitmask; see ALARM_LABELS for bit definitions + rights: "R", + vcId: "number:206", + handle: null, + vcHandle: null, + }, + { + name: "Balance Current", + units: "mA", + reg: { addr: 0x1297, rtype: 0x03, itype: "i16", bo: "BE", wo: "BE" }, + scale: 1, + rights: "R", + vcId: "number:207", + handle: null, + vcHandle: null, + }, + { + name: "State of Charge", + units: "%", + reg: { addr: 0x1298, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, + scale: 1, + rights: "R", + vcId: "number:208", + handle: null, + vcHandle: null, + }, +]; + +/* === ALARM BIT LABELS === */ +var ALARM_LABELS = [ + 'Cell undervoltage', // bit 0 + 'Cell overvoltage', // bit 1 + 'Discharge overcurrent', // bit 2 + 'Charge overcurrent', // bit 3 + 'Low temperature (chg)', // bit 4 + 'High temperature (dis)', // bit 5 + 'MOS overtemperature', // bit 6 + 'Short circuit', // bit 7 + 'Cell delta too large', // bit 8 + 'Pack undervoltage', // bit 9 + 'Pack overvoltage', // bit 10 + 'Low SOC', // bit 11 +]; + +/* === CRC-16 TABLE (MODBUS polynomial 0xA001) === */ +var CRC_TABLE = [ + 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, + 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440, + 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40, + 0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841, + 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40, + 0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41, + 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641, + 0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040, + 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240, + 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441, + 0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41, + 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840, + 0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41, + 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40, + 0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640, + 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041, + 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240, + 0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441, + 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41, + 0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840, + 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41, + 0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40, + 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640, + 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041, + 0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241, + 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440, + 0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40, + 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841, + 0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40, + 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41, + 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641, + 0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040, +]; + +/* === STATE === */ +var state = { + uart: null, + rxBuffer: [], + isReady: false, + pendingRequest: null, + responseTimer: null, + pollTimer: null, +}; + +/* === HELPERS === */ + +function toHex(n) { + n = n & 0xFF; + return (n < 16 ? '0' : '') + n.toString(16).toUpperCase(); +} + +function bytesToHex(bytes) { + var s = ''; + for (var i = 0; i < bytes.length; i++) { + s += toHex(bytes[i]); + if (i < bytes.length - 1) s += ' '; + } + return s; +} + +function debug(msg) { + if (CONFIG.DEBUG) { + print('[JK200] ' + msg); + } +} + +function calcCRC(bytes) { + var crc = 0xFFFF; + for (var i = 0; i < bytes.length; i++) { + crc = (crc >> 8) ^ CRC_TABLE[(crc ^ bytes[i]) & 0xFF]; + } + return crc; +} + +function bytesToStr(bytes) { + var s = ''; + for (var i = 0; i < bytes.length; i++) { + s += String.fromCharCode(bytes[i] & 0xFF); + } + return s; +} + +function buildFrame(slaveAddr, functionCode, data) { + var frame = [slaveAddr & 0xFF, functionCode & 0xFF]; + for (var i = 0; i < data.length; i++) { + frame.push(data[i] & 0xFF); + } + var crc = calcCRC(frame); + frame.push(crc & 0xFF); + frame.push((crc >> 8) & 0xFF); + return frame; +} + +/* === SIGNED CONVERSIONS === */ + +function toSigned16(v) { + return v >= 0x8000 ? v - 0x10000 : v; +} + +function toSigned32(hi, lo) { + var v = hi * 65536 + lo; + return v >= 2147483648 ? v - 4294967296 : v; +} + +/* === DISPLAY FORMATTERS (integer arithmetic only) === */ + +function pad3(n) { + if (n < 10) return '00' + n; + if (n < 100) return '0' + n; + return '' + n; +} + +// millivolts -> "X.XXX V" +function fmtV(mv) { + var sign = mv < 0 ? '-' : ''; + var abs = mv < 0 ? -mv : mv; + return sign + Math.floor(abs / 1000) + '.' + pad3(abs % 1000) + ' V'; +} + +// milliamps -> "X.XXX A" +function fmtA(ma) { + var sign = ma < 0 ? '-' : ''; + var abs = ma < 0 ? -ma : ma; + return sign + Math.floor(abs / 1000) + '.' + pad3(abs % 1000) + ' A'; +} + +// milliwatts -> "X.XXX W" +function fmtW(mw) { + var sign = mw < 0 ? '-' : ''; + var abs = mw < 0 ? -mw : mw; + return sign + Math.floor(abs / 1000) + '.' + pad3(abs % 1000) + ' W'; +} + +// 0.1 degC units -> "X.X C" +function fmtC(tenths) { + var sign = tenths < 0 ? '-' : ''; + var abs = tenths < 0 ? -tenths : tenths; + return sign + Math.floor(abs / 10) + '.' + (abs % 10) + ' C'; +} + +/* === VIRTUAL COMPONENT === */ + +function updateVc(entity, value) { + if (!entity || !entity.vcHandle) return; + entity.vcHandle.setValue(value); + debug(entity.name + ' -> ' + value + ' [' + entity.units + ']'); +} + +/* === MODBUS CORE (FC 0x03 only) === */ + +var FC_READ_HOLDING = 0x03; + +function sendRequest(data, callback) { + if (!state.isReady) { + callback('Not initialised', null); + return; + } + if (state.pendingRequest) { + callback('Request pending', null); + return; + } + + var frame = buildFrame(CONFIG.SLAVE_ID, FC_READ_HOLDING, data); + debug('TX: ' + bytesToHex(frame)); + + state.pendingRequest = { callback: callback }; + state.rxBuffer = []; + + state.responseTimer = Timer.set(CONFIG.RESPONSE_TIMEOUT, false, function () { + if (state.pendingRequest) { + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + debug('Timeout'); + cb('Timeout', null); + } + }); + + state.uart.write(bytesToStr(frame)); +} + +function onReceive(data) { + if (!data || data.length === 0) return; + for (var i = 0; i < data.length; i++) { + state.rxBuffer.push(data.charCodeAt(i) & 0xFF); + } + processResponse(); +} + +function processResponse() { + if (!state.pendingRequest) { + state.rxBuffer = []; + return; + } + if (state.rxBuffer.length < 5) return; + + var fc = state.rxBuffer[1]; + + // Exception response + if (fc & 0x80) { + if (state.rxBuffer.length >= 5) { + var excCrc = calcCRC(state.rxBuffer.slice(0, 3)); + var recvCrc = state.rxBuffer[3] | (state.rxBuffer[4] << 8); + if (excCrc === recvCrc) { + clearResponseTimeout(); + var exCode = state.rxBuffer[2]; + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + state.rxBuffer = []; + cb('Exception 0x' + toHex(exCode), null); + } + } + return; + } + + // Normal FC 0x03 response: slave(1) + FC(1) + byteCount(1) + data(N) + CRC(2) + if (fc !== FC_READ_HOLDING || state.rxBuffer.length < 3) return; + + var expectedLen = 3 + state.rxBuffer[2] + 2; + if (state.rxBuffer.length < expectedLen) return; + + var frame = state.rxBuffer.slice(0, expectedLen); + var crc = calcCRC(frame.slice(0, expectedLen - 2)); + var recvCrc = frame[expectedLen - 2] | (frame[expectedLen - 1] << 8); + + if (crc !== recvCrc) { + debug('CRC error'); + return; + } + + debug('RX: ' + bytesToHex(frame)); + clearResponseTimeout(); + + var responseData = frame.slice(2, expectedLen - 2); + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + state.rxBuffer = []; + cb(null, responseData); +} + +function clearResponseTimeout() { + if (state.responseTimer) { + Timer.clear(state.responseTimer); + state.responseTimer = null; + } +} + +// Read qty holding registers starting at addr. +// Callback receives (err, registers[]) where registers[] is an array of +// uint16 values, one per MODBUS register. +function readRegisters(addr, qty, callback) { + var data = [ + (addr >> 8) & 0xFF, + addr & 0xFF, + (qty >> 8) & 0xFF, + qty & 0xFF, + ]; + + sendRequest(data, function (err, response) { + if (err) { + callback(err, null); + return; + } + // response[0] = byteCount, response[1..byteCount] = register data (big-endian) + var byteCount = response[0]; + var regs = []; + for (var i = 1; i <= byteCount; i += 2) { + regs.push((response[i] << 8) | response[i + 1]); + } + callback(null, regs); + }); +} + +/* === PARSE CELL BLOCK (start 0x1200, qty CELL_COUNT) === */ + +// Returns { cells[], minV, maxV, deltaV, minCell, maxCell } (all in mV). +function parseCellBlock(regs) { + var cells = []; + var minV = 65535; + var maxV = 0; + var minCell = 0; + var maxCell = 0; + + for (var i = 0; i < CONFIG.CELL_COUNT; i++) { + // stride-1: each register is one cell voltage (no padding) + var mv = regs[i]; + cells.push(mv); + if (mv < minV) { minV = mv; minCell = i + 1; } + if (mv > maxV) { maxV = mv; maxCell = i + 1; } + } + + return { + cells: cells, + minV: minV, + maxV: maxV, + deltaV: maxV - minV, + minCell: minCell, + maxCell: maxCell, + }; +} + +/* === PARSE MAIN BLOCK (start 0x128A, qty 30) === */ + +// Register layout is documented in ENTITIES above. +// Block offsets used below: offset = (reg.addr - REG.MAIN_BASE). +// regs[0] -> MOSFET Temperature (0x128A, i16, *0.1 degC) +// regs[1..2] -> reserved (0x128B-C) +// regs[3..4] -> Pack Voltage (0x128D, u32 hi/lo, mV) +// regs[5..6] -> Pack Power (0x128F, i32 hi/lo, mW raw) +// regs[7..8] -> Pack Current (0x1291, i32 hi/lo, mA raw) +// regs[9] -> Temperature 1 (0x1293, i16, *0.1 degC) +// regs[10] -> Temperature 2 (0x1294, i16, *0.1 degC) +// regs[11..12]-> Alarm Bitmask (0x1295, u32 hi/lo) +// regs[13] -> Balance Current (0x1297, i16, mA) +// regs[14] -> State of Charge (0x1298, u16, %) +function parseMainBlock(regs) { + return { + mosFetTemp: toSigned16(regs[0]), // 0.1 degC + voltage: regs[3] * 65536 + regs[4], // mV (U_DWORD) + power: toSigned32(regs[5], regs[6]), // mW raw (S_DWORD, + charge / - discharge) + current: toSigned32(regs[7], regs[8]), // mA raw (S_DWORD, + charge / - discharge) + temp1: toSigned16(regs[9]), // 0.1 degC + temp2: toSigned16(regs[10]), // 0.1 degC + alarms: regs[11] * 65536 + regs[12], // bitmask + balanceCurrent: toSigned16(regs[13]), // mA + soc: regs[14], // % + }; +} + +/* === PRINT === */ + +function printAlarms(bitmask) { + if (bitmask === 0) { + print(' Alarms: none'); + return; + } + var active = 'Alarms: '; + for (var b = 0; b < ALARM_LABELS.length; b++) { + if (bitmask & (1 << b)) { + active += '[' + ALARM_LABELS[b] + '] '; + } + } + if (bitmask & 0x8000) active += '[Manual shutdown] '; + print(' ' + active); +} + +function printData(cellData, main) { + print('--- JK200 BMS ---'); + + if (cellData) { + print(' Cells (' + CONFIG.CELL_COUNT + '):'); + for (var i = 0; i < cellData.cells.length; i++) { + var tag = ''; + if (i + 1 === cellData.minCell) tag = ' (min)'; + if (i + 1 === cellData.maxCell) tag = ' (max)'; + print(' ' + (i + 1 < 10 ? ' ' : '') + (i + 1) + ': ' + fmtV(cellData.cells[i]) + tag); + } + print(' Delta: ' + fmtV(cellData.deltaV) + + ' | Min: ' + fmtV(cellData.minV) + ' (cell ' + cellData.minCell + ')' + + ' | Max: ' + fmtV(cellData.maxV) + ' (cell ' + cellData.maxCell + ')'); + } else { + print(' Cells: read error'); + } + + if (main) { + print(' Pack: ' + fmtV(main.voltage) + + ' | ' + fmtA(main.current) + + ' | ' + fmtW(main.power)); + print(' SOC: ' + main.soc + ' %'); + print(' Temp: MOS ' + fmtC(main.mosFetTemp) + + ' | T1 ' + fmtC(main.temp1) + + ' | T2 ' + fmtC(main.temp2)); + print(' Balance: ' + fmtA(main.balanceCurrent)); + printAlarms(main.alarms); + } else { + print(' Main params: read error'); + } + + print(''); +} + +/* === POLL === */ + +function pollBMS() { + var cellQty = CONFIG.CELL_COUNT; + + readRegisters(REG.CELLS_BASE, cellQty, function (err, regs) { + var cellData = null; + if (err) { + print('[JK200] Cell block error: ' + err); + } else { + if (regs.length < cellQty) { + print('[JK200] Cell block short (' + regs.length + ')'); + } else { + cellData = parseCellBlock(regs); + } + } + + Timer.set(CONFIG.INTER_READ_DELAY, false, function () { + readRegisters(REG.MAIN_BASE, REG.MAIN_QTY, function (err, regs) { + var main = null; + if (err) { + print('[JK200] Main block error: ' + err); + } else if (regs.length < REG.MAIN_QTY) { + print('[JK200] Main block short (' + regs.length + ')'); + } else { + main = parseMainBlock(regs); + } + + // Update Virtual Components (9 entities, scale applied here) + if (main) { + updateVc(ENTITIES[0], main.mosFetTemp * ENTITIES[0].scale); // degC + updateVc(ENTITIES[1], main.voltage * ENTITIES[1].scale); // mV + updateVc(ENTITIES[2], main.power * ENTITIES[2].scale); // W + updateVc(ENTITIES[3], main.current * ENTITIES[3].scale); // A + updateVc(ENTITIES[4], main.temp1 * ENTITIES[4].scale); // degC + updateVc(ENTITIES[5], main.temp2 * ENTITIES[5].scale); // degC + updateVc(ENTITIES[6], main.alarms * ENTITIES[6].scale); // bitmask + updateVc(ENTITIES[7], main.balanceCurrent * ENTITIES[7].scale); // mA + updateVc(ENTITIES[8], main.soc * ENTITIES[8].scale); // % + } + + printData(cellData, main); + }); + }); + }); +} + +/* === INIT === */ + +function init() { + print('JK200 BMS - MODBUS-RTU Reader + Virtual Components'); + print('==================================================='); + + // Initialize virtual component handles + for (var i = 0; i < ENTITIES.length; i++) { + var ent = ENTITIES[i]; + if (ent.vcId) { + ent.vcHandle = Virtual.getHandle(ent.vcId); + debug('VC handle for ' + ent.name + ' -> ' + ent.vcId); + } + } + + state.uart = UART.get(); + if (!state.uart) { + print('ERROR: UART not available'); + return; + } + + if (!state.uart.configure({ baud: CONFIG.BAUD_RATE, mode: CONFIG.MODE })) { + print('ERROR: UART configuration failed'); + return; + } + + state.uart.recv(onReceive); + state.isReady = true; + + debug('UART: ' + CONFIG.BAUD_RATE + ' baud, ' + CONFIG.MODE); + debug('Slave ID: ' + CONFIG.SLAVE_ID); + print('Cells: ' + CONFIG.CELL_COUNT + ' | Poll: ' + (CONFIG.POLL_INTERVAL / 1000) + ' s'); + print(''); + + Timer.set(500, false, pollBMS); + state.pollTimer = Timer.set(CONFIG.POLL_INTERVAL, true, pollBMS); +} + +init(); diff --git a/the_pill/MODBUS/JKESS/JK200-MBS/screenshot.png b/the_pill/MODBUS/JKESS/JK200-MBS/screenshot.png new file mode 100644 index 0000000..d274fb3 Binary files /dev/null and b/the_pill/MODBUS/JKESS/JK200-MBS/screenshot.png differ diff --git a/the_pill/MODBUS/JKESS/README.md b/the_pill/MODBUS/JKESS/README.md new file mode 100644 index 0000000..de156a5 --- /dev/null +++ b/the_pill/MODBUS/JKESS/README.md @@ -0,0 +1,21 @@ +# JKESS MODBUS Examples + +JKESS area for battery-related MODBUS examples on The Pill. + +## Problem (The Story) +Battery systems often expose internal state over RS485 but remain isolated from the rest of automation logic. This folder groups scripts that convert those battery registers into usable local telemetry/control. + +## Persona +- Off-grid or hybrid power user +- BMS-focused integrator +- Technician troubleshooting battery pack behavior + +## Available Device Folders +- [`JK200-MBS/`](JK200-MBS/): JK-PB (JK200-class) BMS examples + +## RS485 Wiring (The Pill 5-Terminal Add-on) +Use The Pill mapping from the MODBUS root README: +- `IO1 (TX)` -> `B (D-)` +- `IO2 (RX)` -> `A (D+)` +- `IO3` -> `DE/RE` +- `GND` shared diff --git a/the_pill/MODBUS/LinkedGo/R290/README.md b/the_pill/MODBUS/LinkedGo/R290/README.md new file mode 100644 index 0000000..d75df92 --- /dev/null +++ b/the_pill/MODBUS/LinkedGo/R290/README.md @@ -0,0 +1,24 @@ +# LinkedGo R290 A/W Thermal Pump MODBUS Example + +R290 thermal pump polling/control example for The Pill over RS485 MODBUS-RTU. + +## Problem (The Story) +A thermal pump has rich runtime, temperature, and fault information in its MODBUS map, but day-to-day operation lacks easy local visibility and quick control hooks. This example provides a starter script to poll critical registers and issue basic control writes. + +## Persona +- Heat-pump installer needing field diagnostics +- Integrator building automations around water temperatures and alarms +- Advanced homeowner optimizing heat-pump behavior locally + +## Files +- [`r290_aw_thermal_pump.shelly.js`](r290_aw_thermal_pump.shelly.js): FC03 polling + FC06 helper writes + +## RS485 Wiring (The Pill 5-Terminal Add-on) +| The Pill Pin | R290 Controller Side | +|---|---| +| `IO1 (TX)` -> `B (D-)` | RS485 B | +| `IO2 (RX)` -> `A (D+)` | RS485 A | +| `IO3` -> `DE/RE` | transceiver direction | +| `GND` -> `GND` | common reference | + +Protocol defaults from the provided document: slave `0x10` (16), `9600`, `8N1`. diff --git a/the_pill/MODBUS/LinkedGo/R290/r290_aw_thermal_pump.shelly.js b/the_pill/MODBUS/LinkedGo/R290/r290_aw_thermal_pump.shelly.js new file mode 100644 index 0000000..293de91 --- /dev/null +++ b/the_pill/MODBUS/LinkedGo/R290/r290_aw_thermal_pump.shelly.js @@ -0,0 +1,477 @@ +/** + * @title LinkedGo R290 A/W Thermal Pump MODBUS example + * @description MODBUS-RTU polling and basic control example for LinkedGo + * R290 air-to-water thermal pumps via RS485 on The Pill. + * @status under development + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/MODBUS/LinkedGo/r290_aw_thermal_pump.shelly.js + */ + +/** + * LinkedGo R290 A/W Thermal Pump - MODBUS RTU Example + * + * Source protocol file: + * R290 A_W modbus protocol.xlsx + * + * Transport defaults from protocol: + * - Baud rate: 9600 + * - Framing: 8N1 + * - Slave ID: 0x10 (decimal 16) + * + * The protocol document labels function usage as "03/16" for many holding + * registers (read/write). This script reads with FC03 and writes with FC06 + * (single register), which is typically accepted for single-word settings. + * + * Data type notes from protocol: + * - TEMP1 values are signed 16-bit with 0.1 degC scale + * - Value 32767 indicates sensor failure + * + * The Pill 5-Terminal Add-on wiring for RS485: + * IO1 (TX) -> RS485 B (D-) + * IO2 (RX) -> RS485 A (D+) + * IO3 -> DE/RE direction control (automatic by UART stack) + * GND -> Device GND + * + * Example API calls from this script console: + * setPower(true); // register 1011 + * setMode(1); // register 1012 (1=heating) + * setHotWaterTarget(50); // register 1157 + * setHeatingTarget(42); // register 1158 + * setCoolingTarget(10); // register 1159 + */ + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +var CONFIG = { + BAUD_RATE: 9600, + MODE: '8N1', + SLAVE_ID: 16, + RESPONSE_TIMEOUT: 1200, + POLL_INTERVAL_MS: 12000, + DEBUG: true +}; + +// ============================================================================ +// REGISTER DEFINITIONS +// ============================================================================ + +var ENTITIES = [ + // Read/write control registers + { key: 'SYSTEM_STATE', name: 'System State', units: '-', reg: { addr: 1011, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'RW' }, + { key: 'MODE', name: 'Mode', units: '-', reg: { addr: 1012, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'RW' }, + { key: 'HOT_WATER_TARGET', name: 'Hot Water Target', units: 'degC', reg: { addr: 1157, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'RW' }, + { key: 'HEATING_TARGET', name: 'Heating Target', units: 'degC', reg: { addr: 1158, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'RW' }, + { key: 'COOLING_TARGET', name: 'Cooling Target', units: 'degC', reg: { addr: 1159, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'RW' }, + + // Read-only status registers + { key: 'RUNNING_MODE', name: 'Running Mode', units: '-', reg: { addr: 2012, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'LOAD_OUTPUT', name: 'Load Output Bitmask', units: '-', reg: { addr: 2019, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'SWITCH_STATE', name: 'Switch State Bitmask', units: '-', reg: { addr: 2034, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + + { key: 'HEAT_RETURN_TEMP', name: 'Heating Return Water Temp', units: 'degC', reg: { addr: 2035, rtype: 0x03, itype: 'i16' }, scale: 0.1, rights: 'R' }, + { key: 'HEAT_OUTLET_TEMP', name: 'Heating Outlet Water Temp', units: 'degC', reg: { addr: 2036, rtype: 0x03, itype: 'i16' }, scale: 0.1, rights: 'R' }, + { key: 'INLET_WATER_TEMP', name: 'Inlet Water Temp', units: 'degC', reg: { addr: 2045, rtype: 0x03, itype: 'i16' }, scale: 0.1, rights: 'R' }, + { key: 'OUTLET_WATER_TEMP', name: 'Outlet Water Temp', units: 'degC', reg: { addr: 2046, rtype: 0x03, itype: 'i16' }, scale: 0.1, rights: 'R' }, + { key: 'DHW_TANK_TEMP', name: 'DHW Tank Water Temp', units: 'degC', reg: { addr: 2047, rtype: 0x03, itype: 'i16' }, scale: 0.1, rights: 'R' }, + { key: 'AMBIENT_TEMP', name: 'Ambient Temp', units: 'degC', reg: { addr: 2048, rtype: 0x03, itype: 'i16' }, scale: 0.1, rights: 'R' }, + { key: 'COIL_TEMP', name: 'Coil Temp', units: 'degC', reg: { addr: 2049, rtype: 0x03, itype: 'i16' }, scale: 0.1, rights: 'R' }, + { key: 'SUCTION_TEMP', name: 'Suction Temp', units: 'degC', reg: { addr: 2051, rtype: 0x03, itype: 'i16' }, scale: 0.1, rights: 'R' }, + { key: 'DISCHARGE_TEMP', name: 'Discharge Temp', units: 'degC', reg: { addr: 2053, rtype: 0x03, itype: 'i16' }, scale: 0.1, rights: 'R' }, + { key: 'ANTI_FREEZE_TEMP', name: 'Anti-Freeze Temp', units: 'degC', reg: { addr: 2055, rtype: 0x03, itype: 'i16' }, scale: 0.1, rights: 'R' }, + { key: 'ROOM_TEMP', name: 'Room Temp', units: 'degC', reg: { addr: 2058, rtype: 0x03, itype: 'i16' }, scale: 0.1, rights: 'R' }, + + { key: 'COMPRESSOR_FREQ_SET', name: 'Compressor Frequency Set', units: 'Hz', reg: { addr: 2071, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'COMPRESSOR_FREQ_RUN', name: 'Compressor Frequency Running', units: 'Hz', reg: { addr: 2072, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'DC_FAN1_SPEED', name: 'DC Fan 1 Speed', units: 'rpm', reg: { addr: 2074, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'DC_FAN2_SPEED', name: 'DC Fan 2 Speed', units: 'rpm', reg: { addr: 2075, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'WATER_FLOW', name: 'Water Flow', units: 'raw', reg: { addr: 2077, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + + { key: 'FAILURE_1', name: 'Failure 1 Bitmask', units: '-', reg: { addr: 2085, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'FAILURE_2', name: 'Failure 2 Bitmask', units: '-', reg: { addr: 2086, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'FAILURE_3', name: 'Failure 3 Bitmask', units: '-', reg: { addr: 2087, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'FAILURE_4', name: 'Failure 4 Bitmask', units: '-', reg: { addr: 2088, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'FAILURE_5', name: 'Failure 5 Bitmask', units: '-', reg: { addr: 2089, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'FAILURE_6', name: 'Failure 6 Bitmask', units: '-', reg: { addr: 2090, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'FAILURE_7', name: 'Failure 7 Bitmask', units: '-', reg: { addr: 2081, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'FAILURE_8', name: 'Failure 8 Bitmask', units: '-', reg: { addr: 2082, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' }, + { key: 'FAILURE_9', name: 'Failure 9 Bitmask', units: '-', reg: { addr: 2083, rtype: 0x03, itype: 'u16' }, scale: 1, rights: 'R' } +]; + +var REG = {}; +var i; +for (i = 0; i < ENTITIES.length; i++) { + REG[ENTITIES[i].key] = ENTITIES[i].reg.addr; +} + +// ============================================================================ +// MODBUS CORE +// ============================================================================ + +var FC = { + READ_HOLDING_REGISTERS: 0x03, + WRITE_SINGLE_REGISTER: 0x06 +}; + +var state = { + uart: null, + rxBuffer: [], + pendingRequest: null, + responseTimer: null, + pollTimer: null, + isReady: false +}; + +function debug(msg) { + if (CONFIG.DEBUG) print('[R290] ' + msg); +} + +function toHex(n) { + n = n & 0xFF; + return (n < 16 ? '0' : '') + n.toString(16).toUpperCase(); +} + +function bytesToHex(bytes) { + var s = ''; + for (var j = 0; j < bytes.length; j++) { + s += toHex(bytes[j]); + if (j < bytes.length - 1) s += ' '; + } + return s; +} + +function bytesToStr(bytes) { + var s = ''; + for (var j = 0; j < bytes.length; j++) { + s += String.fromCharCode(bytes[j] & 0xFF); + } + return s; +} + +function calcCRC(bytes) { + var crc = 0xFFFF; + var j; + for (j = 0; j < bytes.length; j++) { + crc = crc ^ bytes[j]; + var k; + for (k = 0; k < 8; k++) { + if (crc & 0x0001) { + crc = (crc >> 1) ^ 0xA001; + } else { + crc = crc >> 1; + } + } + } + return crc; +} + +function buildFrame(slaveAddr, functionCode, data) { + var frame = [slaveAddr & 0xFF, functionCode & 0xFF]; + var j; + for (j = 0; j < data.length; j++) frame.push(data[j] & 0xFF); + var crc = calcCRC(frame); + frame.push(crc & 0xFF); + frame.push((crc >> 8) & 0xFF); + return frame; +} + +function initUart() { + state.uart = UART.get(); + if (!state.uart) { + print('[R290] ERROR: UART not available'); + return false; + } + + if (!state.uart.configure({ baud: CONFIG.BAUD_RATE, mode: CONFIG.MODE })) { + print('[R290] ERROR: UART configuration failed'); + return false; + } + + state.uart.recv(onReceive); + state.isReady = true; + debug('UART ready @ ' + CONFIG.BAUD_RATE + ' ' + CONFIG.MODE + ', slave=' + CONFIG.SLAVE_ID); + return true; +} + +function sendRequest(functionCode, data, callback) { + if (!state.isReady) { + callback('Not initialized', null); + return; + } + if (state.pendingRequest) { + callback('Request pending', null); + return; + } + + var frame = buildFrame(CONFIG.SLAVE_ID, functionCode, data); + debug('TX: ' + bytesToHex(frame)); + + state.pendingRequest = { + functionCode: functionCode, + callback: callback + }; + state.rxBuffer = []; + + state.responseTimer = Timer.set(CONFIG.RESPONSE_TIMEOUT, false, function() { + if (!state.pendingRequest) return; + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + cb('Timeout', null); + }, null); + + state.uart.write(bytesToStr(frame)); +} + +function onReceive(data) { + if (!data || data.length === 0) return; + + var j; + for (j = 0; j < data.length; j++) { + state.rxBuffer.push(data.charCodeAt(j) & 0xFF); + } + processResponse(); +} + +function processResponse() { + if (!state.pendingRequest) { + state.rxBuffer = []; + return; + } + + if (state.rxBuffer.length < 5) return; + + var response = state.rxBuffer; + var functionCode = response[1]; + + if (functionCode & 0x80) { + if (Timer.clear) Timer.clear(state.responseTimer); + var exc = response.length > 2 ? response[2] : 0; + var cbe = state.pendingRequest.callback; + state.pendingRequest = null; + state.rxBuffer = []; + cbe('Modbus exception 0x' + toHex(exc), null); + return; + } + + var expectedLength; + if (state.pendingRequest.functionCode === FC.READ_HOLDING_REGISTERS) { + expectedLength = 5 + response[2]; + } else { + expectedLength = 8; + } + + if (response.length < expectedLength) return; + + var crcCalculated = calcCRC(response.slice(0, expectedLength - 2)); + var crcReceived = response[expectedLength - 2] | (response[expectedLength - 1] << 8); + if (crcCalculated !== crcReceived) { + if (Timer.clear) Timer.clear(state.responseTimer); + var cbc = state.pendingRequest.callback; + state.pendingRequest = null; + state.rxBuffer = []; + cbc('CRC mismatch', null); + return; + } + + if (Timer.clear) Timer.clear(state.responseTimer); + + var request = state.pendingRequest; + state.pendingRequest = null; + state.rxBuffer = []; + + debug('RX: ' + bytesToHex(response.slice(0, expectedLength))); + request.callback(null, response.slice(0, expectedLength)); +} + +function readHolding(addr, quantity, callback) { + var payload = [ + (addr >> 8) & 0xFF, + addr & 0xFF, + (quantity >> 8) & 0xFF, + quantity & 0xFF + ]; + + sendRequest(FC.READ_HOLDING_REGISTERS, payload, function(err, frame) { + if (err) { + callback(err, null); + return; + } + + var values = []; + var byteCount = frame[2]; + var j; + for (j = 0; j < byteCount; j += 2) { + values.push((frame[3 + j] << 8) | frame[3 + j + 1]); + } + callback(null, values); + }); +} + +function writeSingleRegister(addr, value, callback) { + var payload = [ + (addr >> 8) & 0xFF, + addr & 0xFF, + (value >> 8) & 0xFF, + value & 0xFF + ]; + + sendRequest(FC.WRITE_SINGLE_REGISTER, payload, function(err) { + callback(err); + }); +} + +// ============================================================================ +// DATA PARSING +// ============================================================================ + +function decodeI16(raw) { + if (raw > 0x7FFF) return raw - 0x10000; + return raw; +} + +function decodeByEntity(entity, raw) { + if (entity.reg.itype === 'i16') { + if (raw === 32767) return null; + return decodeI16(raw) * entity.scale; + } + return raw * entity.scale; +} + +function findEntityByKey(key) { + for (var j = 0; j < ENTITIES.length; j++) { + if (ENTITIES[j].key === key) return ENTITIES[j]; + } + return null; +} + +function readEntity(entity, callback) { + readHolding(entity.reg.addr, 1, function(err, values) { + if (err) { + callback(err, null); + return; + } + callback(null, decodeByEntity(entity, values[0])); + }); +} + +// ============================================================================ +// PUBLIC CONTROL HELPERS +// ============================================================================ + +function setPower(isOn) { + writeSingleRegister(REG.SYSTEM_STATE, isOn ? 1 : 0, function(err) { + if (err) print('[R290] setPower failed: ' + err); + else print('[R290] setPower OK -> ' + (isOn ? 'ON' : 'OFF')); + }); +} + +function setMode(modeValue) { + writeSingleRegister(REG.MODE, modeValue, function(err) { + if (err) print('[R290] setMode failed: ' + err); + else print('[R290] setMode OK -> ' + modeValue); + }); +} + +function setHotWaterTarget(tempDegC) { + writeSingleRegister(REG.HOT_WATER_TARGET, tempDegC, function(err) { + if (err) print('[R290] setHotWaterTarget failed: ' + err); + else print('[R290] setHotWaterTarget OK -> ' + tempDegC + ' degC'); + }); +} + +function setHeatingTarget(tempDegC) { + writeSingleRegister(REG.HEATING_TARGET, tempDegC, function(err) { + if (err) print('[R290] setHeatingTarget failed: ' + err); + else print('[R290] setHeatingTarget OK -> ' + tempDegC + ' degC'); + }); +} + +function setCoolingTarget(tempDegC) { + writeSingleRegister(REG.COOLING_TARGET, tempDegC, function(err) { + if (err) print('[R290] setCoolingTarget failed: ' + err); + else print('[R290] setCoolingTarget OK -> ' + tempDegC + ' degC'); + }); +} + +// ============================================================================ +// POLLING +// ============================================================================ + +var POLL_KEYS = [ + 'SYSTEM_STATE', + 'RUNNING_MODE', + 'HEAT_RETURN_TEMP', + 'HEAT_OUTLET_TEMP', + 'INLET_WATER_TEMP', + 'OUTLET_WATER_TEMP', + 'DHW_TANK_TEMP', + 'AMBIENT_TEMP', + 'COIL_TEMP', + 'SUCTION_TEMP', + 'DISCHARGE_TEMP', + 'ANTI_FREEZE_TEMP', + 'ROOM_TEMP', + 'COMPRESSOR_FREQ_RUN', + 'DC_FAN1_SPEED', + 'DC_FAN2_SPEED', + 'WATER_FLOW', + 'FAILURE_1', + 'FAILURE_2', + 'FAILURE_3', + 'FAILURE_4', + 'FAILURE_5', + 'FAILURE_6', + 'FAILURE_7', + 'FAILURE_8', + 'FAILURE_9' +]; + +function pollOnce() { + var idx = 0; + + function next() { + if (idx >= POLL_KEYS.length) return; + + var entity = findEntityByKey(POLL_KEYS[idx]); + idx += 1; + + if (!entity) { + next(); + return; + } + + readEntity(entity, function(err, value) { + if (err) { + print('[R290] ' + entity.name + ': ERROR ' + err); + } else if (value === null) { + print('[R290] ' + entity.name + ': SENSOR_ERROR'); + } else { + print('[R290] ' + entity.name + ': ' + value + ' ' + entity.units); + } + + Timer.set(80, false, next, null); + }); + } + + next(); +} + +function startPolling() { + pollOnce(); + + state.pollTimer = Timer.set(CONFIG.POLL_INTERVAL_MS, true, function() { + pollOnce(); + }, null); +} + +// ============================================================================ +// INITIALIZATION +// ============================================================================ + +function main() { + print('[R290] Starting LinkedGo R290 thermal pump MODBUS example'); + if (!initUart()) return; + startPolling(); +} + +main(); diff --git a/the_pill/MODBUS/LinkedGo/README.md b/the_pill/MODBUS/LinkedGo/README.md new file mode 100644 index 0000000..e246f32 --- /dev/null +++ b/the_pill/MODBUS/LinkedGo/README.md @@ -0,0 +1,21 @@ +# LinkedGo MODBUS Examples + +LinkedGo HVAC/thermal examples for RS485 MODBUS-RTU on The Pill. + +## Problem (The Story) +LinkedGo controllers expose important operating data and controls, but installers often need a direct local path for commissioning and automation. These examples provide practical register-level integrations for thermostat and thermal pump devices. + +## Persona +- HVAC installer commissioning LinkedGo equipment +- Building integrator connecting heat systems to local logic +- Advanced user replacing opaque controller apps with transparent scripts + +## Device Folders +- [`ST802/`](ST802/): ST802 thermostat BMS-client examples +- [`R290/`](R290/): R290 air-to-water thermal pump example + +## RS485 Wiring (The Pill 5-Terminal Add-on) +- `IO1 (TX)` -> `B (D-)` +- `IO2 (RX)` -> `A (D+)` +- `IO3` -> `DE/RE` for half-duplex direction +- `GND` shared diff --git a/the_pill/MODBUS/LinkedGo/ST802/README.md b/the_pill/MODBUS/LinkedGo/ST802/README.md new file mode 100644 index 0000000..365e294 --- /dev/null +++ b/the_pill/MODBUS/LinkedGo/ST802/README.md @@ -0,0 +1,29 @@ +# LinkedGo ST802 MODBUS Examples + +ST802 thermostat scripts acting as a MODBUS BMS-side client from The Pill. + +## Problem (The Story) +A central automation controller needs to enforce operating mode, setpoint, and fan behavior on ST802 thermostats without manual interaction. These scripts simulate BMS commands and read back key status values. + +## Persona +- HVAC controls integrator for fan-coil/floor systems +- Installer standardizing room-control behavior across zones +- Commissioning engineer validating mode and relay operation + +## Files +- [`st802_bms.shelly.js`](st802_bms.shelly.js): command + polling script +- [`st802_bms_vc.shelly.js`](st802_bms_vc.shelly.js): same with Virtual Components + +## Screenshot +![ST802 VC Screenshot](screenshot.png) +This screen shows the ST802 Virtual Components with room/floor temperature, humidity, relay/alarm status, mode, fan speed, setpoint, and power state. + +## RS485 Wiring (The Pill 5-Terminal Add-on) +| The Pill Pin | ST802 Side | +|---|---| +| `IO1 (TX)` -> `B2 (D-)` | RS485-2 B | +| `IO2 (RX)` -> `A2 (D+)` | RS485-2 A | +| `IO3` -> `DE/RE` | transceiver direction | +| `GND` -> `GND` | common reference | + +Common defaults: `9600`, `8N1`, slave `1`. diff --git a/the_pill/MODBUS/LinkedGo/ST802/screenshot.png b/the_pill/MODBUS/LinkedGo/ST802/screenshot.png new file mode 100644 index 0000000..9d4a818 Binary files /dev/null and b/the_pill/MODBUS/LinkedGo/ST802/screenshot.png differ diff --git a/the_pill/MODBUS/LinkedGo/ST802/st802_bms.shelly.js b/the_pill/MODBUS/LinkedGo/ST802/st802_bms.shelly.js new file mode 100644 index 0000000..a26ab61 --- /dev/null +++ b/the_pill/MODBUS/LinkedGo/ST802/st802_bms.shelly.js @@ -0,0 +1,1014 @@ +/** + * @title LinkedGo ST802 Thermostat - BMS Modbus RTU Client + * @description Modbus RTU master that simulates BMS (Building Management System) + * commands for the LinkedGo ST802 Youth Smart Thermostat over RS485. + * @status production + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/MODBUS/LinkedGo/ST802/st802_bms.shelly.js + */ + +/** + * LinkedGo ST802 Youth Smart Thermostat - BMS Client + * + * Communicates via RS485-2 (terminals A2/B2) which defaults to slave mode. + * + * Thermostat RS485-2 factory defaults: + * P05 = 0 (Slave) + * P06 = 3 (9600 baud) + * P07 = 1 (LinkedGo protocol 3.0) + * P08 = 1 (Slave ID 1) + * + * Register map (LinkedGo 3.0, all addresses in hex): + * + * FC 03/06 - Read/Write holding registers: + * 0x1001 (H00) Power 0=OFF 1=ON + * 0x1003 (H02) System type 0=2pipe-AC 1=DC-fan 2=floor-only 3=AC+floor 17=4pipe-AC + * 0x1004 (H03) Operating mode 0=Cooling 3=Dry 4=Heating 5=Floor 7=Ventilation + * 0x1006 (H05) Heat/cool sel 0=Both 1=CoolOnly 2=HeatOnly + * 0x1007 (H06) Fan speed 0=Auto 1=Low 2=Medium 3=High 4=Speed4 5=Speed5 + * 0x1008 (H07) Setpoint temp raw * 0.1 = degC (step 0.5degC, range H23-H24) + * 0x1009 (H08) Humidity SP raw * 0.1 = % (range 40-75%) + * 0x1018 (H23) Min setpoint raw * 0.1 = degC (default 50 = 5degC) + * 0x1019 (H24) Max setpoint raw * 0.1 = degC (default 500 = 50degC -> clamp to 35degC) + * + * FC 03 - Read only: + * 0x2101 (O00) Room temp raw * 0.1 = degC + * 0x2102 (O01) Humidity raw * 0.1 = % + * 0x2103 (O02) Floor temp raw * 0.1 = degC + * 0x2110 (O14) Relay status bitmask (see RELAYS below) + * 0x211A Alarm bit0 = room sensor failure + * + * The Pill 5-Terminal Add-on wiring: + * IO1 (TX) ─── B (D-) ──> Thermostat B2 (D-) + * IO2 (RX) ─── A (D+) ──> Thermostat A2 (D+) + * IO3 ─── DE/RE ── direction control (automatic) + * GND ─── GND ──> Thermostat GND + */ + +/* === CONFIG === */ +var CONFIG = { + BAUD_RATE: 9600, + MODE: "8N1", + + SLAVE_ID: 1, + RESPONSE_TIMEOUT: 1000, // ms + + POLL_INTERVAL: 30000, // Status read period (ms) + CMD_INTERVAL: 60000, // BMS command cycle period (ms) + + DEBUG: true +}; + +/* === ENABLE FLAGS === + * Set any flag to false to disable that poll action or command scenario. + * All other logic stays intact -- flip back to true to re-enable. + */ +var ENABLE = { + // Poll actions (pollStatus) + POLL_TEMPERATURES: true, + POLL_RELAYS: true, + POLL_ALARM: true, + POLL_MODE: true, + POLL_FAN_SPEED: true, + POLL_HUMIDITY: true, + + // BMS command scenarios (CMD_SCENARIOS keys) + CMD_MORNING_HEAT: false, + CMD_COOLING: false, + CMD_ECONOMY_HEAT: false, + CMD_VENTILATION: false, + CMD_DRY: false, + CMD_FLOOR_HEAT: false, + CMD_NIGHT_SETBACK: false, + CMD_STANDBY: false +}; + +/* === ST802 REGISTER MAP === */ +var ENTITIES = [ + // + // --- Control registers (Read / Write, FC 03 / 06) --- + // + { key: "POWER", name: "Power", units: "-", reg: { addr: 0x1001, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { key: "SYS_TYPE", name: "System Type", units: "-", reg: { addr: 0x1003, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { key: "MODE", name: "Operating Mode", units: "-", reg: { addr: 0x1004, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { key: "HC_SELECT", name: "Heat/Cool Select", units: "-", reg: { addr: 0x1006, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { key: "FAN_SPEED", name: "Fan Speed", units: "-", reg: { addr: 0x1007, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { key: "SETPOINT", name: "Setpoint Temp", units: "degC", reg: { addr: 0x1008, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { key: "HUMIDITY_SP", name: "Humidity Setpoint", units: "%", reg: { addr: 0x1009, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { key: "MIN_SP", name: "Min Setpoint", units: "degC", reg: { addr: 0x1018, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { key: "MAX_SP", name: "Max Setpoint", units: "degC", reg: { addr: 0x1019, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + // + // --- Sensor registers (Read only, FC 03) --- + // + { key: "ROOM_TEMP", name: "Room Temperature", units: "degC", reg: { addr: 0x2101, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { key: "HUMIDITY", name: "Humidity", units: "%", reg: { addr: 0x2102, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { key: "FLOOR_TEMP", name: "Floor Temperature", units: "degC", reg: { addr: 0x2103, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { key: "RELAY_STATE", name: "Relay Status", units: "-", reg: { addr: 0x2110, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, + { key: "ALARM", name: "Alarm", units: "-", reg: { addr: 0x211A, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: null, handle: null, vcHandle: null }, +]; + +/* Build REG address map from ENTITIES so all API functions work unchanged */ +var REG = {}; +for (var _ei = 0; _ei < ENTITIES.length; _ei++) { + REG[ENTITIES[_ei].key] = ENTITIES[_ei].reg.addr; +} + +/* === ENUMERATION VALUES === */ +var POWER = { OFF: 0, ON: 1 }; + +var MODE = { + COOLING: 0, + DRY: 3, + HEATING: 4, + FLOOR_HEATING: 5, + VENTILATION: 7 +}; + +var FAN = { + AUTO: 0, + LOW: 1, + MEDIUM: 2, + HIGH: 3, + SPD4: 4, + SPD5: 5 +}; + +var HC = { BOTH: 0, COOL_ONLY: 1, HEAT_ONLY: 2 }; + +/* Relay bitmask positions (O14 / 0x2110) */ +var RELAYS = { + HIGH_SPEED: 0, // bit0 + MEDIUM_SPEED: 1, // bit1 + LOW_SPEED: 2, // bit2 + FAN_COIL_VALVE: 3, // bit3 + FLOOR_VALVE: 4, // bit4 + DRY_CONTACT: 5 // bit5 +}; + +/* === MODBUS FUNCTION CODES === */ +var FC = { + READ_HOLDING_REGISTERS: 0x03, + WRITE_SINGLE_REGISTER: 0x06 +}; + +/* === CRC-16 TABLE (MODBUS polynomial 0xA001) === */ +var CRC_TABLE = [ + 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, + 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440, + 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40, + 0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841, + 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40, + 0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41, + 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641, + 0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040, + 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240, + 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441, + 0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41, + 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840, + 0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41, + 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40, + 0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640, + 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041, + 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240, + 0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441, + 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41, + 0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840, + 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41, + 0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40, + 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640, + 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041, + 0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241, + 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440, + 0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40, + 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841, + 0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40, + 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41, + 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641, + 0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040 +]; + +/* === STATE === */ +var state = { + uart: null, + rxBuffer: [], + isReady: false, + pendingRequest: null, + responseTimer: null, + pollTimer: null, + cmdTimer: null, + cmdStep: 0 +}; + +/* === HELPERS === */ + +function toHex(n) { + n = n & 0xFF; + return (n < 16 ? "0" : "") + n.toString(16).toUpperCase(); +} + +function toHex16(n) { + return toHex((n >> 8) & 0xFF) + toHex(n & 0xFF); +} + +function bytesToHex(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) { + s += toHex(bytes[i]); + if (i < bytes.length - 1) s += " "; + } + return s; +} + +function debug(msg) { + if (CONFIG.DEBUG) { + print("[ST802] " + msg); + } +} + +function calcCRC(bytes) { + var crc = 0xFFFF; + for (var i = 0; i < bytes.length; i++) { + var idx = (crc ^ bytes[i]) & 0xFF; + crc = (crc >> 8) ^ CRC_TABLE[idx]; + } + return crc; +} + +function bytesToStr(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) { + s += String.fromCharCode(bytes[i] & 0xFF); + } + return s; +} + +function buildFrame(slaveAddr, functionCode, data) { + var frame = [slaveAddr & 0xFF, functionCode & 0xFF]; + for (var i = 0; i < data.length; i++) { + frame.push(data[i] & 0xFF); + } + var crc = calcCRC(frame); + frame.push(crc & 0xFF); + frame.push((crc >> 8) & 0xFF); + return frame; +} + +/* === MODBUS CORE === */ + +function sendRequest(functionCode, data, callback) { + if (!state.isReady) { + callback("Not initialized", null); + return; + } + if (state.pendingRequest) { + callback("Request pending", null); + return; + } + + var frame = buildFrame(CONFIG.SLAVE_ID, functionCode, data); + debug("TX: " + bytesToHex(frame)); + + state.pendingRequest = { functionCode: functionCode, callback: callback }; + state.rxBuffer = []; + + state.responseTimer = Timer.set(CONFIG.RESPONSE_TIMEOUT, false, function() { + if (state.pendingRequest) { + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + debug("Timeout"); + cb("Timeout", null); + } + }); + + state.uart.write(bytesToStr(frame)); +} + +function onReceive(data) { + if (!data || data.length === 0) return; + for (var i = 0; i < data.length; i++) { + state.rxBuffer.push(data.charCodeAt(i) & 0xFF); + } + processResponse(); +} + +function processResponse() { + if (!state.pendingRequest) { + state.rxBuffer = []; + return; + } + if (state.rxBuffer.length < 5) return; + + var fc = state.rxBuffer[1]; + + // Exception response: FC with high bit set + if (fc & 0x80) { + if (state.rxBuffer.length >= 5) { + var excFrame = state.rxBuffer.slice(0, 5); + var excCrc = calcCRC(excFrame.slice(0, 3)); + var excRecv = excFrame[3] | (excFrame[4] << 8); + if (excCrc === excRecv) { + clearResponseTimer(); + var excCode = state.rxBuffer[2]; + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + state.rxBuffer = []; + cb("Modbus exception 0x" + toHex(excCode), null); + } + } + return; + } + + var expectedLen = getExpectedLength(fc); + if (expectedLen === 0 || state.rxBuffer.length < expectedLen) return; + + var frame = state.rxBuffer.slice(0, expectedLen); + var crc = calcCRC(frame.slice(0, expectedLen - 2)); + var recvCrc = frame[expectedLen - 2] | (frame[expectedLen - 1] << 8); + + if (crc !== recvCrc) { + debug("CRC error"); + return; + } + + debug("RX: " + bytesToHex(frame)); + clearResponseTimer(); + + var responseData = frame.slice(2, expectedLen - 2); + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + state.rxBuffer = []; + cb(null, responseData); +} + +function getExpectedLength(fc) { + switch (fc) { + case FC.READ_HOLDING_REGISTERS: + if (state.rxBuffer.length >= 3) { + return 3 + state.rxBuffer[2] + 2; + } + return 0; + case FC.WRITE_SINGLE_REGISTER: + return 8; // echo: slave(1)+FC(1)+addr(2)+value(2)+CRC(2) + default: + return 0; + } +} + +function clearResponseTimer() { + if (state.responseTimer) { + Timer.clear(state.responseTimer); + state.responseTimer = null; + } +} + +/* === REGISTER DECODE HELPERS === */ + +/** + * Decode raw register value to degrees Celsius (factor 0.1) + * @param {number} raw + * @returns {number} + */ +function rawToTemp(raw) { + return raw * 0.1; +} + +/** + * Encode temperature in degC to raw register value (rounded to 0.5degC step) + * @param {number} degC + * @returns {number} + */ +function tempToRaw(degC) { + // Round to nearest 0.5degC then multiply by 10 + return Math.round(degC * 2) * 5; +} + +/** + * Decode raw humidity register value to percent (factor 0.1) + * @param {number} raw + * @returns {number} + */ +function rawToHumidity(raw) { + return raw * 0.1; +} + +/** + * Decode relay status bitmask into a readable object + * @param {number} mask + * @returns {object} + */ +function decodeRelayStatus(mask) { + return { + highSpeed: !!(mask & (1 << RELAYS.HIGH_SPEED)), + mediumSpeed: !!(mask & (1 << RELAYS.MEDIUM_SPEED)), + lowSpeed: !!(mask & (1 << RELAYS.LOW_SPEED)), + fanCoilValve: !!(mask & (1 << RELAYS.FAN_COIL_VALVE)), + floorValve: !!(mask & (1 << RELAYS.FLOOR_VALVE)), + dryContact: !!(mask & (1 << RELAYS.DRY_CONTACT)) + }; +} + +/** + * Decode operating mode value to label string + * @param {number} v + * @returns {string} + */ +function modeLabel(v) { + switch (v) { + case MODE.COOLING: return "Cooling"; + case MODE.DRY: return "Dry"; + case MODE.HEATING: return "Heating"; + case MODE.FLOOR_HEATING: return "FloorHeating"; + case MODE.VENTILATION: return "Ventilation"; + default: return "Unknown(" + v + ")"; + } +} + +/** + * Decode fan speed value to label string + * @param {number} v + * @returns {string} + */ +function fanLabel(v) { + switch (v) { + case FAN.AUTO: return "Auto"; + case FAN.LOW: return "Low"; + case FAN.MEDIUM: return "Medium"; + case FAN.HIGH: return "High"; + case FAN.SPD4: return "Speed4"; + case FAN.SPD5: return "Speed5"; + default: return "Unknown(" + v + ")"; + } +} + +/* === ST802 CONTROL API === */ + +/** + * Set thermostat power on or off. + * @param {number} onOff POWER.ON or POWER.OFF + * @param {function} callback callback(error, success) + */ +function setPower(onOff, callback) { + var data = [ + (REG.POWER >> 8) & 0xFF, REG.POWER & 0xFF, + (onOff >> 8) & 0xFF, onOff & 0xFF + ]; + sendRequest(FC.WRITE_SINGLE_REGISTER, data, function(err, resp) { + if (err) { + debug("setPower error: " + err); + if (callback) callback(err, false); + return; + } + debug("Power set to " + (onOff ? "ON" : "OFF")); + if (callback) callback(null, true); + }); +} + +/** + * Set operating mode. + * @param {number} mode MODE.HEATING / COOLING / DRY / FLOOR_HEATING / VENTILATION + * @param {function} callback callback(error, success) + */ +function setMode(mode, callback) { + var data = [ + (REG.MODE >> 8) & 0xFF, REG.MODE & 0xFF, + (mode >> 8) & 0xFF, mode & 0xFF + ]; + sendRequest(FC.WRITE_SINGLE_REGISTER, data, function(err, resp) { + if (err) { + debug("setMode error: " + err); + if (callback) callback(err, false); + return; + } + debug("Mode set to " + modeLabel(mode)); + if (callback) callback(null, true); + }); +} + +/** + * Set fan speed. + * @param {number} speed FAN.AUTO / LOW / MEDIUM / HIGH / SPD4 / SPD5 + * @param {function} callback callback(error, success) + */ +function setFanSpeed(speed, callback) { + var data = [ + (REG.FAN_SPEED >> 8) & 0xFF, REG.FAN_SPEED & 0xFF, + (speed >> 8) & 0xFF, speed & 0xFF + ]; + sendRequest(FC.WRITE_SINGLE_REGISTER, data, function(err, resp) { + if (err) { + debug("setFanSpeed error: " + err); + if (callback) callback(err, false); + return; + } + debug("Fan speed set to " + fanLabel(speed)); + if (callback) callback(null, true); + }); +} + +/** + * Set temperature setpoint. + * Resolution: 0.5degC steps. Range: 5-35degC (device default). + * @param {number} degC Target temperature in degC (e.g. 22.0 or 22.5) + * @param {function} callback callback(error, success) + */ +function setSetpoint(degC, callback) { + var raw = tempToRaw(degC); + var data = [ + (REG.SETPOINT >> 8) & 0xFF, REG.SETPOINT & 0xFF, + (raw >> 8) & 0xFF, raw & 0xFF + ]; + sendRequest(FC.WRITE_SINGLE_REGISTER, data, function(err, resp) { + if (err) { + debug("setSetpoint error: " + err); + if (callback) callback(err, false); + return; + } + debug("Setpoint set to " + degC + "degC (raw " + raw + ")"); + if (callback) callback(null, true); + }); +} + +/** + * Set humidity setpoint. + * Range: 40-75%. + * @param {number} pct Target humidity in % (integer) + * @param {function} callback callback(error, success) + */ +function setHumiditySetpoint(pct, callback) { + var raw = pct * 10; + if (raw < 400) raw = 400; + if (raw > 750) raw = 750; + var data = [ + (REG.HUMIDITY_SP >> 8) & 0xFF, REG.HUMIDITY_SP & 0xFF, + (raw >> 8) & 0xFF, raw & 0xFF + ]; + sendRequest(FC.WRITE_SINGLE_REGISTER, data, function(err, resp) { + if (err) { + debug("setHumiditySetpoint error: " + err); + if (callback) callback(err, false); + return; + } + debug("Humidity setpoint set to " + pct + "% (raw " + raw + ")"); + if (callback) callback(null, true); + }); +} + +/* === ST802 STATUS API === */ + +/** + * Read current room temperature, humidity, and floor temperature. + * Reads three consecutive registers starting at O00 (0x2101). + * @param {function} callback callback(error, {roomTemp, humidity, floorTemp}) + */ +function readTemperatures(callback) { + var startAddr = REG.ROOM_TEMP; + var qty = 3; // O00, O01, O02 + var data = [ + (startAddr >> 8) & 0xFF, startAddr & 0xFF, + (qty >> 8) & 0xFF, qty & 0xFF + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + // resp[0] = byteCount (6), then pairs of bytes per register + var roomTemp = rawToTemp((resp[1] << 8) | resp[2]); + var humidity = rawToHumidity((resp[3] << 8) | resp[4]); + var floorTemp = rawToTemp((resp[5] << 8) | resp[6]); + callback(null, { + roomTemp: roomTemp, + humidity: humidity, + floorTemp: floorTemp + }); + }); +} + +/** + * Read relay output status bitmask (O14 / 0x2110). + * @param {function} callback callback(error, relayStatus) + */ +function readRelayStatus(callback) { + var data = [ + (REG.RELAY_STATE >> 8) & 0xFF, REG.RELAY_STATE & 0xFF, + 0x00, 0x01 + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + var mask = (resp[1] << 8) | resp[2]; + callback(null, decodeRelayStatus(mask)); + }); +} + +/** + * Read alarm register (0x211A). + * @param {function} callback callback(error, {roomSensorFail}) + */ +function readAlarm(callback) { + var data = [ + (REG.ALARM >> 8) & 0xFF, REG.ALARM & 0xFF, + 0x00, 0x01 + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + var mask = (resp[1] << 8) | resp[2]; + callback(null, { roomSensorFail: !!(mask & 0x01) }); + }); +} + +/** + * Read current operating mode (H03 / 0x1004). + * @param {function} callback callback(error, modeValue) + */ +function readMode(callback) { + var data = [ + (REG.MODE >> 8) & 0xFF, REG.MODE & 0xFF, + 0x00, 0x01 + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + callback(null, (resp[1] << 8) | resp[2]); + }); +} + +/** + * Read current fan speed (H06 / 0x1007). + * @param {function} callback callback(error, fanSpeedValue) + */ +function readFanSpeed(callback) { + var data = [ + (REG.FAN_SPEED >> 8) & 0xFF, REG.FAN_SPEED & 0xFF, + 0x00, 0x01 + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + callback(null, (resp[1] << 8) | resp[2]); + }); +} + +/** + * Read current humidity sensor value (O01 / 0x2102). + * @param {function} callback callback(error, humidityPercent) + */ +function readHumidity(callback) { + var data = [ + (REG.HUMIDITY >> 8) & 0xFF, REG.HUMIDITY & 0xFF, + 0x00, 0x01 + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + callback(null, rawToHumidity((resp[1] << 8) | resp[2])); + }); +} + +/** + * Read current power state and operating mode (H00 and H03). + * @param {function} callback callback(error, {power, mode, fanSpeed, setpoint}) + */ +function readControlRegisters(callback) { + // Read H00, skip H01, H02, H03 in one block: addr 0x1001, qty 4 + var startAddr = REG.POWER; // 0x1001 + var qty = 4; // H00(1001), gap(1002), H02(1003), H03(1004) + var data = [ + (startAddr >> 8) & 0xFF, startAddr & 0xFF, + (qty >> 8) & 0xFF, qty & 0xFF + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + // resp[0]=byteCount(8), then 4 registers as big-endian words + var power = (resp[1] << 8) | resp[2]; // H00 at 0x1001 + // resp[3..4] = register 0x1002 (unused gap) + var sysType = (resp[5] << 8) | resp[6]; // H02 at 0x1003 + var mode = (resp[7] << 8) | resp[8]; // H03 at 0x1004 + callback(null, { + power: power, + sysType: sysType, + mode: mode + }); + }); +} + +/* === BMS POLL CYCLE === */ + +/** + * Full status poll: reads temperatures, relay status, and alarm. + * Each step is guarded by its ENABLE flag and chained sequentially + * to avoid bus collisions. + */ +function pollStatus() { + debug("--- Polling ST802 status ---"); + + function doRelays() { + if (!ENABLE.POLL_RELAYS) { doAlarm(); return; } + Timer.set(200, false, function() { + readRelayStatus(function(err, relays) { + if (err) { + debug("Relay read error: " + err); + } else { + print("[ST802] Relays: " + + "Hi=" + (relays.highSpeed ? "1" : "0") + + " Med=" + (relays.mediumSpeed ? "1" : "0") + + " Lo=" + (relays.lowSpeed ? "1" : "0") + + " FanValve=" + (relays.fanCoilValve ? "1" : "0") + + " FloorValve=" + (relays.floorValve ? "1" : "0") + + " DryContact=" + (relays.dryContact ? "1" : "0")); + } + doAlarm(); + }); + }); + } + + function doAlarm() { + if (!ENABLE.POLL_ALARM) { doMode(); return; } + Timer.set(200, false, function() { + readAlarm(function(err, alarm) { + if (err) { + debug("Alarm read error: " + err); + } else if (alarm.roomSensorFail) { + print("[ST802] ALARM: Room sensor failure!"); + } else { + debug("Alarm: OK"); + } + doMode(); + }); + }); + } + + function doMode() { + if (!ENABLE.POLL_MODE) { doFanSpeed(); return; } + Timer.set(200, false, function() { + readMode(function(err, val) { + if (err) { + debug("Mode read error: " + err); + } else { + print("[ST802] Mode: " + modeLabel(val)); + } + doFanSpeed(); + }); + }); + } + + function doFanSpeed() { + if (!ENABLE.POLL_FAN_SPEED) { doHumidity(); return; } + Timer.set(200, false, function() { + readFanSpeed(function(err, val) { + if (err) { + debug("Fan speed read error: " + err); + } else { + print("[ST802] Fan: " + fanLabel(val)); + } + doHumidity(); + }); + }); + } + + function doHumidity() { + if (!ENABLE.POLL_HUMIDITY) { return; } + Timer.set(200, false, function() { + readHumidity(function(err, val) { + if (err) { + debug("Humidity read error: " + err); + } else { + print("[ST802] Humidity: " + val.toFixed(0) + "%"); + } + }); + }); + } + + if (ENABLE.POLL_TEMPERATURES) { + readTemperatures(function(err, temps) { + if (err) { + debug("Temperature read error: " + err); + } else { + print("[ST802] Room: " + temps.roomTemp.toFixed(1) + "degC " + + "Humidity: " + temps.humidity.toFixed(0) + "% " + + "Floor: " + temps.floorTemp.toFixed(1) + "degC"); + } + doRelays(); + }); + } else { + doRelays(); + } +} + +/* === BMS COMMAND SIMULATION === */ + +/** + * BMS command scenarios, cycled on CMD_INTERVAL. + * Each scenario has a `key` matching an ENABLE flag so it can be + * individually disabled without removing code. + */ +var CMD_SCENARIOS = [ + { + key: "CMD_MORNING_HEAT", + label: "Morning start - Heating 22degC, Auto fan", + fn: function() { + setPower(POWER.ON, function() { + Timer.set(300, false, function() { + setMode(MODE.HEATING, function() { + Timer.set(300, false, function() { + setSetpoint(22.0, function() { + Timer.set(300, false, function() { + setFanSpeed(FAN.AUTO, null); + }); + }); + }); + }); + }); + }); + } + }, + { + key: "CMD_COOLING", + label: "Occupied - Cooling 24degC, Medium fan", + fn: function() { + setMode(MODE.COOLING, function() { + Timer.set(300, false, function() { + setSetpoint(24.0, function() { + Timer.set(300, false, function() { + setFanSpeed(FAN.MEDIUM, null); + }); + }); + }); + }); + } + }, + { + key: "CMD_ECONOMY_HEAT", + label: "Economy - Heating 20degC, Low fan", + fn: function() { + setMode(MODE.HEATING, function() { + Timer.set(300, false, function() { + setSetpoint(20.0, function() { + Timer.set(300, false, function() { + setFanSpeed(FAN.LOW, null); + }); + }); + }); + }); + } + }, + { + key: "CMD_VENTILATION", + label: "Ventilation only, Auto fan", + fn: function() { + setMode(MODE.VENTILATION, function() { + Timer.set(300, false, function() { + setFanSpeed(FAN.AUTO, null); + }); + }); + } + }, + { + key: "CMD_DRY", + label: "Dehumidify (Dry mode) 24degC", + fn: function() { + setMode(MODE.DRY, function() { + Timer.set(300, false, function() { + setSetpoint(24.0, null); + }); + }); + } + }, + { + key: "CMD_FLOOR_HEAT", + label: "Floor heating 21degC", + fn: function() { + setMode(MODE.FLOOR_HEATING, function() { + Timer.set(300, false, function() { + setSetpoint(21.0, null); + }); + }); + } + }, + { + key: "CMD_NIGHT_SETBACK", + label: "Night setback - Heating 18degC, Low fan", + fn: function() { + setMode(MODE.HEATING, function() { + Timer.set(300, false, function() { + setSetpoint(18.0, function() { + Timer.set(300, false, function() { + setFanSpeed(FAN.LOW, null); + }); + }); + }); + }); + } + }, + { + key: "CMD_STANDBY", + label: "Standby - Power OFF", + fn: function() { + setPower(POWER.OFF, null); + } + } +]; + +/** + * Execute the next enabled BMS command scenario in the rotation. + * Scenarios whose ENABLE key is false are silently skipped. + * If all scenarios are disabled the cycle is a no-op. + */ +function runNextBmsCommand() { + var total = CMD_SCENARIOS.length; + var checked = 0; + while (checked < total) { + var scenario = CMD_SCENARIOS[state.cmdStep % total]; + state.cmdStep++; + checked++; + if (ENABLE[scenario.key] === false) { + debug("Skipping disabled scenario: " + scenario.key); + continue; + } + print("[BMS] Sending command: " + scenario.label); + scenario.fn(); + return; + } + debug("All command scenarios disabled -- nothing to send."); +} + +/* === INITIALIZATION === */ + +function init() { + print("LinkedGo ST802 - BMS Modbus RTU Client"); + print("======================================="); + print("Slave ID: " + CONFIG.SLAVE_ID + " Baud: " + CONFIG.BAUD_RATE + " " + CONFIG.MODE); + print(""); + + state.uart = UART.get(); + if (!state.uart) { + print("ERROR: UART not available"); + return; + } + + if (!state.uart.configure({ baud: CONFIG.BAUD_RATE, mode: CONFIG.MODE })) { + print("ERROR: UART configuration failed"); + return; + } + + state.uart.recv(onReceive); + state.isReady = true; + + print("UART ready. Starting BMS simulation."); + print(""); + print("Control registers (write with FC 06):"); + print(" 0x" + toHex16(REG.POWER) + " (H00) - Power"); + print(" 0x" + toHex16(REG.MODE) + " (H03) - Operating mode"); + print(" 0x" + toHex16(REG.FAN_SPEED) + " (H06) - Fan speed"); + print(" 0x" + toHex16(REG.SETPOINT) + " (H07) - Setpoint temp"); + print(" 0x" + toHex16(REG.HUMIDITY_SP) + " (H08) - Humidity setpoint"); + print(""); + print("Sensor registers (read with FC 03):"); + print(" 0x" + toHex16(REG.ROOM_TEMP) + " (O00) - Room temp"); + print(" 0x" + toHex16(REG.HUMIDITY) + " (O01) - Humidity"); + print(" 0x" + toHex16(REG.FLOOR_TEMP) + " (O02) - Floor temp"); + print(" 0x" + toHex16(REG.RELAY_STATE) + " (O14) - Relay status"); + print(" 0x" + toHex16(REG.ALARM) + " - Alarm"); + print(""); + + // Initial BMS command after 1s + Timer.set(1000, false, function() { + runNextBmsCommand(); + }); + + // Periodic status poll + Timer.set(3000, false, pollStatus); + state.pollTimer = Timer.set(CONFIG.POLL_INTERVAL, true, pollStatus); + + // Periodic BMS command rotation + state.cmdTimer = Timer.set(CONFIG.CMD_INTERVAL, true, runNextBmsCommand); + + print("BMS simulation running."); + print(" Status poll every " + (CONFIG.POLL_INTERVAL / 1000) + "s"); + print(" Command cycle every " + (CONFIG.CMD_INTERVAL / 1000) + "s"); + print(""); + print("API quick reference:"); + print(" setPower(POWER.ON/OFF, cb)"); + print(" setMode(MODE.HEATING/COOLING/DRY/FLOOR_HEATING/VENTILATION, cb)"); + print(" setFanSpeed(FAN.AUTO/LOW/MEDIUM/HIGH, cb)"); + print(" setSetpoint(22.0, cb) // degC, 0.5 step"); + print(" setHumiditySetpoint(55, cb) // %, range 40-75"); + print(" readTemperatures(cb)"); + print(" readRelayStatus(cb)"); + print(" readAlarm(cb)"); +} + +init(); diff --git a/the_pill/MODBUS/LinkedGo/ST802/st802_bms_vc.shelly.js b/the_pill/MODBUS/LinkedGo/ST802/st802_bms_vc.shelly.js new file mode 100644 index 0000000..cfff0f9 --- /dev/null +++ b/the_pill/MODBUS/LinkedGo/ST802/st802_bms_vc.shelly.js @@ -0,0 +1,999 @@ +/** + * @title LinkedGo ST802 Thermostat - BMS Modbus RTU Client + Virtual Components + * @description Modbus RTU master that simulates BMS commands for the LinkedGo + * ST802 Youth Smart Thermostat over RS485 with Virtual Component updates. + * Publishes room temp, humidity, floor temp, relay state, alarm, mode, + * fan speed, setpoint, and power state to virtual number components. + * @status production + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/MODBUS/LinkedGo/ST802/st802_bms_vc.shelly.js + */ + +/** + * LinkedGo ST802 Youth Smart Thermostat - BMS Client + Virtual Components + * + * Communicates via RS485-2 (terminals A2/B2) which defaults to slave mode. + * + * Thermostat RS485-2 factory defaults: + * P05 = 0 (Slave) + * P06 = 3 (9600 baud) + * P07 = 1 (LinkedGo protocol 3.0) + * P08 = 1 (Slave ID 1) + * + * Register map (LinkedGo 3.0, all addresses in hex): + * + * FC 03/06 - Read/Write holding registers: + * 0x1001 (H00) Power 0=OFF 1=ON + * 0x1003 (H02) System type 0=2pipe-AC 1=DC-fan 2=floor-only 3=AC+floor 17=4pipe-AC + * 0x1004 (H03) Operating mode 0=Cooling 3=Dry 4=Heating 5=Floor 7=Ventilation + * 0x1006 (H05) Heat/cool sel 0=Both 1=CoolOnly 2=HeatOnly + * 0x1007 (H06) Fan speed 0=Auto 1=Low 2=Medium 3=High 4=Speed4 5=Speed5 + * 0x1008 (H07) Setpoint temp raw * 0.1 = degC (step 0.5degC, range H23-H24) + * 0x1009 (H08) Humidity SP raw * 0.1 = % (range 40-75%) + * 0x1018 (H23) Min setpoint raw * 0.1 = degC (default 50 = 5degC) + * 0x1019 (H24) Max setpoint raw * 0.1 = degC (default 500 = 50degC -> clamp to 35degC) + * + * FC 03 - Read only: + * 0x2101 (O00) Room temp raw * 0.1 = degC + * 0x2102 (O01) Humidity raw * 0.1 = % + * 0x2103 (O02) Floor temp raw * 0.1 = degC + * 0x2110 (O14) Relay status bitmask (see RELAYS below) + * 0x211A Alarm bit0 = room sensor failure + * + * The Pill 5-Terminal Add-on wiring: + * IO1 (TX) ─── B (D-) ──> Thermostat B2 (D-) + * IO2 (RX) ─── A (D+) ──> Thermostat A2 (D+) + * IO3 ─── DE/RE ── direction control (automatic) + * GND ─── GND ──> Thermostat GND + * + * Virtual Component mapping (pre-create with skills/modbus-vc-deploy.md): + * number:200 Room Temperature degC + * number:201 Humidity % + * number:202 Floor Temperature degC + * number:203 Relay State bitmask + * number:204 Alarm 0/1 + * number:205 Mode 0-7 + * number:206 Fan Speed 0-5 + * number:207 Setpoint degC + * number:208 Power 0/1 + * group:200 ST802 Thermostat (group) + */ + +/* === CONFIG === */ +var CONFIG = { + BAUD_RATE: 9600, + MODE: "8N1", + + SLAVE_ID: 1, + RESPONSE_TIMEOUT: 1000, // ms + + POLL_INTERVAL: 30000, // Status read period (ms) + CMD_INTERVAL: 60000, // BMS command cycle period (ms) + + DEBUG: true +}; + +/* === ENABLE FLAGS === + * Set any flag to false to disable that poll action or command scenario. + * All other logic stays intact -- flip back to true to re-enable. + */ +var ENABLE = { + // Poll actions (pollStatus) + POLL_TEMPERATURES: true, + POLL_RELAYS: true, + POLL_ALARM: true, + POLL_MODE: true, + POLL_FAN_SPEED: true, + POLL_HUMIDITY: true, + POLL_SETPOINT: true, // VC variant: reads setpoint to keep VC current + POLL_POWER: true, // VC variant: reads power state to keep VC current + + // BMS command scenarios (CMD_SCENARIOS keys) + CMD_MORNING_HEAT: false, + CMD_COOLING: false, + CMD_ECONOMY_HEAT: false, + CMD_VENTILATION: false, + CMD_DRY: false, + CMD_FLOOR_HEAT: false, + CMD_NIGHT_SETBACK: false, + CMD_STANDBY: false +}; + +/* === ST802 REGISTER MAP === */ +var ENTITIES = [ + // + // --- Control registers (Read / Write, FC 03 / 06) --- + // + { key: "POWER", name: "Power", units: "-", reg: { addr: 0x1001, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: "number:208", handle: null, vcHandle: null }, + { key: "SYS_TYPE", name: "System Type", units: "-", reg: { addr: 0x1003, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { key: "MODE", name: "Operating Mode", units: "-", reg: { addr: 0x1004, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: "number:205", handle: null, vcHandle: null }, + { key: "HC_SELECT", name: "Heat/Cool Select", units: "-", reg: { addr: 0x1006, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { key: "FAN_SPEED", name: "Fan Speed", units: "-", reg: { addr: 0x1007, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "RW", vcId: "number:206", handle: null, vcHandle: null }, + { key: "SETPOINT", name: "Setpoint Temp", units: "degC", reg: { addr: 0x1008, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "RW", vcId: "number:207", handle: null, vcHandle: null }, + { key: "HUMIDITY_SP", name: "Humidity Setpoint", units: "%", reg: { addr: 0x1009, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { key: "MIN_SP", name: "Min Setpoint", units: "degC", reg: { addr: 0x1018, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + { key: "MAX_SP", name: "Max Setpoint", units: "degC", reg: { addr: 0x1019, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "RW", vcId: null, handle: null, vcHandle: null }, + // + // --- Sensor registers (Read only, FC 03) --- + // + { key: "ROOM_TEMP", name: "Room Temperature", units: "degC", reg: { addr: 0x2101, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "R", vcId: "number:200", handle: null, vcHandle: null }, + { key: "HUMIDITY", name: "Humidity", units: "%", reg: { addr: 0x2102, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "R", vcId: "number:201", handle: null, vcHandle: null }, + { key: "FLOOR_TEMP", name: "Floor Temperature", units: "degC", reg: { addr: 0x2103, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 0.1, rights: "R", vcId: "number:202", handle: null, vcHandle: null }, + { key: "RELAY_STATE", name: "Relay Status", units: "-", reg: { addr: 0x2110, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: "number:203", handle: null, vcHandle: null }, + { key: "ALARM", name: "Alarm", units: "-", reg: { addr: 0x211A, rtype: 0x03, itype: "u16", bo: "BE", wo: "BE" }, scale: 1, rights: "R", vcId: "number:204", handle: null, vcHandle: null }, +]; + +/* Build REG address map from ENTITIES so all API functions work unchanged */ +var REG = {}; +for (var _ei = 0; _ei < ENTITIES.length; _ei++) { + REG[ENTITIES[_ei].key] = ENTITIES[_ei].reg.addr; +} + +/* === ENUMERATION VALUES === */ +var POWER = { OFF: 0, ON: 1 }; + +var MODE = { + COOLING: 0, + DRY: 3, + HEATING: 4, + FLOOR_HEATING: 5, + VENTILATION: 7 +}; + +var FAN = { + AUTO: 0, + LOW: 1, + MEDIUM: 2, + HIGH: 3, + SPD4: 4, + SPD5: 5 +}; + +var HC = { BOTH: 0, COOL_ONLY: 1, HEAT_ONLY: 2 }; + +/* Relay bitmask positions (O14 / 0x2110) */ +var RELAYS = { + HIGH_SPEED: 0, // bit0 + MEDIUM_SPEED: 1, // bit1 + LOW_SPEED: 2, // bit2 + FAN_COIL_VALVE: 3, // bit3 + FLOOR_VALVE: 4, // bit4 + DRY_CONTACT: 5 // bit5 +}; + +/* === MODBUS FUNCTION CODES === */ +var FC = { + READ_HOLDING_REGISTERS: 0x03, + WRITE_SINGLE_REGISTER: 0x06 +}; + +/* === CRC-16 TABLE (MODBUS polynomial 0xA001) === */ +var CRC_TABLE = [ + 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, + 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440, + 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40, + 0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841, + 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40, + 0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41, + 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641, + 0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040, + 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240, + 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441, + 0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41, + 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840, + 0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41, + 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40, + 0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640, + 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041, + 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240, + 0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441, + 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41, + 0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840, + 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41, + 0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40, + 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640, + 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041, + 0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241, + 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440, + 0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40, + 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841, + 0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40, + 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41, + 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641, + 0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040 +]; + +/* === STATE === */ +var state = { + uart: null, + rxBuffer: [], + isReady: false, + pendingRequest: null, + responseTimer: null, + pollTimer: null, + cmdTimer: null, + cmdStep: 0 +}; + +/* === HELPERS === */ + +function toHex(n) { + n = n & 0xFF; + return (n < 16 ? "0" : "") + n.toString(16).toUpperCase(); +} + +function toHex16(n) { + return toHex((n >> 8) & 0xFF) + toHex(n & 0xFF); +} + +function bytesToHex(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) { + s += toHex(bytes[i]); + if (i < bytes.length - 1) s += " "; + } + return s; +} + +function debug(msg) { + if (CONFIG.DEBUG) { + print("[ST802] " + msg); + } +} + +function calcCRC(bytes) { + var crc = 0xFFFF; + for (var i = 0; i < bytes.length; i++) { + var idx = (crc ^ bytes[i]) & 0xFF; + crc = (crc >> 8) ^ CRC_TABLE[idx]; + } + return crc; +} + +function bytesToStr(bytes) { + var s = ""; + for (var i = 0; i < bytes.length; i++) { + s += String.fromCharCode(bytes[i] & 0xFF); + } + return s; +} + +function buildFrame(slaveAddr, functionCode, data) { + var frame = [slaveAddr & 0xFF, functionCode & 0xFF]; + for (var i = 0; i < data.length; i++) { + frame.push(data[i] & 0xFF); + } + var crc = calcCRC(frame); + frame.push(crc & 0xFF); + frame.push((crc >> 8) & 0xFF); + return frame; +} + +/* === VIRTUAL COMPONENT === */ + +function entityByKey(key) { + for (var i = 0; i < ENTITIES.length; i++) { + if (ENTITIES[i].key === key) return ENTITIES[i]; + } + return null; +} + +function updateVc(entity, value) { + if (!entity || !entity.vcHandle) return; + entity.vcHandle.setValue(value); + debug(entity.name + " -> " + value + " [" + entity.units + "]"); +} + +/* === MODBUS CORE === */ + +function sendRequest(functionCode, data, callback) { + if (!state.isReady) { + callback("Not initialized", null); + return; + } + if (state.pendingRequest) { + callback("Request pending", null); + return; + } + + var frame = buildFrame(CONFIG.SLAVE_ID, functionCode, data); + debug("TX: " + bytesToHex(frame)); + + state.pendingRequest = { functionCode: functionCode, callback: callback }; + state.rxBuffer = []; + + state.responseTimer = Timer.set(CONFIG.RESPONSE_TIMEOUT, false, function() { + if (state.pendingRequest) { + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + debug("Timeout"); + cb("Timeout", null); + } + }); + + state.uart.write(bytesToStr(frame)); +} + +function onReceive(data) { + if (!data || data.length === 0) return; + for (var i = 0; i < data.length; i++) { + state.rxBuffer.push(data.charCodeAt(i) & 0xFF); + } + processResponse(); +} + +function processResponse() { + if (!state.pendingRequest) { + state.rxBuffer = []; + return; + } + if (state.rxBuffer.length < 5) return; + + var fc = state.rxBuffer[1]; + + // Exception response: FC with high bit set + if (fc & 0x80) { + if (state.rxBuffer.length >= 5) { + var excFrame = state.rxBuffer.slice(0, 5); + var excCrc = calcCRC(excFrame.slice(0, 3)); + var excRecv = excFrame[3] | (excFrame[4] << 8); + if (excCrc === excRecv) { + clearResponseTimer(); + var excCode = state.rxBuffer[2]; + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + state.rxBuffer = []; + cb("Modbus exception 0x" + toHex(excCode), null); + } + } + return; + } + + var expectedLen = getExpectedLength(fc); + if (expectedLen === 0 || state.rxBuffer.length < expectedLen) return; + + var frame = state.rxBuffer.slice(0, expectedLen); + var crc = calcCRC(frame.slice(0, expectedLen - 2)); + var recvCrc = frame[expectedLen - 2] | (frame[expectedLen - 1] << 8); + + if (crc !== recvCrc) { + debug("CRC error"); + return; + } + + debug("RX: " + bytesToHex(frame)); + clearResponseTimer(); + + var responseData = frame.slice(2, expectedLen - 2); + var cb = state.pendingRequest.callback; + state.pendingRequest = null; + state.rxBuffer = []; + cb(null, responseData); +} + +function getExpectedLength(fc) { + switch (fc) { + case FC.READ_HOLDING_REGISTERS: + if (state.rxBuffer.length >= 3) { + return 3 + state.rxBuffer[2] + 2; + } + return 0; + case FC.WRITE_SINGLE_REGISTER: + return 8; // echo: slave(1)+FC(1)+addr(2)+value(2)+CRC(2) + default: + return 0; + } +} + +function clearResponseTimer() { + if (state.responseTimer) { + Timer.clear(state.responseTimer); + state.responseTimer = null; + } +} + +/* === REGISTER DECODE HELPERS === */ + +function rawToTemp(raw) { + return raw * 0.1; +} + +function tempToRaw(degC) { + return Math.round(degC * 2) * 5; +} + +function rawToHumidity(raw) { + return raw * 0.1; +} + +function decodeRelayStatus(mask) { + return { + highSpeed: !!(mask & (1 << RELAYS.HIGH_SPEED)), + mediumSpeed: !!(mask & (1 << RELAYS.MEDIUM_SPEED)), + lowSpeed: !!(mask & (1 << RELAYS.LOW_SPEED)), + fanCoilValve: !!(mask & (1 << RELAYS.FAN_COIL_VALVE)), + floorValve: !!(mask & (1 << RELAYS.FLOOR_VALVE)), + dryContact: !!(mask & (1 << RELAYS.DRY_CONTACT)) + }; +} + +function modeLabel(v) { + switch (v) { + case MODE.COOLING: return "Cooling"; + case MODE.DRY: return "Dry"; + case MODE.HEATING: return "Heating"; + case MODE.FLOOR_HEATING: return "FloorHeating"; + case MODE.VENTILATION: return "Ventilation"; + default: return "Unknown(" + v + ")"; + } +} + +function fanLabel(v) { + switch (v) { + case FAN.AUTO: return "Auto"; + case FAN.LOW: return "Low"; + case FAN.MEDIUM: return "Medium"; + case FAN.HIGH: return "High"; + case FAN.SPD4: return "Speed4"; + case FAN.SPD5: return "Speed5"; + default: return "Unknown(" + v + ")"; + } +} + +/* === ST802 CONTROL API === */ + +function setPower(onOff, callback) { + var data = [ + (REG.POWER >> 8) & 0xFF, REG.POWER & 0xFF, + (onOff >> 8) & 0xFF, onOff & 0xFF + ]; + sendRequest(FC.WRITE_SINGLE_REGISTER, data, function(err, resp) { + if (err) { + debug("setPower error: " + err); + if (callback) callback(err, false); + return; + } + debug("Power set to " + (onOff ? "ON" : "OFF")); + updateVc(entityByKey("POWER"), onOff); + if (callback) callback(null, true); + }); +} + +function setMode(mode, callback) { + var data = [ + (REG.MODE >> 8) & 0xFF, REG.MODE & 0xFF, + (mode >> 8) & 0xFF, mode & 0xFF + ]; + sendRequest(FC.WRITE_SINGLE_REGISTER, data, function(err, resp) { + if (err) { + debug("setMode error: " + err); + if (callback) callback(err, false); + return; + } + debug("Mode set to " + modeLabel(mode)); + updateVc(entityByKey("MODE"), mode); + if (callback) callback(null, true); + }); +} + +function setFanSpeed(speed, callback) { + var data = [ + (REG.FAN_SPEED >> 8) & 0xFF, REG.FAN_SPEED & 0xFF, + (speed >> 8) & 0xFF, speed & 0xFF + ]; + sendRequest(FC.WRITE_SINGLE_REGISTER, data, function(err, resp) { + if (err) { + debug("setFanSpeed error: " + err); + if (callback) callback(err, false); + return; + } + debug("Fan speed set to " + fanLabel(speed)); + updateVc(entityByKey("FAN_SPEED"), speed); + if (callback) callback(null, true); + }); +} + +function setSetpoint(degC, callback) { + var raw = tempToRaw(degC); + var data = [ + (REG.SETPOINT >> 8) & 0xFF, REG.SETPOINT & 0xFF, + (raw >> 8) & 0xFF, raw & 0xFF + ]; + sendRequest(FC.WRITE_SINGLE_REGISTER, data, function(err, resp) { + if (err) { + debug("setSetpoint error: " + err); + if (callback) callback(err, false); + return; + } + debug("Setpoint set to " + degC + "degC (raw " + raw + ")"); + updateVc(entityByKey("SETPOINT"), degC); + if (callback) callback(null, true); + }); +} + +function setHumiditySetpoint(pct, callback) { + var raw = pct * 10; + if (raw < 400) raw = 400; + if (raw > 750) raw = 750; + var data = [ + (REG.HUMIDITY_SP >> 8) & 0xFF, REG.HUMIDITY_SP & 0xFF, + (raw >> 8) & 0xFF, raw & 0xFF + ]; + sendRequest(FC.WRITE_SINGLE_REGISTER, data, function(err, resp) { + if (err) { + debug("setHumiditySetpoint error: " + err); + if (callback) callback(err, false); + return; + } + debug("Humidity setpoint set to " + pct + "% (raw " + raw + ")"); + if (callback) callback(null, true); + }); +} + +/* === ST802 STATUS API === */ + +function readTemperatures(callback) { + var startAddr = REG.ROOM_TEMP; + var qty = 3; // O00, O01, O02 + var data = [ + (startAddr >> 8) & 0xFF, startAddr & 0xFF, + (qty >> 8) & 0xFF, qty & 0xFF + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + var roomTemp = rawToTemp((resp[1] << 8) | resp[2]); + var humidity = rawToHumidity((resp[3] << 8) | resp[4]); + var floorTemp = rawToTemp((resp[5] << 8) | resp[6]); + updateVc(entityByKey("ROOM_TEMP"), roomTemp); + updateVc(entityByKey("HUMIDITY"), humidity); + updateVc(entityByKey("FLOOR_TEMP"), floorTemp); + callback(null, { + roomTemp: roomTemp, + humidity: humidity, + floorTemp: floorTemp + }); + }); +} + +function readRelayStatus(callback) { + var data = [ + (REG.RELAY_STATE >> 8) & 0xFF, REG.RELAY_STATE & 0xFF, + 0x00, 0x01 + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + var mask = (resp[1] << 8) | resp[2]; + updateVc(entityByKey("RELAY_STATE"), mask); + callback(null, decodeRelayStatus(mask)); + }); +} + +function readAlarm(callback) { + var data = [ + (REG.ALARM >> 8) & 0xFF, REG.ALARM & 0xFF, + 0x00, 0x01 + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + var mask = (resp[1] << 8) | resp[2]; + updateVc(entityByKey("ALARM"), mask & 0x01); + callback(null, { roomSensorFail: !!(mask & 0x01) }); + }); +} + +function readMode(callback) { + var data = [ + (REG.MODE >> 8) & 0xFF, REG.MODE & 0xFF, + 0x00, 0x01 + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + var val = (resp[1] << 8) | resp[2]; + updateVc(entityByKey("MODE"), val); + callback(null, val); + }); +} + +function readFanSpeed(callback) { + var data = [ + (REG.FAN_SPEED >> 8) & 0xFF, REG.FAN_SPEED & 0xFF, + 0x00, 0x01 + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + var val = (resp[1] << 8) | resp[2]; + updateVc(entityByKey("FAN_SPEED"), val); + callback(null, val); + }); +} + +function readHumidity(callback) { + var data = [ + (REG.HUMIDITY >> 8) & 0xFF, REG.HUMIDITY & 0xFF, + 0x00, 0x01 + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + var val = rawToHumidity((resp[1] << 8) | resp[2]); + updateVc(entityByKey("HUMIDITY"), val); + callback(null, val); + }); +} + +function readControlRegisters(callback) { + var startAddr = REG.POWER; // 0x1001 + var qty = 4; // H00(1001), gap(1002), H02(1003), H03(1004) + var data = [ + (startAddr >> 8) & 0xFF, startAddr & 0xFF, + (qty >> 8) & 0xFF, qty & 0xFF + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (err) { + callback(err, null); + return; + } + var power = (resp[1] << 8) | resp[2]; // H00 at 0x1001 + var sysType = (resp[5] << 8) | resp[6]; // H02 at 0x1003 + var mode = (resp[7] << 8) | resp[8]; // H03 at 0x1004 + callback(null, { + power: power, + sysType: sysType, + mode: mode + }); + }); +} + +/* === BMS POLL CYCLE === */ + +function pollStatus() { + debug("--- Polling ST802 status ---"); + + function doRelays() { + if (!ENABLE.POLL_RELAYS) { doAlarm(); return; } + Timer.set(200, false, function() { + readRelayStatus(function(err, relays) { + if (err) { + debug("Relay read error: " + err); + } else { + print("[ST802] Relays: " + + "Hi=" + (relays.highSpeed ? "1" : "0") + + " Med=" + (relays.mediumSpeed ? "1" : "0") + + " Lo=" + (relays.lowSpeed ? "1" : "0") + + " FanValve=" + (relays.fanCoilValve ? "1" : "0") + + " FloorValve=" + (relays.floorValve ? "1" : "0") + + " DryContact=" + (relays.dryContact ? "1" : "0")); + } + doAlarm(); + }); + }); + } + + function doAlarm() { + if (!ENABLE.POLL_ALARM) { doMode(); return; } + Timer.set(200, false, function() { + readAlarm(function(err, alarm) { + if (err) { + debug("Alarm read error: " + err); + } else if (alarm.roomSensorFail) { + print("[ST802] ALARM: Room sensor failure!"); + } else { + debug("Alarm: OK"); + } + doMode(); + }); + }); + } + + function doMode() { + if (!ENABLE.POLL_MODE) { doFanSpeed(); return; } + Timer.set(200, false, function() { + readMode(function(err, val) { + if (err) { + debug("Mode read error: " + err); + } else { + print("[ST802] Mode: " + modeLabel(val)); + } + doFanSpeed(); + }); + }); + } + + function doFanSpeed() { + if (!ENABLE.POLL_FAN_SPEED) { doHumidity(); return; } + Timer.set(200, false, function() { + readFanSpeed(function(err, val) { + if (err) { + debug("Fan speed read error: " + err); + } else { + print("[ST802] Fan: " + fanLabel(val)); + } + doHumidity(); + }); + }); + } + + function doHumidity() { + if (!ENABLE.POLL_HUMIDITY) { doSetpoint(); return; } + Timer.set(200, false, function() { + readHumidity(function(err, val) { + if (err) { + debug("Humidity read error: " + err); + } else { + print("[ST802] Humidity: " + val.toFixed(0) + "%"); + } + doSetpoint(); + }); + }); + } + + function doSetpoint() { + if (!ENABLE.POLL_SETPOINT) { doPower(); return; } + Timer.set(200, false, function() { + var data = [ + (REG.SETPOINT >> 8) & 0xFF, REG.SETPOINT & 0xFF, + 0x00, 0x01 + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (!err) { + var val = rawToTemp((resp[1] << 8) | resp[2]); + updateVc(entityByKey("SETPOINT"), val); + debug("Setpoint: " + val + " degC"); + } + doPower(); + }); + }); + } + + function doPower() { + if (!ENABLE.POLL_POWER) { return; } + Timer.set(200, false, function() { + var data = [ + (REG.POWER >> 8) & 0xFF, REG.POWER & 0xFF, + 0x00, 0x01 + ]; + sendRequest(FC.READ_HOLDING_REGISTERS, data, function(err, resp) { + if (!err) { + var val = (resp[1] << 8) | resp[2]; + updateVc(entityByKey("POWER"), val); + debug("Power: " + (val ? "ON" : "OFF")); + } + }); + }); + } + + if (ENABLE.POLL_TEMPERATURES) { + readTemperatures(function(err, temps) { + if (err) { + debug("Temperature read error: " + err); + } else { + print("[ST802] Room: " + temps.roomTemp.toFixed(1) + "degC " + + "Humidity: " + temps.humidity.toFixed(0) + "% " + + "Floor: " + temps.floorTemp.toFixed(1) + "degC"); + } + doRelays(); + }); + } else { + doRelays(); + } +} + +/* === BMS COMMAND SIMULATION === */ + +var CMD_SCENARIOS = [ + { + key: "CMD_MORNING_HEAT", + label: "Morning start - Heating 22degC, Auto fan", + fn: function() { + setPower(POWER.ON, function() { + Timer.set(300, false, function() { + setMode(MODE.HEATING, function() { + Timer.set(300, false, function() { + setSetpoint(22.0, function() { + Timer.set(300, false, function() { + setFanSpeed(FAN.AUTO, null); + }); + }); + }); + }); + }); + }); + } + }, + { + key: "CMD_COOLING", + label: "Occupied - Cooling 24degC, Medium fan", + fn: function() { + setMode(MODE.COOLING, function() { + Timer.set(300, false, function() { + setSetpoint(24.0, function() { + Timer.set(300, false, function() { + setFanSpeed(FAN.MEDIUM, null); + }); + }); + }); + }); + } + }, + { + key: "CMD_ECONOMY_HEAT", + label: "Economy - Heating 20degC, Low fan", + fn: function() { + setMode(MODE.HEATING, function() { + Timer.set(300, false, function() { + setSetpoint(20.0, function() { + Timer.set(300, false, function() { + setFanSpeed(FAN.LOW, null); + }); + }); + }); + }); + } + }, + { + key: "CMD_VENTILATION", + label: "Ventilation only, Auto fan", + fn: function() { + setMode(MODE.VENTILATION, function() { + Timer.set(300, false, function() { + setFanSpeed(FAN.AUTO, null); + }); + }); + } + }, + { + key: "CMD_DRY", + label: "Dehumidify (Dry mode) 24degC", + fn: function() { + setMode(MODE.DRY, function() { + Timer.set(300, false, function() { + setSetpoint(24.0, null); + }); + }); + } + }, + { + key: "CMD_FLOOR_HEAT", + label: "Floor heating 21degC", + fn: function() { + setMode(MODE.FLOOR_HEATING, function() { + Timer.set(300, false, function() { + setSetpoint(21.0, null); + }); + }); + } + }, + { + key: "CMD_NIGHT_SETBACK", + label: "Night setback - Heating 18degC, Low fan", + fn: function() { + setMode(MODE.HEATING, function() { + Timer.set(300, false, function() { + setSetpoint(18.0, function() { + Timer.set(300, false, function() { + setFanSpeed(FAN.LOW, null); + }); + }); + }); + }); + } + }, + { + key: "CMD_STANDBY", + label: "Standby - Power OFF", + fn: function() { + setPower(POWER.OFF, null); + } + } +]; + +function runNextBmsCommand() { + var total = CMD_SCENARIOS.length; + var checked = 0; + while (checked < total) { + var scenario = CMD_SCENARIOS[state.cmdStep % total]; + state.cmdStep++; + checked++; + if (ENABLE[scenario.key] === false) { + debug("Skipping disabled scenario: " + scenario.key); + continue; + } + print("[BMS] Sending command: " + scenario.label); + scenario.fn(); + return; + } + debug("All command scenarios disabled -- nothing to send."); +} + +/* === INITIALIZATION === */ + +function init() { + print("LinkedGo ST802 - BMS Modbus RTU Client + Virtual Components"); + print("============================================================="); + print("Slave ID: " + CONFIG.SLAVE_ID + " Baud: " + CONFIG.BAUD_RATE + " " + CONFIG.MODE); + print(""); + + // Initialize virtual component handles + for (var i = 0; i < ENTITIES.length; i++) { + var ent = ENTITIES[i]; + if (ent.vcId) { + ent.vcHandle = Virtual.getHandle(ent.vcId); + debug("VC handle for " + ent.name + " -> " + ent.vcId); + } + } + + state.uart = UART.get(); + if (!state.uart) { + print("ERROR: UART not available"); + return; + } + + if (!state.uart.configure({ baud: CONFIG.BAUD_RATE, mode: CONFIG.MODE })) { + print("ERROR: UART configuration failed"); + return; + } + + state.uart.recv(onReceive); + state.isReady = true; + + print("UART ready. Starting BMS simulation."); + print(""); + print("Control registers (write with FC 06):"); + print(" 0x" + toHex16(REG.POWER) + " (H00) - Power"); + print(" 0x" + toHex16(REG.MODE) + " (H03) - Operating mode"); + print(" 0x" + toHex16(REG.FAN_SPEED) + " (H06) - Fan speed"); + print(" 0x" + toHex16(REG.SETPOINT) + " (H07) - Setpoint temp"); + print(" 0x" + toHex16(REG.HUMIDITY_SP) + " (H08) - Humidity setpoint"); + print(""); + print("Sensor registers (read with FC 03):"); + print(" 0x" + toHex16(REG.ROOM_TEMP) + " (O00) - Room temp"); + print(" 0x" + toHex16(REG.HUMIDITY) + " (O01) - Humidity"); + print(" 0x" + toHex16(REG.FLOOR_TEMP) + " (O02) - Floor temp"); + print(" 0x" + toHex16(REG.RELAY_STATE) + " (O14) - Relay status"); + print(" 0x" + toHex16(REG.ALARM) + " - Alarm"); + print(""); + + // Initial BMS command after 1s + Timer.set(1000, false, function() { + runNextBmsCommand(); + }); + + // Periodic status poll + Timer.set(3000, false, pollStatus); + state.pollTimer = Timer.set(CONFIG.POLL_INTERVAL, true, pollStatus); + + // Periodic BMS command rotation + state.cmdTimer = Timer.set(CONFIG.CMD_INTERVAL, true, runNextBmsCommand); + + print("BMS simulation running."); + print(" Status poll every " + (CONFIG.POLL_INTERVAL / 1000) + "s"); + print(" Command cycle every " + (CONFIG.CMD_INTERVAL / 1000) + "s"); + print(""); + print("API quick reference:"); + print(" setPower(POWER.ON/OFF, cb)"); + print(" setMode(MODE.HEATING/COOLING/DRY/FLOOR_HEATING/VENTILATION, cb)"); + print(" setFanSpeed(FAN.AUTO/LOW/MEDIUM/HIGH, cb)"); + print(" setSetpoint(22.0, cb) // degC, 0.5 step"); + print(" setHumiditySetpoint(55, cb) // %, range 40-75"); + print(" readTemperatures(cb)"); + print(" readRelayStatus(cb)"); + print(" readAlarm(cb)"); +} + +init(); diff --git a/the_pill/MODBUS/README.md b/the_pill/MODBUS/README.md index 9176826..274cc9c 100644 --- a/the_pill/MODBUS/README.md +++ b/the_pill/MODBUS/README.md @@ -1,135 +1,32 @@ -# MODBUS-RTU Master - -> **Under Development** - This example is currently under development and may not be fully functional. - -Scripts for communicating with MODBUS slave devices (sensors, PLCs, energy meters, etc.) using the MODBUS-RTU protocol over UART. - -## Hardware Requirements - -- Shelly device with UART (e.g., The Pill) -- RS485 transceiver module (e.g., MAX485, SP485) -- MODBUS slave device - -### Wiring - -| RS485 Module | Shelly | -|--------------|--------| -| RO (Receiver Output) | RX (GPIO) | -| DI (Driver Input) | TX (GPIO) | -| VCC | 3.3V or 5V | -| GND | GND | - -**RS485 Bus:** -| RS485 Module | MODBUS Device | -|--------------|---------------| -| A (D+) | A (D+) | -| B (D-) | B (D-) | -| GND | GND (optional) | - -**UART Settings:** 9600 baud (default), 8N1 - -## Files - -### modbus_rtu.shelly.js - -**Core MODBUS-RTU Master Library** - Full implementation of the MODBUS-RTU protocol. - -**Supported Function Codes:** - -| Code | Name | Description | -|------|------|-------------| -| 0x01 | Read Coils | Read 1-2000 coil status bits | -| 0x02 | Read Discrete Inputs | Read 1-2000 input status bits | -| 0x03 | Read Holding Registers | Read 1-125 16-bit registers | -| 0x04 | Read Input Registers | Read 1-125 16-bit input registers | -| 0x05 | Write Single Coil | Write one coil ON/OFF | -| 0x06 | Write Single Register | Write one 16-bit register | - -**API Methods:** -```javascript -MODBUS.init() // Initialize UART - -// Read functions -MODBUS.readCoils(slave, addr, qty, callback) // FC 0x01 -MODBUS.readDiscreteInputs(slave, addr, qty, callback) // FC 0x02 -MODBUS.readHoldingRegisters(slave, addr, qty, callback) // FC 0x03 -MODBUS.readInputRegisters(slave, addr, qty, callback) // FC 0x04 - -// Write functions -MODBUS.writeSingleCoil(slave, addr, value, callback) // FC 0x05 -MODBUS.writeSingleRegister(slave, addr, value, callback) // FC 0x06 - -// Convenience methods -MODBUS.readRegister(slave, addr, callback) // Read single holding register -MODBUS.readCoil(slave, addr, callback) // Read single coil -``` - ---- - -### ComWinTop/mb308v.shelly.js - -**CWT-MB308V GPIO Expander Example** - See [ComWinTop/README.md](ComWinTop/README.md) for full documentation. - ---- - -### JK200-MBS/the_pill_mbsa_jk200.shelly.js - -**Jikong JK-PB BMS Reader** - See [JK200-MBS/README.md](JK200-MBS/README.md) for full documentation. - -## Usage Examples - -### Read Holding Registers - -```javascript -// Read 2 registers from slave 1, starting at address 0 -MODBUS.readHoldingRegisters(1, 0, 2, function(err, registers) { - if (err) { - print("Error: " + err); - return; - } - print("Register 0: " + registers[0]); - print("Register 1: " + registers[1]); -}); -``` - -### Write Single Coil - -```javascript -// Turn ON coil 0 on slave 1 -MODBUS.writeSingleCoil(1, 0, true, function(err, success) { - if (err) { - print("Failed: " + err); - } else { - print("Coil turned ON"); - } -}); -``` - -### Write Single Register - -```javascript -// Write value 250 to register 100 on slave 1 -MODBUS.writeSingleRegister(1, 100, 250, function(err, success) { - if (err) { - print("Failed: " + err); - } else { - print("Register written"); - } -}); -``` - -## Configuration - -```javascript -var CONFIG = { - BAUD_RATE: 9600, // 9600, 19200, 38400, 115200 - MODE: "8N1", // "8N1", "8E1", "8O1" - RESPONSE_TIMEOUT: 1000, // ms - DEBUG: true -}; -``` - -## References - -- [MODBUS Protocol Specification](https://modbus.org/specs.php) -- [MODBUS over Serial Line](https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf) +# The Pill MODBUS-RTU Examples + +MODBUS-RTU examples for Shelly devices (The Pill) using RS485 half-duplex communication. + +## Problem (The Story) +You have field devices (BMS, inverter, thermostat, IO module, thermal pump) that expose valuable status and controls over MODBUS, but your automation stack cannot read them directly or consistently. These examples provide a practical bridge from RS485/MODBUS to Shelly scripts and virtual components. + +## Persona +- Integrator wiring The Pill into HVAC, energy, and battery systems +- Installer replacing vendor cloud dependence with local telemetry/control +- Advanced DIY user needing readable, modifiable MODBUS scripts + +## RS485 Wiring (The Pill 5-Terminal Add-on) +| The Pill Pin | RS485 Role | Remote Device Pin | +|---|---|---| +| `GND` | Common ground | `GND` | +| `IO1 (TX)` | Data B / D- | `B` / `D-` | +| `IO2 (RX)` | Data A / D+ | `A` / `D+` | +| `IO3` | DE/RE direction control | `DE` + `RE` | +| `5V` | Optional transceiver power | `VCC` | + +RS485 reliability notes: +- Use twisted pair for `A/B` on longer runs. +- Add 120 ohm termination at bus ends for longer cables. +- Always share `GND` between nodes. + +## Structure +- [`modbus_rtu.shelly.js`](modbus_rtu.shelly.js): reusable MODBUS master core +- [`ComWinTop/`](ComWinTop/): CWT-MB308V IO module examples +- [`Deye/`](Deye/): Deye inverter readers (plain + VC) +- [`JKESS/`](JKESS/): JK BMS examples +- [`LinkedGo/`](LinkedGo/): LinkedGo thermostat and thermal pump examples diff --git a/the_pill/MODBUS/modbus_rtu.shelly.js b/the_pill/MODBUS/modbus_rtu.shelly.js index b32f73b..dbe5372 100644 --- a/the_pill/MODBUS/modbus_rtu.shelly.js +++ b/the_pill/MODBUS/modbus_rtu.shelly.js @@ -20,12 +20,12 @@ * - 0x05: Write Single Coil * - 0x06: Write Single Register * - * Hardware connection: - * - RS485 Module TX -> Shelly RX (GPIO) - * - RS485 Module RX -> Shelly TX (GPIO) - * - RS485 Module DE/RE -> Directly managed by module - * - VCC -> 3.3V or 5V (depending on module) - * - GND -> GND + * The Pill 5-Terminal Add-on wiring: + * IO1 (TX) ─── B (D-) ──> Device RS485 B (D-) + * IO2 (RX) ─── A (D+) ──> Device RS485 A (D+) + * IO3 ─── DE/RE ── direction control (automatic) + * GND ─── GND ──> Device GND + * 5V ─── 5V ──> Device VCC (if needed) * * Protocol: * - Default baud rate: 9600 (configurable) diff --git a/the_pill/SDS011/README.md b/the_pill/SDS011/README.md index e49b287..b109997 100644 --- a/the_pill/SDS011/README.md +++ b/the_pill/SDS011/README.md @@ -79,33 +79,17 @@ Shelly.emitEvent("air_quality", { --- -### sds011_setup.shelly.js +### sds011-vc-cycle.shelly.js -**Virtual Components Setup** - Run ONCE to create the UI components. - -Creates the following virtual components: -| Component | Description | -|-----------|-------------| -| `number:200` | PM2.5 value display (μg/m³) | -| `number:201` | PM10 value display (μg/m³) | -| `text:200` | AQI category display | -| `button:200` | Wake/Sleep toggle | - -After running, you can delete or disable this script. - ---- - -### sds011_vc.shelly.js - -**Virtual Components UI** - Main script with graphical interface. - -**Prerequisites:** Run `sds011_setup.shelly.js` first to create components. +**Virtual Components + Duty Cycle** - Main SDS011 script for Virtual Components. **Features:** -- Displays PM2.5 and PM10 on Shelly UI -- Shows AQI category in real-time -- Wake/Sleep button for power management -- Event emission for automation +- Wake/warmup/collect/sleep cycle for longer sensor lifetime +- Frame validation, checksum checks, and ACK filtering +- PM2.5/PM10 averaging with minimum sample threshold +- Spike clamping and bounds validation for cleaner values +- Virtual Component updates for PM values, status, timestamp, and AQI category +- Optional boolean power component (`boolean:200`) to enable/disable polling --- @@ -122,8 +106,10 @@ After running, you can delete or disable this script. ## Quick Start 1. Wire the SDS011 sensor to your Shelly device -2. Upload and run `sds011.shelly.js` -3. Readings will print automatically every second +2. Create Virtual Components used by the script: + - `number:200`, `number:201`, `text:200`, `text:201`, `enum:200`, `boolean:200` +3. Upload and run `sds011-vc-cycle.shelly.js` +4. Enable `boolean:200` to start the polling cycle **Example Output:** ``` diff --git a/the_pill/SDS011/sds011-vc-cycle.shelly.js b/the_pill/SDS011/sds011-vc-cycle.shelly.js new file mode 100644 index 0000000..a1b1632 --- /dev/null +++ b/the_pill/SDS011/sds011-vc-cycle.shelly.js @@ -0,0 +1,459 @@ +/** + * @title SDS011 virtual components cycle reader + * @description Cycled UART reader for SDS011 PM2.5/PM10 values with Virtual + * Component updates and wake/sleep duty cycle control. + * @status under development + * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/SDS011/sds011-vc-cycle.shelly.js + */ + +/** + * Nova Fitness SDS011 PM2.5/PM10 sensor with Virtual Components + * + * Reads SDS011 frames over UART, filters out invalid or sudden-spike values, + * averages samples in a collection window, and writes results to Virtual + * Components. + * + * Hardware connection: + * - SDS011 TX (Pin 7) -> Shelly RX (GPIO) + * - SDS011 RX (Pin 6) -> Shelly TX (GPIO) + * - VCC (Pin 3) -> 5V + * - GND (Pin 5) -> GND + * + * Virtual Components used: + * - number:200 PM2.5 value (ug/m3) + * - number:201 PM10 value (ug/m3) + * - text:200 Last report timestamp + * - text:201 Runtime status + * - enum:200 Air quality category + * - boolean:200 Power control (on/off) + */ + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +const BAUD = 9600; + +const VC_PM25 = Virtual.getHandle('number:200'); +const VC_PM10 = Virtual.getHandle('number:201'); +const VC_LAST_REPORT = Virtual.getHandle('text:200'); +const VC_STATE_REPORT = Virtual.getHandle('text:201'); +const VC_AIR_QUALITY = Virtual.getHandle('enum:200'); +const VC_POWER = Virtual.getHandle('boolean:200'); + +const WARMUP_SEC = 30; +const SAMPLE_SEC = 30; +const SLEEP_SEC = 15 * 60; +const MIN_SAMPLES = 10; + +const HEADER_BYTE = 0xaa; +const TAIL_BYTE = 0xab; +const FRAME_LEN = 10; +const BUF_MAX_FRAMES = 50; +const BUF_MAX_LEN = FRAME_LEN * BUF_MAX_FRAMES; +const DATA_FRAME_TYPE = 0xc0; +const CMD_ACK_TYPE = 0xc5; +const CMD_FRAME_TYPE = 0xb4; +const CMD_SET_SLEEP = 0x06; +const CMD_SET_MODE = 0x02; +const SET_FLAG = 0x01; +const WAKEUP_ON = 0x01; +const WAKEUP_OFF = 0x00; +const MODE_ACTIVE = 0x00; + +const MAX_PM25 = 1000; +const MAX_PM10 = 1000; +const FRAME_MAX_DELTA_PM25 = 100; +const FRAME_MAX_DELTA_PM10 = 200; + +// ============================================================================ +// STATE +// ============================================================================ + +const uart = UART.get(); + +let power = false; +let buf = ''; +let collecting = false; +let sum25 = 0; +let sum10 = 0; +let cnt = 0; +let rxBytes = 0; +let lastFramePm25 = null; +let lastFramePm10 = null; + +const timers = { + start: null, + wakeup: null, + warmup: null, + stop: null, + next: null, +}; + +// ============================================================================ +// HELPERS +// ============================================================================ + +function setValue(vc, value) { + if (vc) { + vc.setValue(value); + } +} + +function setStatus(status) { + setValue(VC_STATE_REPORT, status); +} + +function ts() { + return new Date().toString().split('GMT')[0].trim(); +} + +function byteAt(s, i) { + return s.charCodeAt(i) & 0xff; +} + +function round1(n) { + return Math.round(n * 10) / 10; +} + +function resetStats() { + sum25 = 0; + sum10 = 0; + cnt = 0; + lastFramePm25 = null; + lastFramePm10 = null; +} + +function resetBuffer() { + buf = ''; +} + +function resetRxBytes() { + rxBytes = 0; +} + +function reset() { + resetStats(); + resetBuffer(); + resetRxBytes(); +} + +function setAirQuality(pm25) { + if (!VC_AIR_QUALITY) { + return; + } + + let key = 'n_a'; + if (pm25 === undefined || pm25 < 0) { + key = 'n_a'; + } else if (pm25 <= 10) { + key = 'good'; + } else if (pm25 <= 35) { + key = 'moderate'; + } else if (pm25 <= 55) { + key = 'poor'; + } else if (pm25 <= 150) { + key = 'unhealthy'; + } else { + key = 'hazardous'; + } + + VC_AIR_QUALITY.setValue(key); +} + +function clearTimer(id) { + if (id !== undefined && id !== null) { + Timer.clear(id); + } +} + +function clearAllTimers() { + for (const k in timers) { + clearTimer(timers[k]); + timers[k] = null; + } +} + +function schedule(key, ms, fn) { + if (!(key in timers)) { + return; + } + + clearTimer(timers[key]); + timers[key] = Timer.set(ms, false, fn); +} + +function clampDelta(v, last, maxDelta) { + if (last === null || last === undefined) { + return v; + } + if (v > last + maxDelta) { + return last + maxDelta; + } + if (v < last - maxDelta) { + return last - maxDelta; + } + return v; +} + +function isFiniteNumber(n) { + return typeof n === 'number' && isFinite(n); +} + +function findHeaderIndex(s) { + for (let i = 0; i < s.length; i++) { + if (byteAt(s, i) === HEADER_BYTE) { + return i; + } + } + return -1; +} + +function beginCollecting() { + reset(); + collecting = true; + setStatus('Collecting for ' + SAMPLE_SEC + ' sec.'); +} + +// ============================================================================ +// SDS011 COMMANDS +// ============================================================================ + +function bytesToStr(bytes) { + let s = ''; + for (let i = 0; i < bytes.length; i++) { + s += String.fromCharCode(bytes[i] & 0xff); + } + return s; +} + +function sumLow8(bytes, from, to) { + let s = 0; + for (let i = from; i <= to; i++) { + s = (s + (bytes[i] & 0xff)) & 0xff; + } + return s & 0xff; +} + +function buildCmd(cmd, setFlag, value) { + const b = [HEADER_BYTE, CMD_FRAME_TYPE, cmd & 0xff, setFlag & 0xff, value & 0xff, 0x00]; + for (let i = 0; i < 9; i++) { + b.push(0x00); + } + b.push(0xff, 0xff); + b.push(sumLow8(b, 2, 16)); + b.push(TAIL_BYTE); + return b; +} + +function sendCmd(cmd, setFlag, value) { + uart.write(bytesToStr(buildCmd(cmd, setFlag, value))); +} + +function cmdWake() { + sendCmd(CMD_SET_SLEEP, SET_FLAG, WAKEUP_ON); +} + +function cmdSleep() { + sendCmd(CMD_SET_SLEEP, SET_FLAG, WAKEUP_OFF); +} + +function cmdActive() { + sendCmd(CMD_SET_MODE, SET_FLAG, MODE_ACTIVE); +} + +// ============================================================================ +// SDS011 FRAME PARSING +// ============================================================================ + +function checkSum10(frame) { + let sum = 0; + for (let i = 2; i <= 7; i++) { + sum = (sum + byteAt(frame, i)) & 0xff; + } + return sum === byteAt(frame, 8); +} + +function parseFrame(frame) { + if ( + frame.length !== FRAME_LEN || + byteAt(frame, 0) !== HEADER_BYTE || + byteAt(frame, 1) !== DATA_FRAME_TYPE || + byteAt(frame, 9) !== TAIL_BYTE || + !checkSum10(frame) + ) { + return null; + } + + const pm25 = (((byteAt(frame, 3) << 8) | byteAt(frame, 2)) & 0xffff) / 10.0; + const pm10 = (((byteAt(frame, 5) << 8) | byteAt(frame, 4)) & 0xffff) / 10.0; + return { pm25: pm25, pm10: pm10 }; +} + +function isValidReading(p) { + if (!p) { + return false; + } + if (!isFiniteNumber(p.pm25) || !isFiniteNumber(p.pm10)) { + return false; + } + if (p.pm25 < 0 || p.pm10 < 0 || p.pm25 > MAX_PM25 || p.pm10 > MAX_PM10) { + return false; + } + return true; +} + +function collectDataFrame(frame) { + if (!collecting) { + return; + } + + const p = parseFrame(frame); + if (!p) { + return; + } + + p.pm25 = clampDelta(p.pm25, lastFramePm25, FRAME_MAX_DELTA_PM25); + p.pm10 = clampDelta(p.pm10, lastFramePm10, FRAME_MAX_DELTA_PM10); + if (isValidReading(p)) { + sum25 += p.pm25; + sum10 += p.pm10; + cnt++; + lastFramePm25 = p.pm25; + lastFramePm10 = p.pm10; + } +} + +function scanFrames() { + while (buf.length >= FRAME_LEN) { + const start = findHeaderIndex(buf); + if (start < 0) { + buf = ''; + return; + } + + if (start > 0) { + buf = buf.slice(start); + } + if (buf.length < FRAME_LEN) { + return; + } + + const frame = buf.slice(0, FRAME_LEN); + const type = byteAt(frame, 1); + buf = buf.slice(FRAME_LEN); + + if (type === CMD_ACK_TYPE) { + continue; + } + if (type === DATA_FRAME_TYPE) { + collectDataFrame(frame); + continue; + } + + buf = frame.slice(1) + buf; + } +} + +// ============================================================================ +// CYCLE CONTROL +// ============================================================================ + +function startCycle() { + if (!power) { + return; + } + + clearAllTimers(); + collecting = false; + reset(); + + cmdActive(); + cmdWake(); + schedule('wakeup', 500, cmdWake); + setStatus('Warmup for ' + WARMUP_SEC + ' sec.'); + schedule('warmup', WARMUP_SEC * 1000, beginCollecting); + schedule('stop', (WARMUP_SEC + SAMPLE_SEC) * 1000, finishCycle); +} + +function finishCycle() { + scanFrames(); + collecting = false; + + const sleepMin = Math.floor(SLEEP_SEC / 60); + if (cnt >= MIN_SAMPLES) { + const pm25 = round1(sum25 / cnt); + setValue(VC_PM25, pm25); + setValue(VC_PM10, round1(sum10 / cnt)); + setValue(VC_LAST_REPORT, ts()); + setAirQuality(pm25); + setStatus('Sleeping for ' + sleepMin + ' min.'); + } else { + let error = 'No samples collected. '; + if (cnt === 0 && rxBytes === 0) { + error = 'No data received from sensor. '; + } + setStatus(error + 'Sleeping for ' + sleepMin + ' min.'); + } + + cmdSleep(); + reset(); + schedule('next', Math.max(1, SLEEP_SEC) * 1000, startCycle); +} + +function applyPowerState(isOn) { + power = !!isOn; + clearAllTimers(); + + if (power) { + setStatus('Power ON. Starting cycle...'); + schedule('start', 300, startCycle); + return; + } + + collecting = false; + reset(); + cmdSleep(); + setAirQuality(-1); + setStatus('Power OFF. Sleeping.'); +} + +// ============================================================================ +// INITIALIZATION +// ============================================================================ + +function init() { + if (!uart || !uart.configure({ baud: BAUD, mode: '8N1' })) { + setStatus('Unable to configure UART @ ' + BAUD); + die(); + } + + uart.recv(function(data) { + if (!power || !data || !data.length) { + return; + } + + rxBytes += data.length; + buf += data; + + if (buf.length > BUF_MAX_LEN) { + buf = buf.slice(buf.length - BUF_MAX_LEN); + } + + scanFrames(); + }); + + if (VC_POWER) { + VC_POWER.on('change', function() { + applyPowerState(!!VC_POWER.getValue()); + }); + + // Start on boot only when Power is enabled. + applyPowerState(!!VC_POWER.getValue()); + return; + } + + applyPowerState(true); +} + +init(); diff --git a/the_pill/SDS011/sds011_setup.shelly.js b/the_pill/SDS011/sds011_setup.shelly.js deleted file mode 100644 index 81dcd6e..0000000 --- a/the_pill/SDS011/sds011_setup.shelly.js +++ /dev/null @@ -1,219 +0,0 @@ -/** - * @title SDS011 virtual component setup - * @description Creates virtual components for displaying SDS011 readings. - * @status under development - * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/SDS011/sds011_setup.shelly.js - */ - -/** - * SDS011 Air Quality Sensor - Virtual Components Setup Script - * - * Creates the virtual components for SDS011 air quality monitoring UI. - * Run this script ONCE to set up the graphical interface. - * - * Components Created: - * - Group:200 - Air Quality group container - * - Number:200 - PM2.5 value display (ug/m3) - * - Number:201 - PM10 value display (ug/m3) - * - Text:200 - AQI category display - * - Button:200 - Wake/Sleep toggle - * - * After running, you can delete this script or disable it. - */ - -// ============================================================================ -// CONFIGURATION -// ============================================================================ - -var GROUP_ID = 200; -var GROUP_NAME = 'Air Quality'; - -var COMPONENTS = [ - { - type: 'group', - id: GROUP_ID, - config: { - name: GROUP_NAME - } - }, - { - type: 'number', - id: 200, - config: { - name: 'PM2.5', - min: 0, - max: 1000, - meta: { - ui: { - view: 'label', - unit: 'ug/m3', - icon: 'mdi:blur' - } - } - } - }, - { - type: 'number', - id: 201, - config: { - name: 'PM10', - min: 0, - max: 1000, - meta: { - ui: { - view: 'label', - unit: 'ug/m3', - icon: 'mdi:blur-linear' - } - } - } - }, - { - type: 'text', - id: 200, - config: { - name: 'AQI', - meta: { - ui: { - view: 'label', - icon: 'mdi:air-filter' - } - } - } - }, - { - type: 'button', - id: 200, - config: { - name: 'Wake/Sleep', - meta: { - ui: { - icon: 'mdi:power' - } - } - } - } -]; - -// Group members (component keys to add to the group) -var GROUP_MEMBERS = [ - 'number:200', - 'number:201', - 'text:200', - 'button:200' -]; - -// ============================================================================ -// STATE -// ============================================================================ - -var currentIndex = 0; -var createdCount = 0; -var skippedCount = 0; -var errorCount = 0; - -// ============================================================================ -// SETUP FUNCTIONS -// ============================================================================ - -function createComponent(comp, callback) { - var componentKey = comp.type + ':' + comp.id; - - // First check if component already exists - var status = Shelly.getComponentStatus(comp.type, comp.id); - if (status !== null) { - print('[SETUP] ' + componentKey + ' exists, skipping'); - skippedCount++; - if (callback) callback(true); - return; - } - - print('[SETUP] Creating ' + componentKey + '...'); - - Shelly.call( - 'Virtual.Add', - { - type: comp.type, - id: comp.id, - config: comp.config - }, - function(result, error_code, error_message) { - if (error_code !== 0) { - print('[SETUP] ERROR: ' + error_message); - errorCount++; - if (callback) callback(false); - } else { - print('[SETUP] Created ' + componentKey); - createdCount++; - if (callback) callback(true); - } - } - ); -} - -function createNextComponent() { - if (currentIndex >= COMPONENTS.length) { - // All components created, now set group members - Timer.set(300, false, setGroupMembers); - return; - } - - var comp = COMPONENTS[currentIndex]; - currentIndex++; - - createComponent(comp, function(success) { - Timer.set(200, false, createNextComponent); - }); -} - -function setGroupMembers() { - print('[SETUP] Setting group members...'); - - Shelly.call( - 'Group.Set', - { - id: GROUP_ID, - value: GROUP_MEMBERS - }, - function(result, error_code, error_message) { - if (error_code !== 0) { - print('[SETUP] ERROR setting group: ' + error_message); - errorCount++; - } else { - print('[SETUP] Group members set successfully'); - } - printSummary(); - } - ); -} - -function printSummary() { - print(''); - print('[SETUP] ========================================'); - print('[SETUP] SDS011 Setup Complete'); - print('[SETUP] ----------------------------------------'); - print('[SETUP] Created: ' + createdCount); - print('[SETUP] Skipped: ' + skippedCount); - print('[SETUP] Errors: ' + errorCount); - print('[SETUP] ========================================'); - - if (errorCount === 0) { - print('[SETUP] All components ready in group:' + GROUP_ID); - print('[SETUP] You can now run sds011_vc.shelly.js'); - } -} - -// ============================================================================ -// INITIALIZATION -// ============================================================================ - -function init() { - print(''); - print('[SETUP] SDS011 Air Quality - Virtual Components'); - print('[SETUP] Creating ' + COMPONENTS.length + ' components...'); - print(''); - - createNextComponent(); -} - -init(); diff --git a/the_pill/SDS011/sds011_vc.shelly.js b/the_pill/SDS011/sds011_vc.shelly.js deleted file mode 100644 index c63b045..0000000 --- a/the_pill/SDS011/sds011_vc.shelly.js +++ /dev/null @@ -1,309 +0,0 @@ -/** - * @title SDS011 virtual component UI - * @description UI-oriented SDS011 script that updates virtual components with PM and - * AQI data. - * @status under development - * @link https://github.com/ALLTERCO/shelly-script-examples/blob/main/the_pill/SDS011/sds011_vc.shelly.js - */ - -/** - * SDS011 Air Quality Sensor with Virtual Components UI - * - * Reads PM2.5/PM10 from SDS011 sensor and displays values on - * Shelly virtual components for graphical UI. - * - * Prerequisites: - * - Run sds011_setup.shelly.js once to create virtual components - * - * Hardware connection: - * - SDS011 TX (Pin 7) -> Shelly RX (GPIO) - * - SDS011 RX (Pin 6) -> Shelly TX (GPIO) - * - VCC (Pin 3) -> 5V - * - GND (Pin 5) -> GND - * - * Virtual Components Used: - * - number:200 - PM2.5 display - * - number:201 - PM10 display - * - text:200 - AQI category - * - button:200 - Wake/Sleep toggle - */ - -/* === CONFIG === */ -var CONFIG = { - // UART settings - BAUD_RATE: 9600, - - // Protocol constants - HEADER: 0xAA, - TAIL: 0xAB, - CMD_DATA: 0xC0, - CMD_REPLY: 0xC5, - CMD_HEADER: 0xB4, - - // Frame sizes - DATA_FRAME_SIZE: 10, - - // Virtual component IDs - VC_PM25: 200, - VC_PM10: 201, - VC_AQI: 200, - VC_BUTTON: 200, - - // Debug mode - DEBUG: true -}; - -/* === STATE === */ -var state = { - uart: null, - rxBuffer: [], - isReady: false, - isAwake: true, - lastPm25: null, - lastPm10: null, - deviceId: null -}; - -/* === HELPERS === */ - -function debug(msg) { - if (CONFIG.DEBUG) { - print("[SDS011-VC] " + msg); - } -} - -function calcDataChecksum(bytes) { - var sum = 0; - for (var i = 2; i < 8; i++) { - sum += bytes[i]; - } - return sum & 0xFF; -} - -function calcCmdChecksum(bytes) { - var sum = 0; - for (var i = 2; i < 17; i++) { - sum += bytes[i]; - } - return sum & 0xFF; -} - -function buildCommand(cmd, data, deviceId) { - var frame = []; - frame.push(CONFIG.HEADER); - frame.push(CONFIG.CMD_HEADER); - frame.push(cmd); - - for (var i = 0; i < 13; i++) { - frame.push(data && data[i] !== undefined ? data[i] : 0x00); - } - - var idLo = deviceId ? (deviceId & 0xFF) : 0xFF; - var idHi = deviceId ? ((deviceId >> 8) & 0xFF) : 0xFF; - frame.push(idLo); - frame.push(idHi); - frame.push(calcCmdChecksum(frame)); - frame.push(CONFIG.TAIL); - - return frame; -} - -function sendCommand(frame) { - var bytes = ""; - for (var i = 0; i < frame.length; i++) { - bytes += String.fromCharCode(frame[i]); - } - state.uart.write(bytes); -} - -/* === AQI CALCULATION === */ - -function getAqiCategory(pm25) { - if (pm25 <= 12.0) return "Good"; - if (pm25 <= 35.4) return "Moderate"; - if (pm25 <= 55.4) return "Unhealthy (Sensitive)"; - if (pm25 <= 150.4) return "Unhealthy"; - if (pm25 <= 250.4) return "Very Unhealthy"; - return "Hazardous"; -} - -/* === SENSOR CONTROL === */ - -function setSleep(awake) { - var data = [0x01, awake ? 0x01 : 0x00]; - var frame = buildCommand(0x06, data, state.deviceId); - sendCommand(frame); - state.isAwake = awake; - debug(awake ? "Sensor waking up..." : "Sensor going to sleep..."); -} - -function setMode(active) { - var data = [0x01, active ? 0x00 : 0x01]; - var frame = buildCommand(0x02, data, state.deviceId); - sendCommand(frame); -} - -/* === VIRTUAL COMPONENTS UPDATE === */ - -function updateVirtualComponents() { - // Update PM2.5 - if (state.lastPm25 !== null) { - Shelly.call("Number.Set", { - id: CONFIG.VC_PM25, - value: state.lastPm25 - }); - } - - // Update PM10 - if (state.lastPm10 !== null) { - Shelly.call("Number.Set", { - id: CONFIG.VC_PM10, - value: state.lastPm10 - }); - } - - // Update AQI text - if (state.lastPm25 !== null) { - var aqi = getAqiCategory(state.lastPm25); - Shelly.call("Text.Set", { - id: CONFIG.VC_AQI, - value: aqi - }); - } -} - -/* === UART HANDLER === */ - -function onUartReceive(data) { - for (var i = 0; i < data.length; i++) { - state.rxBuffer.push(data.charCodeAt(i)); - } - processRxBuffer(); -} - -function processRxBuffer() { - while (state.rxBuffer.length >= CONFIG.DATA_FRAME_SIZE) { - if (state.rxBuffer[0] !== CONFIG.HEADER) { - state.rxBuffer.shift(); - continue; - } - - if (state.rxBuffer.length < CONFIG.DATA_FRAME_SIZE) { - break; - } - - if (state.rxBuffer[9] !== CONFIG.TAIL) { - state.rxBuffer.shift(); - continue; - } - - var frame = state.rxBuffer.splice(0, CONFIG.DATA_FRAME_SIZE); - - var checksum = calcDataChecksum(frame); - if (checksum !== frame[8]) { - debug("Checksum error"); - continue; - } - - parseFrame(frame); - } -} - -function parseFrame(frame) { - var cmd = frame[1]; - - if (cmd === CONFIG.CMD_DATA) { - var pm25Raw = frame[2] | (frame[3] << 8); - var pm10Raw = frame[4] | (frame[5] << 8); - - state.lastPm25 = pm25Raw / 10.0; - state.lastPm10 = pm10Raw / 10.0; - state.deviceId = frame[6] | (frame[7] << 8); - - debug("PM2.5: " + state.lastPm25.toFixed(1) + " | PM10: " + state.lastPm10.toFixed(1)); - - // Update virtual components - updateVirtualComponents(); - - // Emit event - Shelly.emitEvent("air_quality", { - pm25: state.lastPm25, - pm10: state.lastPm10, - aqi: getAqiCategory(state.lastPm25) - }); - } -} - -/* === BUTTON HANDLER === */ - -function onButtonEvent(ev) { - if (ev.component !== "button:" + CONFIG.VC_BUTTON) return; - if (!ev.info || ev.info.event !== "single_push") return; - - // Toggle wake/sleep - if (state.isAwake) { - setSleep(false); - Shelly.call("Text.Set", { - id: CONFIG.VC_AQI, - value: "Sleeping..." - }); - } else { - setSleep(true); - Shelly.call("Text.Set", { - id: CONFIG.VC_AQI, - value: "Waking up..." - }); - } -} - -/* === INITIALIZATION === */ - -function init() { - print("SDS011 Air Quality Sensor with Virtual Components"); - print("================================================="); - - // Check virtual components exist - var pm25Status = Shelly.getComponentStatus("number", CONFIG.VC_PM25); - if (pm25Status === null) { - print("ERROR: Virtual components not found!"); - print("Run sds011_setup.shelly.js first to create components."); - return; - } - - // Initialize UART - state.uart = UART.get(); - if (!state.uart) { - print("ERROR: UART not available on this device"); - return; - } - - state.uart.configure({ - baud: CONFIG.BAUD_RATE, - mode: "8N1" - }); - - state.uart.recv(onUartReceive); - print("UART configured at " + CONFIG.BAUD_RATE + " baud"); - - // Register button handler - Shelly.addEventHandler(onButtonEvent); - - // Initialize UI - Shelly.call("Text.Set", { - id: CONFIG.VC_AQI, - value: "Initializing..." - }); - - // Wait for sensor to initialize - Timer.set(2000, false, function() { - state.isReady = true; - setMode(true); - print("Ready! Receiving air quality data..."); - Shelly.call("Text.Set", { - id: CONFIG.VC_AQI, - value: "Waiting for data..." - }); - }); -} - -init();