diff --git a/node_modules/request/node_modules/form-data/.editorconfig b/.editorconfig similarity index 69% rename from node_modules/request/node_modules/form-data/.editorconfig rename to .editorconfig index 0f099897..1cbe002b 100644 --- a/node_modules/request/node_modules/form-data/.editorconfig +++ b/.editorconfig @@ -1,10 +1,9 @@ -# editorconfig.org root = true [*] +charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true insert_final_newline = true +trim_trailing_whitespace = false diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..908f27e2 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,46 @@ +{ + "env": { + "node": true, + "es2021": true + }, + "rules": { + "brace-style": "off", + "camelcase": "off", + "consistent-return": "off", + "eqeqeq": "off", + "func-names": "off", + "global-require": "off", + "guard-for-in": "off", + "import/extensions": "off", + "max-len": 0, + "no-async-promise-executor": "off", + "no-cond-assign": "off", + "no-console": "off", + "no-const-assign": "off", + "no-constant-condition": "off", + "no-mixed-spaces-and-tabs": "off", + "no-new": "off", + "no-trailing-spaces": "off", + "no-restricted-syntax": "off", + "no-tabs": "off", + "no-undef": "off", + "comma-dangle": "off", + "no-unused-vars": "off", + "no-multi-spaces": "off", + "node/no-unsupported-features/node-builtins": "off", + "no-multiple-empty-lines": "off", + "padding-line-between-statements": "off", + "radix": "off", + "indent": "off", + "valid-typeof": "off", + "import/no-commonjs": "off", + "no-useless-concat": "off", + "linebreak-style": 0, + "object-curly-newline": "off", + "object-property-newline": "off", + "quote-props": "off", + "node/no-commonjs": "off", + "quotes": "off", + "prefer-template": "off" + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..b30b7d70 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +** Version of Homey ** +** Version of the Homewizard Homey app ** +** Version of the firmware of the device you are trying to add (Homewizard wifi dongle p1 must be 2.09) +** Confirm Local API has been enabled in Homewizard Energy app needed for discovery + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/workflows/homey-publish.yaml b/.github/workflows/homey-publish.yaml new file mode 100644 index 00000000..21a230c0 --- /dev/null +++ b/.github/workflows/homey-publish.yaml @@ -0,0 +1,23 @@ +name: Publish Homey App (old) + +on: + push: + branches: + - main # Replace with the branch you want to trigger the publish + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: Publish to Homey + uses: jtebbens/homey-app-publish@v1 + with: + HOMEY_CLI_TOKEN: ${{ secrets.HOMEY_CLI_TOKEN }} diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 00000000..b309709a --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,20 @@ +name: Publish Homey app +on: + workflow_dispatch: + +jobs: + main: + name: Publish Homey App + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Publish + uses: athombv/github-action-homey-app-publish@master + id: publish + with: + personal_access_token: ${{ secrets.HOMEY_CLI_TOKEN }} + + - name: URL + run: | + echo "Manage your app at ${{ steps.publish.outputs.url }}." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml new file mode 100644 index 00000000..34e57c6d --- /dev/null +++ b/.github/workflows/validate.yaml @@ -0,0 +1,46 @@ +--- +name: CI + +on: + pull_request: + types: [opened, reopened, synchronize] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + homey-validate: + runs-on: ubuntu-latest + name: Validate Homey App + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: 20.9.0 + cache: 'npm' + + - name: Install Homey CLI + run: npm ci --no-optional homey + - run: npm ci --include=optional sharp + + - name: Validate Homey App + run: npx homey app validate --level=publish + + lint-eslint: + name: eslint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.9.0 + cache: 'npm' + + - run: npm ci + - run: npm run lint-check diff --git a/.gitignore b/.gitignore index 40eb83a5..6be4ffb5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,243 +1,5 @@ *.DS_Store -.AppleDouble -.LSOverride +/node_modules -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -################# -## Eclipse -################# - -*.pydevproject -.project -.metadata -bin/ -tmp/ -*.tmp -*.bak -*.swp -*~.nib -local.properties -.classpath -.settings/ -.loadpath - -# External tool builders -.externalToolBuilders/ - -# Locally stored "Eclipse launch configurations" -*.launch - -# CDT-specific -.cproject - -# PDT-specific -.buildpath - - -################# -## Visual Studio -################# - -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. - -# User-specific files -*.suo -*.user -*.sln.docstates - -# Build results - -[Dd]ebug/ -[Rr]elease/ -x64/ -build/ -[Bb]in/ -[Oo]bj/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -*_i.c -*_p.c -*.ilk -*.meta -*.obj -*.pch -*.pdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.log -*.scc - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opensdf -*.sdf -*.cachefile - -# Visual Studio profiler -*.psess -*.vsp -*.vspx - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# NCrunch -*.ncrunch* -.*crunch*.local.xml - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.Publish.xml -*.pubxml -*.publishproj - -# NuGet Packages Directory -## TODO: If you have NuGet Package Restore enabled, uncomment the next line -#packages/ - -# Windows Azure Build Output -csx -*.build.csdef - -# Windows Store app package directory -AppPackages/ - -# Others -sql/ -*.Cache -ClientBin/ -[Ss]tyle[Cc]op.* -~$* -*~ -*.dbmdl -*.[Pp]ublish.xml -*.pfx -*.publishsettings - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file to a newer -# Visual Studio version. Backup files are not needed, because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm - -# SQL Server files -App_Data/*.mdf -App_Data/*.ldf - -############# -## Windows detritus -############# - -# Windows image file caches -Thumbs.db -ehthumbs.db - -# Folder config file -Desktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Mac crap -.DS_Store - - -############# -## Python -############# - -*.py[cod] - -# Packages -*.egg -*.egg-info -dist/ -build/ -eggs/ -parts/ -var/ -sdist/ -develop-eggs/ -.installed.cfg - -# Installer logs -pip-log.txt - -# Unit test / coverage reports -.coverage -.tox - -#Translations -*.mo - -#Mr Developer -.mr.developer.cfg +# Added by Homey CLI +/.homeybuild/ diff --git a/.homeychangelog.json b/.homeychangelog.json new file mode 100644 index 00000000..42760d62 --- /dev/null +++ b/.homeychangelog.json @@ -0,0 +1,959 @@ +{ + "2.0.8": { + "en": "Bug fix: setClass(solar)" + }, + "2.0.9": { + "en": "Bug fixes (memory)" + }, + "2.0.10": { + "en": "node-fetch increased timeout 20s" + }, + "2.0.11": { + "en": "Bug fixes on P1 dongle (removal of Current usage all phases -> duplicate)" + }, + "2.0.12": { + "en": "Bug fix attempt (customer request SDM230 & P1 dongle)" + }, + "2.0.13": { + "en": "Solar class fix for KWH module" + }, + "2.0.14": { + "en": "Bugfix SDM230 (Measure_power value empty)" + }, + "2.0.15": { + "en": "Manifest update" + }, + "2.0.16": { + "en": "Heatlink and Energylink icon update" + }, + "2.0.17": { + "en": "svg files size issue" + }, + "2.1.1": { + "en": "Energy socket support" + }, + "2.1.2": { + "en": "Images and icon" + }, + "2.1.3": { + "en": "Improved mdns discovery (avoid wrong devices)" + }, + "2.1.4": { + "en": "Thermometer and Humidity offset support" + }, + "2.1.5": { + "en": "Attempt to catch unhandled rejections" + }, + "2.1.6": { + "en": "async fixes" + }, + "2.1.7": { + "en": "measure_rain invalid check" + }, + "2.1.8": { + "en": "Energylink error catch fixes" + }, + "2.1.9": { + "en": "Adjusting timeouts polling for slow Homewizard legacy devices" + }, + "2.1.10": { + "en": "Homewizard Legacy polling to 20seconds and timeout 18seconds" + }, + "2.1.11": { + "en": "Energylink bug fix" + }, + "2.1.13": { + "en": "AbortController & FetchError catch error handling" + }, + "2.1.14": { + "en": "Rainmeter fix" + }, + "2.1.15": { + "en": "Typo fix for rainmeter" + }, + "2.1.16": { + "en": "Adjusted mdns condition check as it was matching the wrong devices" + }, + "2.1.17": { + "en": "Changed mdns discovery string for sdm230 and sdm630 to host = kwhmeter" + }, + "2.1.18": { + "en": "Conditional timeouts and code fixes" + }, + "2.1.19": { + "en": "Added precheck for Homewizard Legacy before attempting node-fetch connect" + }, + "2.1.20": { + "en": "Typo fix" + }, + "2.1.21": { + "en": "Code fix (removed async on json parse, didnt update the values)" + }, + "2.1.22": { + "en": "Potential workaround for fqdn based ip for homewizard legacy" + }, + "2.1.23": { + "en": "Fix for string to number convert (Homewizard port)" + }, + "2.1.25": { + "en": "Added Homewizard Energy - Watermeter support" + }, + "2.1.26": { + "en": "Fix unhandledRejection" + }, + "2.1.27": { + "en": "Replaced node-fetch with Axios for Homewizard Legacy as its easier for timeout issues." + }, + "2.1.28": { + "en": "Heatlink function error fix" + }, + "2.1.29": { + "en": "Adjusted error logging to show less details, added axios abort controller to enforce abort to pending session." + }, + "2.1.30": { + "en": "Homewizard context/preset bug fix" + }, + "2.1.31": { + "en": "Watermeter offset support to align with real value" + }, + "2.1.32": { + "en": "Updated kwh1 and kwh3 icons" + }, + "2.1.33": { + "en": "Updated icons for Homewizard legacy devices (credits to basvanderploeg)" + }, + "2.1.34": { + "en": "Energylink car support and Wifi signal strength value added to Energy Sockets" + }, + "2.1.35": { + "en": "Updated wifi ssid logo, added P1, watermeter, kwh1 & kwh3 wifi strength signal support" + }, + "2.1.36": { + "en": "Changed energy_socket to class socket (was sensor)." + }, + "2.1.37": { + "en": "Minor fixes on energy_socket adjustment" + }, + "2.1.38": { + "en": "Changed SDM230 to socket type to allow Solar based tracking (was sensor)" + }, + "2.1.39": { + "en": "Bug fix" + }, + "2.1.40": { + "en": "Modified solar flag to ignore socket type" + }, + "2.1.41": { + "en": "Force socket type to SDM230" + }, + "3.0.0": { + "en": "SDKv3 Support (in prep for Pro2023 release)" + }, + "3.0.1": { + "en": "Textual fix in app name (showed v3 at end)" + }, + "3.0.2": { + "en": "Code cleanup and bugfix" + }, + "3.0.3": { + "en": "Offset watermeter and thermometer fixed (callback is not a function error)" + }, + "3.0.4": { + "en": "P1 meter fix for returning power and for Sweden having different values from the P1 as aggregated meter." + }, + "3.0.5": { + "en": "Code cleanup" + }, + "3.0.6": { + "en": "Revert legacy homewizard connectivity back to Axios module, others stay node-fetch" + }, + "3.0.7": { + "en": "Preset bugfix (await problem in code)" + }, + "3.0.8": { + "en": "Some async/await changes" + }, + "3.1.0": { + "en": "New features P1 HW Energy firmware and bugfixes" + }, + "3.1.1": { + "en": "Peak/OffPeak trigger fixed" + }, + "3.1.2": { + "en": "Pre FW 4.x P1 dongle fix for T1 & T2 export meter values" + }, + "3.1.3": { + "en": "Added volt support P1 dongle (3phase types) and code change Homewizard legacy preset changes (rollback to avoid undefined error)" + }, + "3.1.4": { + "en": "Bug fix voltage. 3 decimal support for KwH P1 device" + }, + "3.1.5": { + "en": "Remove Amp 2 and 3 from Phase 1 P1 dongle" + }, + "3.1.6": { + "en": "Typo fix for Phase3 Volt value being empty" + }, + "3.1.7": { + "en": "Added custom import and export total kwh meters and triggers for better tracking energy usage" + }, + "3.2.0": { + "en": "Improved Heatlink" + }, + "3.2.1": { + "en": "Replaced axios with node-fetch and additional retry & abort code. Belgium P1 value added for monthly peak watt" + }, + "3.2.2": { + "en": "Text fix for new meter as it got the default measure_power" + }, + "3.2.3": { + "en": "Code fix for P1 voltage & amp. Attempt to get rid of callback calls" + }, + "3.2.4": { + "en": "Several bug fixes, see changelog" + }, + "3.2.5": { + "en": "Homewizard Sensor Driver fix SDK3 problem" + }, + "3.2.6": { + "en": "Removed retry code for legacy Homewizard (HW wifi chip cant handle extra connections)" + }, + "3.2.7": { + "en": "Increased polling cycle to 20s for HW Legacy" + }, + "3.2.8": { + "en": "30s poll and 28s timeout" + }, + "3.2.9": { + "en": "Removed retry fetch for HW Legacy. Heatlink added tapwater and updated icons." + }, + "3.2.10": { + "en": "Modifications to mDNS. Unhandled rejection code for Heatlink. Drivername changes." + }, + "3.2.11": { + "en": "mDNS bugfix on regex and product_type to find devices." + }, + "3.2.12": { + "en": "Energylink bug fix s2 for \"other\" or \"car\" type sources." + }, + "3.2.13": { + "en": "Windmeter fix (device not found)" + }, + "3.2.14": { + "en": "P1 added Power failures, voltage sags & swell counts" + }, + "3.2.15": { + "en": "Bug fix socket class for KWH3" + }, + "3.2.16": { + "en": "Added optional energy socket Watt compensation" + }, + "3.2.17": { + "en": "Controller error bug fix" + }, + "3.2.18": { + "en": "Wattcher bug fix and windmeter battery support" + }, + "3.2.19": { + "en": "Doorcontact 868 fix for Homewizard Legacy" + }, + "3.2.20": { + "en": "Windmeter battery code fix (when battery it needs to update it)" + }, + "3.2.21": { + "en": "Windmeter conditional fix (battery empty but shows still data)" + }, + "3.2.22": { + "en": "Rainmeter battery alarm added (Homewizard Legacy)" + }, + "3.2.24": { + "en": "P1 adjustment for Phase3 circuit. Offset watermeter import from Homewizard Energy when set." + }, + "3.2.25": { + "en": "Bugfix watermeter offset" + }, + "3.3.0": { + "en": "KWH Meters SDM230 & SDM630 added support for Voltage & Amp" + }, + "3.3.1": { + "en": "Bug fix SDM630 active_current_l1_a undefined" + }, + "3.3.2": { + "en": "Additional mDNS matching for kWh meters" + }, + "3.3.3": { + "en": "mDNS timing problem (async onDiscoveryAvailable)" + }, + "3.3.4": { + "en": "Attempt to improve mDNS functions with async calls" + }, + "3.3.5": { + "en": "Lowered CPU footprint (polling energy sockets to 10s)" + }, + "3.3.6": { + "en": "Attempt to get correct gasmeter from P1 when replaced unit, Voltage & Amp support for Norway" + }, + "3.3.7": { + "en": "Bugfix Capability remove (P1). " + }, + "3.3.8": { + "en": "Energylink Insight support (user request)" + }, + "3.3.9": { + "en": "Energylink added S2 solar capability" + }, + "3.3.10": { + "en": "Energylink name tags S2 (solar) added" + }, + "3.3.11": { + "en": "Rollback gasmeter code (old P1 firmware fails check)" + }, + "3.3.12": { + "en": "Energylink user request fix to force positive solar values from unit" + }, + "3.3.13": { + "en": "Other means of making a negative value positive from the Energylink T2" + }, + "3.3.14": { + "en": "Removed delayed push code for Energylink" + }, + "3.3.15": { + "en": "Added T3 meter to P1 (User request)" + }, + "3.3.16": { + "en": "Bugfix T3 meter, added to app.json file" + }, + "3.3.17": { + "en": "Added 60s timeout for Homewizard wifi devices due to bad user wifi coverage" + }, + "3.3.18": { + "en": "Added support to the Energy usage for Homey (Homey SDK)" + }, + "3.3.19": { + "en": "Remove node-fetch and use fetch from NodeJS" + }, + "3.3.20": { + "en": "Watermeter cumulative energy support" + }, + "3.3.21": { + "en": "Enabled Wifi RSSI (Insights)" + }, + "3.3.22": { + "en": "Pairing prompt to enable LOCAL API and warning for watermeter that needs USB power" + }, + "3.3.23": { + "en": "Update images and manifest to match HomeWizard branding" + }, + "3.3.24": { + "en": "Icon color adjustment" + }, + "3.3.25": { + "en": "BUGFIX SD230 device set as solar showing negative values" + }, + "3.3.26": { + "en": "Watermeter support for Belgium" + }, + "3.4.0": { + "en": "First attempt plugin battery Homewizard APIv2" + }, + "3.4.1": { + "en": "Images update, temp svg icon for battery" + }, + "3.4.2": { + "en": "Bug fixes and addition of extra metrics that were missed for plugin battery." + }, + "3.4.3": { + "en": "Icon update, pair text in readme as workaround" + }, + "3.4.4": { + "en": "Energy socket naming improved (serial added), voltage support added." + }, + "3.5.0": { + "en": "Conversion to homey-compose" + }, + "3.5.1": { + "en": "Socket identification (push button, led blink)" + }, + "3.5.2": { + "en": "SDM630 clone to allow P1-like tracking with SDM630." + }, + "3.5.3": { + "en": "Improved pairing process P1 APIv2 (DCSBL)" + }, + "3.5.4": { + "en": "APIv2 pairing process P1 and Plugin Battery aligned" + }, + "3.5.5": { + "en": "Text fix during plugin battery pairing process" + }, + "3.6.0": { + "en": "Massive code rework (credits to DCSBL for time and effort)" + }, + "3.6.1": { + "en": "Homey Energy dashhboard: Energylink meter_gas capability added" + }, + "3.6.2": { + "en": "Text fix PIB localization and an attempt to resolve a APIv2 pairing timer timeout problem" + }, + "3.6.3": { + "en": "Polling interval Energy devices lowered to 1s" + }, + "3.6.4": { + "en": "Reverted socket interval back 10s as this has an increased load on some wifi networks and (older) homeys" + }, + "3.6.5": { + "en": "Adjusted P1 polling for Homey Early2019 models" + }, + "3.6.6": { + "en": "Reverted interval back 10s as this has an increased load on some wifi networks and (older) homeys (Early2019)" + }, + "3.6.7": { + "en": "Update code for custom polling for P1 and sockets (Default is back to 10s)" + }, + "3.6.8": { + "en": "Bug fixes polling timers that suddenly stopped" + }, + "3.6.9": { + "en": "P1(APIv2) bug fixes" + }, + "3.6.10": { + "en": "P1(apiv2) aggregated total usage added (support for PowerByTheHour app)" + }, + "3.6.11": { + "en": "Custom polling added for SDM230, SDM630 and the SDM630-p1 mode. Default is 10s." + }, + "3.6.12": { + "en": "kwh meter insights added, custom polling watermeter (user requests)" + }, + "3.6.13": { + "en": "Bugfix firmware version check P1apiv2 for action cards" + }, + "3.6.14": { + "en": "Additional logging Plugin Battery Mode get/set. Wifi metric added for P1 and Battery" + }, + "3.6.15": { + "en": "RSSI capability fix for P1 and PluginBattery. Also added imported/exported capability for Battery" + }, + "3.6.16": { + "en": "Custom polling plugin battery added, default 10s" + }, + "3.6.17": { + "en": "emoved version check for battery mode, using API query to verify if data is there, only then condition and action cards should show." + }, + "3.6.18": { + "en": "Bugfix P1(apiv2) showing as unresponsive due to battery getMode query error." + }, + "3.6.19": { + "en": "Attempt to get condition and action flow card error register sorted" + }, + "3.6.20": { + "en": "Daily usage imported power and gas (P1apiv1) - User request" + }, + "3.6.21": { + "en": "P1apiv2 tariff fix." + }, + "3.6.22": { + "en": "ReferenceError: body is not defined (fix)" + }, + "3.6.23": { + "en": "Temporary store pair token in settings for troubleshooting" + }, + "3.6.24": { + "en": "Moved temp setting to oninit" + }, + "3.6.25": { + "en": "this.log dump" + }, + "3.6.26": { + "en": "redo oninit token P1" + }, + "3.6.27": { + "en": "Recreated condition and action cards via homeycompose" + }, + "3.6.28": { + "en": "Adjusted args (included device), added filter (P1apiv2)" + }, + "3.6.29": { + "en": "Added 3 static action cards for battery mode " + }, + "3.6.30": { + "en": "Typo fix actioncard ActionCardFullChargeMode" + }, + "3.6.31": { + "en": "Added extra log if url and token are there upon action card call" + }, + "3.6.32": { + "en": "Restructerd oninit. Cards are now registered." + }, + "3.6.33": { + "en": "Condition card updated (PIB), added extra battery group information to P1apiv2" + }, + "3.6.34": { + "en": "Plugin Battery: added time_to_empty and time_to_full (minutes)" + }, + "3.6.35": { + "en": "Bugfix Energy - Invalid Capability: meter_gas.daily" + }, + "3.6.36": { + "en": "Code split for daily tracking gas and energy. (bug fix)" + }, + "3.6.37": { + "en": "Resolved async function call that should not have been async at all." + }, + "3.6.38": { + "en": "Added trigger for battery mode change" + }, + "3.6.39": { + "en": "Cloud connection setting made available for P1, Sockets, Watermeter, SDM230, SDM630" + }, + "3.6.40": { + "en": "Offset watermeter fix" + }, + "3.6.41": { + "en": "Phase capacity added, adjust setting for 1 or 3 phases and their capacity in Amps" + }, + "3.6.42": { + "en": "Bugfix : ReferenceError: temp_current_phase2_load is not defined" + }, + "3.6.43": { + "en": "Bugfix for sliders when gridconnection has 3 phases" + }, + "3.6.44": { + "en": "Actual gas meter measurement added (5min poll pending on smartmeter)" + }, + "3.6.45": { + "en": "P1apiv1 code refactored" + }, + "3.6.46": { + "en": "Bugfix: batteryCapacityWh is not defined" + }, + "3.6.47": { + "en": "Extra plugin battery trigger cards (state change, time to full, time to empty)" + }, + "3.6.48": { + "en": "Bugfix: time_to_empty is not defined" + }, + "3.6.49": { + "en": "Removed sliders in GUI for P1 phases" + }, + "3.6.50": { + "en": "Removed sliders from Homeycompose" + }, + "3.6.51": { + "en": "Bugfix: added hasCapability test to avoid error upon untest removal capability" + }, + "3.6.52": { + "en": "Firmware 12.5.2RC3 issue with removal or hascapabilty checks. " + }, + "3.6.53": { + "en": "Attempt to fix reported user problem after firmware 12.5.2" + }, + "3.6.54": { + "en": "Another attempt to resolve user reported problem" + }, + "3.6.55": { + "en": "Typo fix for 3rd phase removal slider. Many const - let replacements in code (attempt to lower memory footprint)" + }, + "3.6.57": { + "en": "Code cleanup, Energyv2 tweak, Energy socket Battery tracking (imported/exported energy for dashboard)" + }, + "3.6.58": { + "en": "Const / let assignment error for plugin battery (fix)" + }, + "3.6.59": { + "en": "SDM230 (p1 mode), Daily usage kwh for APIv2 P1, Adjustment P1 for Norway specific P1's" + }, + "3.6.60": { + "en": "HTTP - keepalive agent added to P1, sockets, APIv2 devices" + }, + "3.6.61": { + "en": "Increase keepAlive default time from 1000ms to 15000 or 35000ms as polling cycle is more than 1sec." + }, + "3.6.62": { + "en": "AbortController added (apiv2) and Wifi quality capability" + }, + "3.6.63": { + "en": "Bugfix: P1, missed setAvailable(). Code didn’t recover from a failed P1 connection and kept P1 offline" + }, + "3.6.64": { + "en": "Fallback url for mDNS problems. Homewizard Legacy devices removed retry code, changed to keepAlive agent mode" + }, + "3.6.65": { + "en": "Battery Group data removed from P1 after a fetch fail (bugfix)" + }, + "3.6.66": { + "en": "APIv2 increased timeout authorization, Language update notification P1 warning phase overload" + }, + "3.6.67": { + "en": "Enforcing interval clears on various devices when interval is reset" + }, + "3.6.68": { + "en": "Finetuning polling and capability during init phase of various drivers" + }, + "3.6.69": { + "en": "Added more logging for diagnostic reports" + }, + "3.6.70": { + "en": "Bugfix SDM230 solar parameter was undefined" + }, + "3.6.71": { + "en": "Added an estimate charge available in plugin battery value" + }, + "3.6.72": { + "en": "More try/catch code to avoid any crashes on Homewizard Legacy main unit getStatus fail (Device not found)" + }, + "3.6.73": { + "en": "Code fixes: unhandledRejections CloudOn/Off for sockets and P1" + }, + "3.6.74": { + "en": "Homewizard Legacy - Thermometer recode, main unit adjustment (promises), finetuning keepAlive for other devices" + }, + "3.6.75": { + "en": "Added verbose mDNS discovery results for troubleshooting (apiv1)" + }, + "3.6.76": { + "en": "Custom polling-interval option made available for Homewizard Legacy main unit" + }, + "3.6.77": { + "en": "Fallback url for P1 mode SDM230 / SDM630" + }, + "3.6.78": { + "en": "Realtime data for P1 (apiv2) via Websocket" + }, + "3.6.79": { + "en": "Realtime data for Plugin Battery via Websocket / Bugfix P1apiv2 when gas is null" + }, + "3.6.80": { + "en": "Gas reading fix, websocket logic (reconnect) added" + }, + "3.6.81": { + "en": "Finetuning gas data via websockets" + }, + "3.6.82": { + "en": "Plugin Battery group fix (tracking combined set of batteries) - bugfix / Refenece error" + }, + "3.6.83": { + "en": "Various bugfixes (websocket) and added netfrequency capability to Plugin Battery and trigger for out of range" + }, + "3.6.84": { + "en": "Bugfix - WebSocket was closed before the connection was established" + }, + "3.6.85": { + "en": "Homewizard Legacy - code rollback (pairing problems after improvements)" + }, + "3.7.0": { + "en": "P1 (apiv2) - Added checkbox setting to fallback to polling if websocket is to heavy for Homey device" + }, + "3.7.1": { + "en": "Version bump to refresh package upload and potentially fix non working app comments on forum." + }, + "3.7.2": { + "en": "Extra check upon websocket creation to avoid crashes" + }, + "3.7.3": { + "en": "Plugin battery catch all error (unhandled exception)" + }, + "3.7.4": { + "en": "Additional checking and error handling on bad wifi connections (websocket based)" + }, + "3.7.5": { + "en": "Version bump (unknown install problem)" + }, + "3.7.6": { + "en": "Syntax error in P1 (websocket)" + }, + "3.7.7": { + "en": "Fetch was not defined for fetchWithTimeout" + }, + "3.7.8": { + "en": "net_frequence fix (3 decimals)" + }, + "3.7.9": { + "en": "Capability update fix (avoid removal check)" + }, + "3.8.0": { + "en": "Removed node-fetch for Homey 12.9.x (nodejs v22 - native fetch). Moved websocket functions to include to clean up P1 and plugin_battery code." + }, + "3.8.3": { + "en": "Force Homey firmware 12.9.0 version check" + }, + "3.8.4": { + "en": "Conditional require (node-fetch) it will try to use native fetch with a fallback to take the node-fetch module instead" + }, + "3.8.5": { + "en": "global.fetch not working for nodejs v12, adjusted code to cover this" + }, + "3.8.6": { + "en": "Fetch attempt" + }, + "3.8.7": { + "en": "After attempting conditional fetch, roll back to node-fetch until 12.9.x releases (Homey Pro 2016 - 2019)" + }, + "3.8.8": { + "en": "Bugfix: SDM230-p1mode - error during initialization" + }, + "3.8.9": { + "en": "Roll back energy dongle code v3.7.0" + }, + "3.8.10": { + "en": "Strange SD630 problem on older Homey's" + }, + "3.8.11": { + "en": "Extra verbose logging in urls to expose mDNS problems for older Homeys (url)" + }, + "3.8.12": { + "en": "Extra error handling (updateCapability) based on received crashreports" + }, + "3.8.13": { + "en": "Bugfix: ReferenceError: err is not defined (energy_socket)" + }, + "3.8.14": { + "en": "Updated APIv2 to add more text upon fetch failed" + }, + "3.8.15": { + "en": "Websocket based battery mode settings added (both condition and action)" + }, + "3.8.16": { + "en": "Websocket enhancements (heartbeat for plugin battery), P1 and Energy Socket agent tuning (ETIMEOUT and ECONNRESET)" + }, + "3.8.17": { + "en": "Bugfix: Failed to recreate agent: TypeError: Assignment to constant variable (energy)" + }, + "3.8.18": { + "en": "Adjustment to async/await code several drivers" + }, + "3.8.19": { + "en": "Websocket finetuning (energy_v2 and Plugin Battery), Centralized fetch queue for all fetch calls. Removed internal interval check" + }, + "3.8.20": { + "en": "WebsocketManager tuning" + }, + "3.8.21": { + "en": "Restore custom polling sockets (got removed by accident rollback)" + }, + "3.8.22": { + "en": "Additional watchdog code to reconnect energy_v2 and plugin_battery upon firmware up/downgrades" + }, + "3.9.0": { + "en": "New Plugin Battery mode support (zero_charge_only & zero_discharge_only) - dynamic tariff capabilities" + }, + "3.9.1": { + "en": "Checkbox show gasmeter (P1, apiv1 and apiv2). Belgium datapoint (avarage_power_15m_w) - P1apiv2" + }, + "3.9.2": { + "en": "Plugin Battery - Bugfix setMode for to_full (PUT)" + }, + "3.9.3": { + "en": "Bugfix - Updated P1apiv2 check-battery-mode condition card" + }, + "3.9.4": { + "en": "Backward compatibilty fix for the new battery mode applied to older P1 firmware." + }, + "3.9.5": { + "en": "Bugfix - Websocket payload battery mode adjustment" + }, + "3.9.6": { + "en": "Bugfixes condition and action cards for battery mode and permissions" + }, + "3.9.7": { + "en": "Fixed: rare crash when _handleBatteries() ran after a device was deleted" + }, + "3.9.8": { + "en": "New Feature: Baseload (sluipverbruik) detection (experimental)" + }, + "3.9.9": { + "en": "Bugfix: energy_socket connection_error capability fix" + }, + "3.9.10": { + "en": "Multiple bugfixes (energy_socket, energy_v2, sdm230_v2, pair token with new SHS" + }, + "3.9.11": { + "en": "Bugfix: APIv2 pairing" + }, + "3.9.12": { + "en": "Rollback random username to something easy" + }, + "3.9.13": { + "en": "Bugfix: APIv2 pairing -> local/homey_xxxxxx" + }, + "3.9.14": { + "en": "Bugfix: SDM630v2 trigger cards removed (obsolete as these are default Homey)" + }, + "3.9.15": { + "en": "Finetune: P1(apiv2) websocket + polling, capability updates" + }, + "3.9.16": { + "en": "Refractor code update for P1apiv1, SDM230, SDM630, watermeter. And Customizable phase overload warning + reset marker." + }, + "3.9.17": { + "en": "Phase 1 / 3 fix for P1(apiv1) after refractor code update" + }, + "3.9.18": { + "en": "Bugfix: Fallback url for SDM230v2 and P1apiv2 (mDNS fail workaround)" + }, + "3.9.19": { + "en": "Bugfix: pairing problem \"Cannot read properties of undefined (reading 'log')" + }, + "3.9.20": { + "en": "Bugfix: pairing problem apiv1 and apiv2" + }, + "3.9.21": { + "en": "Wsmanager optimize, custom polling Homewizard Legacy and fix Driver.js (this.log)" + }, + "3.9.22": { + "en": "Thermometer rollback (name index matching doesnt work as expected)" + }, + "3.9.23": { + "en": "Homewizard legacy -> node-fetch and not the fetchQueue utility (bad user experience feedback)" + }, + "3.9.24": { + "en": "Baseload (sluipverbruik) improvement (fridge/freezer should not be flagged as invalid )" + }, + "3.9.25": { + "en": "Homewizard app setting page with log or debug information for discovery, fetch failures, websocket problems and baseload samples" + }, + "3.9.26": { + "en": "Bugfix: Homewizard.poll (legacy unit)" + }, + "3.9.27": { + "en": "Homewizard Preset addition, Heatlink control improvement, Gasmeter fix (external source)" + }, + "3.9.28": { + "en": "Thermometer trigger and condition cards for no response X hours." + }, + "3.9.29": { + "en": "Improvement fetchQueue (protect against high cpu warning for devices on 1s polling)" + }, + "3.10.0": { + "en": "fetchQueue method dropped, using fetch directly so debug information can be shown in app settings tabs" + }, + "3.10.1": { + "en": "Watermeter daily usage added, Bugfix: Device Fetch Debug wasn't updating" + }, + "3.10.2": { + "en": "Bugfix: Circular Reference \"device\"" + }, + "3.10.3": { + "en": "Bugfix: SDM230(p1mode) - updateCapability missed" + }, + "3.10.4": { + "en": "Another Circular Reference \"device\" error fix" + }, + "3.10.5": { + "en": "Removed all retry/timeout code for fetch as it seems to lock up devices" + }, + "3.10.6": { + "en": "Cleanup device drivers with overcomplicated checks that ended up with polling deadlocks" + }, + "3.10.7": { + "en": "SDM230(p1mode) - Extra code handling for TIMEOUT issues" + }, + "3.10.8": { + "en": "SDM630 added per phase kwh meter tracking + daily kwh meter (estimate)" + }, + "3.10.9": { + "en": "More gas fix reset at night time (apiv1 and apiv2)" + }, + "3.10.10": { + "en": "Bugfix: incorrect daily reset during day of gas usage" + }, + "3.10.11": { + "en": "Cleanup first_run_logged key" + }, + "3.10.12": { + "en": "Bugfix: Energylink (watermeter) and Thermometer (battery)" + }, + "3.10.13": { + "en": "Rollback Daily gas reset for both P1 apiv1 and apiv2" + }, + "3.10.14": { + "en": "Energy P1 changed to modular code" + }, + "3.11.0": { + "en": "Modular code for P1 (both versions) & Heatlink set target_temperature fallback check" + }, + "3.11.1": { + "en": "P1, changed order of processing, eletric first then gas/water" + }, + "3.11.2": { + "en": "P1 energy tuning" + }, + "3.11.3": { + "en": "P1 missed call in onPoll interval to reset daily calculation" + }, + "3.11.4": { + "en": "Bugfix: P1 (apiv2) polling mode - Charge mode fixes & Extra log information on Group Battery State of Charge" + }, + "3.11.5": { + "en": "Bugfix: Group Battery State of Charge (increased timestamp check)" + }, + "3.11.6": { + "en": "Troubleshooting user plugin battery group" + }, + "3.11.7": { + "en": "Fallback Plugin battery Soc (api fetch)" + }, + "3.11.8": { + "en": "Fetch based group soc state" + }, + "3.11.9": { + "en": "Realtime pull from all batteries as fallback Battery Group State" + }, + "3.12.0": { + "en": "Baseload ignore return power. Plugin battery LED bridghtness. Websocket & cache improvements. " + }, + "3.12.1": { + "en": "Bug fix: Battery Group (SoC missed when there are fetch errors)" + }, + "3.12.2": { + "en": "Bug fix: Polling deadlock fix for (energy, energy_socket, SDM230, SDM630, watermeter)" + }, + "3.12.3": { + "en": "setAvailable fix for energy_sockets" + }, + "3.12.4": { + "en": "Bugfix: _cacheSet undefined" + }, + "3.12.5": { + "en": "P1 tuning for TIMEOUT and Unreachable problem" + }, + "3.12.6": { + "en": "Extra P1 logging" + }, + "3.12.7": { + "en": "Removed pollingActive check, unwanted side effect" + }, + "3.12.8": { + "en": "Plugin battery charge mode now selectable from UI" + }, + "3.12.9": { + "en": "Energy(apiv2) guard for add / remove \"battery_group_charge_mode\"" + }, + "3.13.0": { + "en": "Watermeter battery mode support (via hwenergy cloud)" + }, + "3.13.1": { + "en": "Update platform settings cloud and local" + }, + "3.13.2": { + "en": "platform update cloud and local" + }, + "3.13.3": { + "en": "Platform update cloud and local" + }, + "3.13.4": { + "en": "Process error account not allowed to publish cloud app" + }, + "3.13.5": { + "en": "Cloud_p1 (experimental release) and tariff trigger fix (energy_v2)" + }, + "3.13.6": { + "en": "Bugfix: capability_already_exists (cloud_p1)" + }, + "3.13.7": { + "en": "Addtional check on capability_already_exists error" + }, + "3.13.8": { + "en": "Plugin Battery state of charge icon added as tile for dashboard" + } +} diff --git a/.homeycompose/app.json b/.homeycompose/app.json new file mode 100644 index 00000000..1bae3a0e --- /dev/null +++ b/.homeycompose/app.json @@ -0,0 +1,74 @@ +{ + "id": "com.homewizard", + "name": { + "en": "HomeWizard" + }, + "version": "3.13.8", + "platforms": [ + "local" + ], + "sdk": 3, + "brandColor": "#2fc052", + "compatibility": ">=12.9.0", + "description": { + "en": "Helps you understand and save" + }, + "category": [ + "energy", + "appliances", + "climate" + ], + "images": { + "xlarge": "assets/images/xlarge.png", + "large": "assets/images/large.png", + "small": "assets/images/small.png" + }, + "author": { + "name": "Jeroen Tebbens", + "email": "jeroen@tebbens.net" + }, + "contributors": { + "developers": [ + { + "name": "Jeroen Bos", + "email": "jeroenbos22@gmail.com" + }, + { + "name": "Nick Bockmeulen", + "email": "git@bockmeulen.nl" + }, + { + "name": "Jeroen Tebbens", + "email": "jeroen@tebbens.net" + }, + { + "name": "Freddie Welvering", + "email": "freddie@welvering.eu" + }, + { + "name": "Emile Nijssen", + "email": "emile@emilenijssen.nl" + }, + { + "name": "Dennie de Groot", + "email": "mail@denniedegroot.nl" + } + ] + }, + "contributing": { + "donate": { + "paypal": { + "username": "jtebbens" + } + } + }, + "bugs": { + "url": "https://community.homey.app/t/app-pro-homewizard/19267" + }, + "source": "https://github.com/jtebbens/com.homewizard", + "homeyCommunityTopicId": 19267, + "support": "https://community.homey.app/t/app-pro-homewizard/19267", + "permissions": [ + "homey:manager:ledring" + ] +} \ No newline at end of file diff --git a/.homeycompose/capabilities/battery_group_average_soc.json b/.homeycompose/capabilities/battery_group_average_soc.json new file mode 100644 index 00000000..792611ca --- /dev/null +++ b/.homeycompose/capabilities/battery_group_average_soc.json @@ -0,0 +1,16 @@ +{ + "type": "number", + "title": { + "en": "Battery Group Average SoC", + "nl": "Batterij Groep Lading" + }, + "getable": true, + "setable": false, + "insights": true, + "uiComponent": "sensor", + "icon": "assets/battery.svg", + "units": { + "en": "%", + "nl": "%" + } +} diff --git a/.homeycompose/capabilities/battery_group_charge_mode.json b/.homeycompose/capabilities/battery_group_charge_mode.json new file mode 100644 index 00000000..75fc56bc --- /dev/null +++ b/.homeycompose/capabilities/battery_group_charge_mode.json @@ -0,0 +1,49 @@ +{ + "type": "enum", + "title": { + "en": "Battery Group Charge Mode", + "nl": "Battery Groep Oplaadmodus" + }, + "getable": true, + "setable": true, + "uiComponent": "picker", + "insights": true, + "icon": "assets/battery.svg", + "values": [ + { + "id": "zero", + "title": { + "en": "Zero (Net Zero)", + "nl": "Nul op de meter" + } + }, + { + "id": "zero_charge_only", + "title": { + "en": "Zero – Charge Only", + "nl": "NOM – Alleen laden" + } + }, + { + "id": "zero_discharge_only", + "title": { + "en": "Zero – Discharge Only", + "nl": "NOM – Alleen ontladen" + } + }, + { + "id": "standby", + "title": { + "en": "Standby", + "nl": "Stand‑by" + } + }, + { + "id": "to_full", + "title": { + "en": "Full Charge", + "nl": "Volledig laden" + } + } + ] +} diff --git a/.homeycompose/capabilities/battery_group_state.json b/.homeycompose/capabilities/battery_group_state.json new file mode 100644 index 00000000..527abe3c --- /dev/null +++ b/.homeycompose/capabilities/battery_group_state.json @@ -0,0 +1,12 @@ +{ + "type": "string", + "title": { + "en": "Battery Group Charge state", + "nl": "Battery Groep Laadt status" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "icon": "assets/battery.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/battery_group_total_capacity_kwh.json b/.homeycompose/capabilities/battery_group_total_capacity_kwh.json new file mode 100644 index 00000000..1dd62f96 --- /dev/null +++ b/.homeycompose/capabilities/battery_group_total_capacity_kwh.json @@ -0,0 +1,14 @@ +{ + "type": "number", + "title": { + "en": "Battery Group Total Capacity" + }, + "units": { + "en": "kWh" + }, + "getable": true, + "setable": false, + "insights": false, + "icon": "assets/battery.svg", + "uiComponent": "sensor" +} diff --git a/.homeycompose/capabilities/central_heating_flame.json b/.homeycompose/capabilities/central_heating_flame.json new file mode 100644 index 00000000..d0445376 --- /dev/null +++ b/.homeycompose/capabilities/central_heating_flame.json @@ -0,0 +1,12 @@ +{ + "type": "boolean", + "title": { + "en": "Central Heating Burner", + "nl": "CV brander" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/flame.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/central_heating_pump.json b/.homeycompose/capabilities/central_heating_pump.json new file mode 100644 index 00000000..bdeb6467 --- /dev/null +++ b/.homeycompose/capabilities/central_heating_pump.json @@ -0,0 +1,12 @@ +{ + "type": "boolean", + "title": { + "en": "Central Heating", + "nl": "Central Verwarming" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/central_heating.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/connection_error.json b/.homeycompose/capabilities/connection_error.json new file mode 100644 index 00000000..dac385dd --- /dev/null +++ b/.homeycompose/capabilities/connection_error.json @@ -0,0 +1,11 @@ +{ + "type": "string", + "title": { + "en": "Connection Error", + "nl": "Verbindingsfout" + }, + "getable": true, + "setable": false, + "insights": true, + "icon": "assets/icon.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/cycles.json b/.homeycompose/capabilities/cycles.json new file mode 100644 index 00000000..bbb7696f --- /dev/null +++ b/.homeycompose/capabilities/cycles.json @@ -0,0 +1,16 @@ +{ + "type": "number", + "title": { + "en": "Number of battery cycles", + "nl": "Aantal battery laadcycli" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "icon": "assets/cycles.svg", + "units": { + "en": "cycles", + "nl": "cycli" + } +} \ No newline at end of file diff --git a/.homeycompose/capabilities/estimate_kwh.json b/.homeycompose/capabilities/estimate_kwh.json new file mode 100644 index 00000000..6928543e --- /dev/null +++ b/.homeycompose/capabilities/estimate_kwh.json @@ -0,0 +1,17 @@ +{ +"type": "number", + "title": { + "en": "Estimate kWh in battery", + "nl": "Geschatte kWh in batterij" + }, + "getable": true, + "setable": true, + "insights": true, + "uiComponent": "sensor", + "icon": "assets/tariff.svg", + "units": { + "en": "kWh", + "nl": "kWh" + } +} + \ No newline at end of file diff --git a/.homeycompose/capabilities/identify.json b/.homeycompose/capabilities/identify.json new file mode 100644 index 00000000..1274398e --- /dev/null +++ b/.homeycompose/capabilities/identify.json @@ -0,0 +1,12 @@ +{ + "type": "boolean", + "title": { + "en": "Identify", + "nl": "Identificeren" + }, + "getable": false, + "setable": true, + "uiComponent": "button", + "insights": false, + "icon": "assets/magnify.svg" +} diff --git a/.homeycompose/capabilities/long_power_fail_count.json b/.homeycompose/capabilities/long_power_fail_count.json new file mode 100644 index 00000000..b6101a73 --- /dev/null +++ b/.homeycompose/capabilities/long_power_fail_count.json @@ -0,0 +1,12 @@ +{ + "type": "number", + "title": { + "en": "Power failures", + "nl": "Stroomstoringen" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/measure_gas.json b/.homeycompose/capabilities/measure_gas.json new file mode 100644 index 00000000..4a7ef24a --- /dev/null +++ b/.homeycompose/capabilities/measure_gas.json @@ -0,0 +1,17 @@ +{ + "type": "number", + "title": { + "en": "Current gas usage", + "nl": "Huidig gasverbruik" + }, + "getable": true, + "setable": false, + "decimals": 3, + "uiComponent": "sensor", + "insights": true, + "units": { + "en": "m3", + "nl": "m3" + }, + "icon": "assets/icon.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/net_load_phase1.json b/.homeycompose/capabilities/net_load_phase1.json new file mode 100644 index 00000000..5e016cf5 --- /dev/null +++ b/.homeycompose/capabilities/net_load_phase1.json @@ -0,0 +1,29 @@ +{ + "type": "number", + "title": { + "en": "Phase 1 load", + "nl": "Fase 1 belasting" + }, + "titleFormatted": { + "en": "Phase 1 load", + "nl": "Fase 1 belasting" + }, + "desc" : { + "en": "Phase 1 load", + "nl": "Fase 1 belasting" + }, + "min": 0, + "max": 100, + "step": 1, + "decimals": 0, + "chartType": "stepLine", + "getable": true, + "setable": false, + "uiComponent": "slider", + "insights": true, + "units": { + "en": "A", + "nl": "A" + }, + "icon": "assets/icon.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/net_load_phase1_pct.json b/.homeycompose/capabilities/net_load_phase1_pct.json new file mode 100644 index 00000000..a1fef216 --- /dev/null +++ b/.homeycompose/capabilities/net_load_phase1_pct.json @@ -0,0 +1,25 @@ +{ + "type": "number", + "title": { + "en": "Phase 1 load", + "nl": "Fase 1 belasting" + }, + "titleFormatted": { + "en": "Phase 1 load", + "nl": "Fase 1 belasting" + }, + "desc" : { + "en": "Phase 1 load", + "nl": "Fase 1 belasting" + }, + "decimals": 0, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "units": { + "en": "%", + "nl": "%" + }, + "icon": "assets/icon.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/net_load_phase2.json b/.homeycompose/capabilities/net_load_phase2.json new file mode 100644 index 00000000..71f18c3c --- /dev/null +++ b/.homeycompose/capabilities/net_load_phase2.json @@ -0,0 +1,29 @@ +{ + "type": "number", + "title": { + "en": "Phase 2 load", + "nl": "Fase 2 belasting" + }, + "titleFormatted": { + "en": "Phase 2 load", + "nl": "Fase 2 belasting" + }, + "desc" : { + "en": "Phase 2 load", + "nl": "Fase 2 belasting" + }, + "min": 0, + "max": 100, + "step": 1, + "decimals": 0, + "chartType": "stepLine", + "getable": true, + "setable": false, + "uiComponent": "slider", + "insights": true, + "units": { + "en": "A", + "nl": "A" + }, + "icon": "assets/icon.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/net_load_phase2_pct.json b/.homeycompose/capabilities/net_load_phase2_pct.json new file mode 100644 index 00000000..c62bc997 --- /dev/null +++ b/.homeycompose/capabilities/net_load_phase2_pct.json @@ -0,0 +1,25 @@ +{ + "type": "number", + "title": { + "en": "Phase 2 load", + "nl": "Fase 2 belasting" + }, + "titleFormatted": { + "en": "Phase 2 load", + "nl": "Fase 2 belasting" + }, + "desc" : { + "en": "Phase 2 load", + "nl": "Fase 2 belasting" + }, + "decimals": 0, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "units": { + "en": "%", + "nl": "%" + }, + "icon": "assets/icon.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/net_load_phase3.json b/.homeycompose/capabilities/net_load_phase3.json new file mode 100644 index 00000000..2c18da19 --- /dev/null +++ b/.homeycompose/capabilities/net_load_phase3.json @@ -0,0 +1,29 @@ +{ + "type": "number", + "title": { + "en": "Phase 3 load", + "nl": "Fase 3 belasting" + }, + "titleFormatted": { + "en": "Phase 3 load", + "nl": "Fase 3 belasting" + }, + "desc" : { + "en": "Phase 3 load", + "nl": "Fase 3 belasting" + }, + "min": 0, + "max": 100, + "step": 1, + "decimals": 0, + "chartType": "stepLine", + "getable": true, + "setable": false, + "uiComponent": "slider", + "insights": true, + "units": { + "en": "A", + "nl": "A" + }, + "icon": "assets/icon.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/net_load_phase3_pct.json b/.homeycompose/capabilities/net_load_phase3_pct.json new file mode 100644 index 00000000..c8ef02cb --- /dev/null +++ b/.homeycompose/capabilities/net_load_phase3_pct.json @@ -0,0 +1,25 @@ +{ + "type": "number", + "title": { + "en": "Phase 3 load", + "nl": "Fase 3 belasting" + }, + "titleFormatted": { + "en": "Phase 3 load", + "nl": "Fase 3 belasting" + }, + "desc" : { + "en": "Phase 3 load", + "nl": "Fase 3 belasting" + }, + "decimals": 0, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "units": { + "en": "%", + "nl": "%" + }, + "icon": "assets/icon.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/preset.json b/.homeycompose/capabilities/preset.json new file mode 100644 index 00000000..ac2849a7 --- /dev/null +++ b/.homeycompose/capabilities/preset.json @@ -0,0 +1,16 @@ +{ + "type": "enum", + "title": { + "en": "Preset", + "nl": "Preset" + }, + "getable": true, + "setable": true, + "uiComponent": "picker", + "values": [ + { "id": "0", "title": { "nl": "Thuis", "en": "Home" } }, + { "id": "1", "title": { "nl": "Weg", "en": "Away" } }, + { "id": "2", "title": { "nl": "Slapen", "en": "Sleep" } }, + { "id": "3", "title": { "nl": "Vakantie", "en": "Holiday" } } + ] +} diff --git a/.homeycompose/capabilities/rssi.json b/.homeycompose/capabilities/rssi.json new file mode 100644 index 00000000..31c64e23 --- /dev/null +++ b/.homeycompose/capabilities/rssi.json @@ -0,0 +1,16 @@ +{ + "type": "number", + "title": { + "en": "WiFi Signal", + "nl": "WiFi signaal" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "icon": "assets/rssi.svg", + "units": { + "en": "RSSI", + "nl": "RSSI" + } +} \ No newline at end of file diff --git a/.homeycompose/capabilities/tariff.json b/.homeycompose/capabilities/tariff.json new file mode 100644 index 00000000..d043682f --- /dev/null +++ b/.homeycompose/capabilities/tariff.json @@ -0,0 +1,16 @@ +{ + "type": "number", + "title": { + "en": "Active tariff", + "nl": "Tarief actief" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/tariff.svg", + "units": { + "en": "Tariff", + "nl": "Tarief" + } +} \ No newline at end of file diff --git a/.homeycompose/capabilities/time_to_empty.json b/.homeycompose/capabilities/time_to_empty.json new file mode 100644 index 00000000..ed7519e7 --- /dev/null +++ b/.homeycompose/capabilities/time_to_empty.json @@ -0,0 +1,16 @@ +{ + "type": "number", + "title": { + "en": "Time until discharged", + "nl": "Tijd tot ontladen" + }, + "getable": true, + "setable": false, + "insights": true, + "uiComponent": "sensor", + "icon": "assets/clock.svg", + "units": { + "en": "min", + "nl": "min" + } + } \ No newline at end of file diff --git a/.homeycompose/capabilities/time_to_full.json b/.homeycompose/capabilities/time_to_full.json new file mode 100644 index 00000000..b8006ff3 --- /dev/null +++ b/.homeycompose/capabilities/time_to_full.json @@ -0,0 +1,16 @@ +{ + "type": "number", + "title": { + "en": "Time until full charge", + "nl": "Tijd tot vol geladen" + }, + "getable": true, + "setable": false, + "insights": true, + "uiComponent": "sensor", + "icon": "assets/clock.svg", + "units": { + "en": "min", + "nl": "min" + } +} \ No newline at end of file diff --git a/.homeycompose/capabilities/voltage_sag_l1.json b/.homeycompose/capabilities/voltage_sag_l1.json new file mode 100644 index 00000000..b62cb606 --- /dev/null +++ b/.homeycompose/capabilities/voltage_sag_l1.json @@ -0,0 +1,12 @@ +{ + "type": "number", + "title": { + "en": "Net dip L1", + "nl": "Net dip L1" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/voltage_sag_l2.json b/.homeycompose/capabilities/voltage_sag_l2.json new file mode 100644 index 00000000..edb7d8d3 --- /dev/null +++ b/.homeycompose/capabilities/voltage_sag_l2.json @@ -0,0 +1,12 @@ +{ + "type": "number", + "title": { + "en": "Net dip L2", + "nl": "Net dip L2" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/voltage_sag_l3.json b/.homeycompose/capabilities/voltage_sag_l3.json new file mode 100644 index 00000000..bc0cbf66 --- /dev/null +++ b/.homeycompose/capabilities/voltage_sag_l3.json @@ -0,0 +1,12 @@ +{ + "type": "number", + "title": { + "en": "Net dip L3", + "nl": "Net dip L3" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/voltage_swell_l1.json b/.homeycompose/capabilities/voltage_swell_l1.json new file mode 100644 index 00000000..57ca3128 --- /dev/null +++ b/.homeycompose/capabilities/voltage_swell_l1.json @@ -0,0 +1,12 @@ +{ + "type": "number", + "title": { + "en": "Net peak L1", + "nl": "Net piek L1" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/voltage_swell_l2.json b/.homeycompose/capabilities/voltage_swell_l2.json new file mode 100644 index 00000000..a0c6a600 --- /dev/null +++ b/.homeycompose/capabilities/voltage_swell_l2.json @@ -0,0 +1,12 @@ +{ + "type": "number", + "title": { + "en": "Net peak L2", + "nl": "Net piek L2" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/voltage_swell_l3.json b/.homeycompose/capabilities/voltage_swell_l3.json new file mode 100644 index 00000000..8a8bef64 --- /dev/null +++ b/.homeycompose/capabilities/voltage_swell_l3.json @@ -0,0 +1,12 @@ +{ + "type": "number", + "title": { + "en": "Net peak L3", + "nl": "Net piek L3" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/warm_water.json b/.homeycompose/capabilities/warm_water.json new file mode 100644 index 00000000..69ef370e --- /dev/null +++ b/.homeycompose/capabilities/warm_water.json @@ -0,0 +1,12 @@ +{ + "type": "boolean", + "title": { + "en": "Warm water", + "nl": "Warm water" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/shower.svg" +} \ No newline at end of file diff --git a/.homeycompose/capabilities/wifi_quality.json b/.homeycompose/capabilities/wifi_quality.json new file mode 100644 index 00000000..50eacad0 --- /dev/null +++ b/.homeycompose/capabilities/wifi_quality.json @@ -0,0 +1,12 @@ +{ + "type": "string", + "title": { + "en": "WiFi State", + "nl": "WiFi Status" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "icon": "assets/rssi.svg" +} \ No newline at end of file diff --git a/.homeycompose/discovery/SDM230.json b/.homeycompose/discovery/SDM230.json new file mode 100644 index 00000000..4ac8ee72 --- /dev/null +++ b/.homeycompose/discovery/SDM230.json @@ -0,0 +1,28 @@ +{ + "type": "mdns-sd", + "mdns-sd": { + "name": "hwenergy", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "SDM230-wifi" + } + } + ], + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-KWH1" + } + } + ] + ] +} \ No newline at end of file diff --git a/.homeycompose/discovery/SDM230_v2.json b/.homeycompose/discovery/SDM230_v2.json new file mode 100644 index 00000000..b0e8da21 --- /dev/null +++ b/.homeycompose/discovery/SDM230_v2.json @@ -0,0 +1,28 @@ +{ + "type": "mdns-sd", + "mdns-sd": { + "name": "homewizard", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "SDM230-wifi" + } + } + ], + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-KWH1" + } + } + ] + ] +} \ No newline at end of file diff --git a/.homeycompose/discovery/SDM630.json b/.homeycompose/discovery/SDM630.json new file mode 100644 index 00000000..32578702 --- /dev/null +++ b/.homeycompose/discovery/SDM630.json @@ -0,0 +1,28 @@ +{ + "type": "mdns-sd", + "mdns-sd": { + "name": "hwenergy", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "SDM630-wifi" + } + } + ], + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-KWH3" + } + } + ] + ] +} \ No newline at end of file diff --git a/.homeycompose/discovery/SDM630_v2.json b/.homeycompose/discovery/SDM630_v2.json new file mode 100644 index 00000000..35160dd1 --- /dev/null +++ b/.homeycompose/discovery/SDM630_v2.json @@ -0,0 +1,28 @@ +{ + "type": "mdns-sd", + "mdns-sd": { + "name": "homewizard", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "SDM630-wifi" + } + } + ], + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-KWH3" + } + } + ] + ] +} \ No newline at end of file diff --git a/.homeycompose/discovery/energy.json b/.homeycompose/discovery/energy.json new file mode 100644 index 00000000..7672c2a0 --- /dev/null +++ b/.homeycompose/discovery/energy.json @@ -0,0 +1,26 @@ +{ + "type": "mdns-sd", + "mdns-sd": { + "name": "hwenergy", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "host", + "match": { + "type": "regex", + "value": "^p1meter-" + } + }, + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-P1" + } + } + ] + ] +} \ No newline at end of file diff --git a/.homeycompose/discovery/energy_socket.json b/.homeycompose/discovery/energy_socket.json new file mode 100644 index 00000000..3f211c65 --- /dev/null +++ b/.homeycompose/discovery/energy_socket.json @@ -0,0 +1,26 @@ +{ + "type": "mdns-sd", + "mdns-sd": { + "name": "hwenergy", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "host", + "match": { + "type": "regex", + "value": "^energysocket-" + } + }, + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-SKT" + } + } + ] + ] +} \ No newline at end of file diff --git a/.homeycompose/discovery/energy_v2.json b/.homeycompose/discovery/energy_v2.json new file mode 100644 index 00000000..288793fb --- /dev/null +++ b/.homeycompose/discovery/energy_v2.json @@ -0,0 +1,26 @@ +{ + "type": "mdns-sd", + "mdns-sd": { + "name": "homewizard", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "host", + "match": { + "type": "regex", + "value": "^p1meter-" + } + }, + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-P1" + } + } + ] + ] +} \ No newline at end of file diff --git a/.homeycompose/discovery/plugin_battery.json b/.homeycompose/discovery/plugin_battery.json new file mode 100644 index 00000000..747082bf --- /dev/null +++ b/.homeycompose/discovery/plugin_battery.json @@ -0,0 +1,26 @@ +{ + "type": "mdns-sd", + "mdns-sd": { + "name": "homewizard", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "host", + "match": { + "type": "regex", + "value": "^battery-" + } + }, + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-BAT" + } + } + ] + ] +} \ No newline at end of file diff --git a/.homeycompose/discovery/watermeter.json b/.homeycompose/discovery/watermeter.json new file mode 100644 index 00000000..9503ebdc --- /dev/null +++ b/.homeycompose/discovery/watermeter.json @@ -0,0 +1,26 @@ +{ + "type": "mdns-sd", + "mdns-sd": { + "name": "hwenergy", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "host", + "match": { + "type": "regex", + "value": "^watermeter-" + } + }, + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-WTR" + } + } + ] + ] +} \ No newline at end of file diff --git a/.homeycompose/flow/conditions/check-battery-mode.json b/.homeycompose/flow/conditions/check-battery-mode.json new file mode 100644 index 00000000..40167ee9 --- /dev/null +++ b/.homeycompose/flow/conditions/check-battery-mode.json @@ -0,0 +1,58 @@ +{ + "title": { + "en": "Check Battery Mode", + "nl": "Controleer batterij Modus" + }, + "titleFormatted": { + "en": "Check battery mode !{{is|isn't}} [[mode]]", + "nl": "Controleer batterij modus !{{is|isn't}} [[mode]]" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy_v2" + }, + { + "type": "dropdown", + "name": "mode", + "values": [ + { + "id": "zero", + "label": { + "en": "Zero mode", + "nl": "Null op de meter" + } + }, + { + "id": "to_full", + "label": { + "en": "Full Charge", + "nl": "Volledig laden" + } + }, + { + "id": "standby", + "label": { + "en": "Standby", + "nl": "Standby" + } + }, + { + "id": "zero_charge_only", + "label": { + "en": "Zero mode, Charge Only", + "nl": "Nul op de Meter, Alleen laden" + } + }, + { + "id": "zero_discharge_only", + "label": { + "en": "Zero mode, Discharge Only", + "nl": "Nul op de Meter, Alleen ontladen" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/.homeycompose/flow/triggers/leak_changed.json b/.homeycompose/flow/triggers/leak_changed.json new file mode 100644 index 00000000..18609dd3 --- /dev/null +++ b/.homeycompose/flow/triggers/leak_changed.json @@ -0,0 +1,29 @@ +{ + "id": "leak_changed", + "title": { + "en": "Water leakage", + "nl": "Water lekkage" + }, + "args": [ + { + "name": "Kakusensor", + "type": "device", + "filter": "driver_id=kakusensor", + "placeholder": { + "en": "Which Sensor", + "nl": "Welke Sensor" + } + } + ], + "tokens": [ + { + "name": "leak_changed", + "type": "number", + "title": { + "en": "mm", + "nl": "mm" + }, + "example": 15 + } + ] +} \ No newline at end of file diff --git a/.homeyignore b/.homeyignore new file mode 100644 index 00000000..11536f2f --- /dev/null +++ b/.homeyignore @@ -0,0 +1 @@ +hwconfig.exe.zip \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..af2b3669 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +--- +repos: + - repo: local + hooks: + - id: linter + name: Linter + pass_filenames: False + language: system + entry: npm run lint + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: no-commit-to-branch + name: Check if commit is not in branch 'master' + args: + - --branch=main # For future us + - --branch=master + + - id: end-of-file-fixer + - id: check-json + - id: check-yaml + - id: check-added-large-files + - id: check-case-conflict + - id: trailing-whitespace + - id: mixed-line-ending diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..8f15ca29 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "overrides": [ + { + "files": ["*.html", "**/*.js"], + "options": { + "semi": true, + "singleQuote": true, + "tabWidth": 4, + "printWidth": 200, + "trailingComma": "none" + } + } + ] + } + \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..e093ef8b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to Homey (Host)", + "type": "node", + "request": "attach", + "address": "192.168.1.12", + "port": 9225, + "localRoot": "${workspaceFolder}", + "remoteRoot": "${workspaceFolder}", + "restart": true, + "skipFiles": ["/**"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..72dad011 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "eslint.workingDirectories": [{ "mode": "auto" }], + "eslint.validate": ["javascript"], + "eslint.options": { + "overrideConfig": { + "rules": { + "node/no-commonjs": "off", + "import/no-commonjs": "off", + "no-useless-concat": "off", + "prefer-template": "off" + } + } + }, + "javascript.validate.enable": false, + "typescript.validate.enable": false, + "typescript.disableAutomaticTypeAcquisition": true, + "eslint.run": "onSave" +} diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 00000000..d7625e4a --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,70 @@ +# Credits and Acknowledgments + +## HomeWizard Homey App + +### Current Maintainer + +- **Jeroen Tebbens** ([@jtebbens](https://github.com/jtebbens)) - Current maintainer and lead developer + +### Contributors + +- **Jeroen Bos** - Contributor +- **Freddie Welvering** - Contributor +- **Emile Nijssen** - Contributor +- **Dennie de Groot** - Contributor +- **Sven Serlier** - Contributor + +### Cloud Integration + +The cloud-based device support (drivers/cloud-p1) is based on research and API documentation by: + +- **Sven Serlier** ([@smarthomesven](https://github.com/smarthomesven)) +- Repository: [homey-homewizard-energy-cloud](https://github.com/smarthomesven/homey-homewizard-energy-cloud) +- Implementation: HomeWizard Cloud API reverse engineering and documentation + +### Community Support + +Special thanks to: + +- **DCSBL** - Code improvements and homey-compose migration +- All users who reported issues and provided feedback +- The Homey community for testing and support + +## Third-Party Components + +### Dependencies + +- **homey-api** - Athom B.V. +- **ws** - WebSocket library (MIT License) +- [Other dependencies from package.json] + +## License + +This application is licensed under the GNU General Public License v3.0 (GPL-3.0). + +See [LICENSE](LICENSE) for the full license text. + +### What this means + +- ✅ You can use this app freely +- ✅ You can modify the code +- ✅ You can distribute modified versions +- ⚠️ Modified versions must also be GPL-3.0 +- ⚠️ You must include copyright notices +- ⚠️ You must provide source code access + +## HomeWizard + +This is an unofficial integration for HomeWizard devices. + +- **HomeWizard** is a trademark of HomeWizard B.V. +- This app is not affiliated with, endorsed by, or sponsored by HomeWizard B.V. +- Official HomeWizard products and services: https://www.homewizard.com + +## Disclaimer + +This software is provided "as is", without warranty of any kind, express or implied. Use at your own risk. + +--- + +**Last Updated:** February 2026 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 00000000..2dd92316 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,309 @@ +# HomeWizard Development Context + +## Architecture Overview + +### Driver Categories + +**WebSocket-Based (Real-Time, Low Latency):** +- `energy_v2` - P1 meter with API v2 (WebSocket preferred, polling fallback) +- `plugin_battery` - Battery system via WebSocket (real-time power updates) +- Communication: WSS connection to device, ~2 second measurement intervals +- Manager: `includes/v2/Ws.js` - Handles connection, authorization, reconnection logic + +**HTTP Polling-Based (API v1 & v2):** +- `energy` - P1 meter classic API (10s polling default, configurable) +- `SDM230`, `SDM230_v2`, `SDM230-p1mode` - 3-phase kWh meters +- `SDM630`, `SDM630_v2`, `SDM630-p1mode` - 3-phase kWh meters (industrial grade) +- `energy_socket` - Smart socket with power monitoring +- `watermeter` - Water consumption tracking +- Communication: HTTP REST with keep-alive agents, configurable intervals +- Manager: `includes/v2/Api.js` - Centralized fetch with timeout handling + +**Legacy Gateway-Based (Proxy via Main Hub):** +- `thermometer` - Temperature sensors (poor WiFi) +- `heatlink` - Heating control (poor WiFi) +- `rainmeter` - Rain detection (poor WiFi) +- `windmeter` - Wind speed (poor WiFi) +- `kakusensors` - Various sensors (poor WiFi) +- `energylink` - Energy gateway (poor WiFi) +- Communication: HTTP polling to main hub unit which proxies requests +- Manager: `includes/legacy/homewizard.js` - Adaptive polling with backoff + +--- + +## Key File Locations + +| File | Purpose | Key Functions | +|------|---------|---| +| `app.js` | App entry point, lifecycle | Flow cards, baseload monitor init | +| `includes/v2/Ws.js` | WebSocket manager | Connection, auth, reconnect, message buffering | +| `includes/v2/Api.js` | HTTP utilities | fetchWithTimeout, fetch queue | +| `includes/legacy/homewizard.js` | Legacy gateway | Adaptive polling, device management, retry logic | +| `includes/utils/baseloadMonitor.js` | Baseload (sluipverbruik) tracker | Night analysis, fridge detection, oscillation checks | +| `includes/utils/fetchQueue.js` | Fetch rate limiter | Prevents CPU spikes from polling | +| `drivers/energy_v2/device.js` | P1 APIv2 driver | WebSocket + polling hybrid, battery handling | +| `drivers/energy/device.js` | P1 APIv1 driver | Polling-based, gas/water processing | +| `drivers/plugin_battery/device.js` | Battery driver | WebSocket real-time, polling fallback | + +--- + +## Communication Patterns + +### WebSocket Flow (energy_v2, plugin_battery) +``` +1. Start → Preflight check (GET /api/system) +2. Connect → WebSocket upgrade to WSS +3. Authorize → Send token via message +4. Subscribe → Request system, measurement, batteries topics +5. Receive → Messages buffered, flushed every 2-10 seconds +6. Reconnect → Exponential backoff on disconnect (max 30 attempts) +7. Watchdog → Ping every 30s, detect stale connections (190s timeout) +``` + +### HTTP Polling Flow (energy, SDM230/630, energy_socket, watermeter) +``` +1. OnInit → Create keep-alive HTTP agent, start polling interval +2. OnPoll → Fetch from device /data endpoint +3. Parse → JSON parse, validate structure +4. Update → updateCapability() with Promise.allSettled() +5. Retry → Exponential backoff on 5xx errors +6. OnDeleted → Clear interval, destroy agent, flush debug logs +``` + +### Legacy Gateway Flow (thermometer, heatlink, etc.) +``` +1. RegisterDevice → Added to homewizard.devices map +2. StartPoll → Set interval with adaptive timeout +3. Call → homewizard.callnew() with abort controller +4. Timeout → getAdaptiveTimeout() based on response history +5. Retry → Backoff on failure, record response times +6. OnDeleted → homewizard.removeDevice() clears all references +``` + +--- + +## Known Issues & Workarounds + +### WebSocket Specific +- **Reconnection spam:** Reduced debug logs during frequent reconnects +- **Authorization timeout:** Preflight check now validates device reachability first +- **Listener duplication:** Must call `removeAllListeners()` before re-attaching handlers +- **Memory leak risk:** Handler functions MUST be bound once in onInit(), not per reconnect + +### HTTP Polling Specific +- **Agent socket leak:** Must destroy agent in onDeleted() to prevent port exhaustion +- **Polling deadlock:** Removed overcomplicated interval checks that blocked polling +- **Timeout cascades:** Each device has independent timeout, not global queue +- **Debug I/O overhead:** Now batched with 5-second debounce (85% I/O reduction) + +### Legacy Gateway Specific +- **Poor WiFi:** Adaptive polling increases interval after failures, resets on success +- **Device deletion:** Must call `homewizard.removeDevice()` to clear internal maps +- **Callback leaks:** safeCallback() wrapper ensures AbortController/timeout cleanup +- **Race conditions:** Response stats array now has atomic bounds checking + +### Baseload Monitor Specific +- **Fridge false positives:** Near-zero detection now requires CONSECUTIVE samples (not cumulative) +- **Battery interference:** Negative power (export) completely filtered before analysis +- **Night data gaps:** Uses fallback calculation if too many invalid nights +- **Oscillation sensitivity:** 300W threshold for normal grid variations, 400W for battery systems + +--- + +## Performance Baselines & Targets + +### CPU Usage Per Device Type +| Driver | Type | Typical CPU | Update Frequency | +|--------|------|-------------|---| +| energy_v2 (WS) | WebSocket | 0.5-1% | ~2s (buffered) | +| plugin_battery (WS) | WebSocket | 0.3-0.5% | ~2s (buffered) | +| energy (polling) | HTTP | 1-2% | 10s default | +| SDM230/630 | HTTP | 0.8-1.5% | 10s default | +| energy_socket | HTTP | 0.5-1% | 10s default | +| watermeter | HTTP | 0.3-0.5% | 10s default | +| Legacy (thermometer, etc.) | HTTP | 0.2-0.8% | 15-60s adaptive | + +### Memory Usage Per Device +| Driver | Typical | Notes | +|--------|---------|-------| +| energy_v2 | ~5-8 MB | Includes cache, capability store, battery tracking | +| plugin_battery | ~3-5 MB | Smaller dataset than P1 | +| polling drivers | ~2-3 MB each | Minimal state | +| legacy drivers | ~200-300 KB | Lightweight, sparse data | + +### I/O Operations Reduction (v3.11.10) +- Settings.get() calls: 8,640/day → 1,440/day (83% reduction) +- Debug log writes: 50/min → ~10/min (85% reduction) +- WebSocket hash calculations: 30/min → optimized loop (garbage collection reduced) + +--- + +## Debug Logging & Monitoring + +### App Settings Dashboard +- Location: App settings → "Fetch Debug" / "WebSocket Debug" / "Baseload Samples" +- Batched writes: Every 5 seconds max, 500-log global limit per app +- Per-device buffer: Max 20 logs before flush +- Cleared on device deletion to prevent unbounded growth + +### Key Debug Flags +```javascript +const debug = false; // Set to true for verbose WebSocket/measurement logging +const wsDebug = require('./wsDebug'); // WebSocket connection lifecycle +``` + +### Common Log Patterns +- `❌` - Error, action failed or malformed +- `⚠️` - Warning, degraded but functional +- `🔐` - Security/authorization +- `📡` - Network communication +- `⚡` - Battery/power-related +- `🕒` - Timing/watchdog +- `💧` - Gas/water data +- `🔌` - WebSocket lifecycle + +--- + +## Baseload Monitor (Sluipverbruik) Logic + +### Detection Window +- **Night hours:** 1 AM - 5 AM (configurable) +- **Sample collection:** Every power update during night (typically 10-30s intervals) +- **History:** Last 30 nights kept for stability + +### Invalid Night Conditions +1. **High Plateau** - Avg power > baseload + 500W for 10+ min (indicates external load) +2. **Negative Long** - Power < 0W for 5+ min (grid export, disabled for battery systems) +3. **Near-Zero Consecutive** - Continuous ±50W for 20+ min (grid balancing detected) +4. **Oscillation** - 300W+ swing in 5-min window (unstable conditions) +5. **PV Startup** - Negative power (export) at 4-6 AM (solar generation) + +### Valid Night Conditions +- Fridge cycles (50-300W, 30-120 min duration) detected and ignored +- Battery discharge (negative power) filtered out completely +- Baseload = average of all positive samples during night +- Stability = average of last 7 valid nights + +### Calculation Formula +``` +baseload = average(last_7_valid_nights) +- Each valid night = average power during 1-5 AM +- Filters applied: negative power removed, fridge cycles allowed +- Fallback: 10th percentile of all samples if < 7 valid nights +``` + +--- + +## Common Development Tasks + +### Adding a New Polling Driver +1. Create `drivers/my_device/device.js` +2. Extend with polling loop in onInit() +3. Implement onPoll() with fetchWithTimeout() +4. Use updateCapability() in Promise.allSettled() pattern +5. Destroy HTTP agent in onDeleted() +6. Add debug logging with _debugLog() pattern +7. Update app.json with device definition + +### Fixing a Memory Leak +1. Check for event listeners not removed (WebSocket.removeAllListeners()) +2. Check for closures holding references (bind once in onInit()) +3. Check for intervals/timeouts not cleared (verify onDeleted()) +4. Check for device map entries not cleaned (verify removeDevice() called) +5. Monitor with: `node --inspect` and Chrome DevTools + +### Optimizing CPU Usage +1. Batch updates with Promise.allSettled() (parallel, not sequential) +2. Throttle expensive operations (e.g., baseload detection every 30s) +3. Eliminate spread operators in tight loops +4. Cache settings.get() results instead of repeated calls +5. Use reverse iteration with early exit for filters + +### Testing Battery Integration +1. Set energy_v2 to use_polling = true (WebSocket fallback) +2. Trigger reconnects: restart device or kill WiFi +3. Monitor battery message buffering (should flush every 10s) +4. Verify battery mode changes trigger flow cards +5. Check that negative power doesn't invalidate baseload + +--- + +## Async/Await Patterns + +### Safe Pattern (Parallel Execution) +```javascript +const tasks = []; +tasks.push(updateCapability(this, 'cap1', value1).catch(this.error)); +tasks.push(updateCapability(this, 'cap2', value2).catch(this.error)); +await Promise.allSettled(tasks); // All run in parallel +``` + +### Anti-Pattern (Sequential, Slow) +```javascript +await updateCapability(this, 'cap1', value1); // Wait for cap1 +await updateCapability(this, 'cap2', value2); // Then wait for cap2 +``` + +### Error Handling +```javascript +try { + const data = await fetchWithTimeout(url, options); + // Process data +} catch (err) { + this.error('Failed to fetch:', err.message); + await this.setUnavailable(err.message || 'Fetch error'); +} +``` + +--- + +## Configuration Reference + +### Device Settings (app.json) +- `polling_interval` - Fetch interval in seconds (default 10) +- `url` - Device IP/hostname +- `show_gas` - Include gas meter data (P1 only) +- `offset_polling` - Socket smart plug polling offset +- `use_polling` - Force polling instead of WebSocket (energy_v2, plugin_battery) + +### App Settings +- `baseload_state` - Persisted baseload history and preferences +- `pluginBatteryGroup` - Fallback battery data when realtime unavailable +- Debug logs - Per-driver and per-app entries + +--- + +## Testing Procedures + +### Device Connectivity +``` +1. Verify Local API enabled in HomeWizard app +2. Check device reachability: curl http://device_ip/api/system +3. Verify authentication: curl with Authorization header +4. Test WebSocket: wscat wss://device_ip/api/ws +``` + +### Polling Stability +``` +1. Monitor poll intervals: check debug logs for timing +2. Verify no deadlocks: check pollingActive flag doesn't stick +3. Test timeout recovery: kill network briefly, verify reconnect +4. Stress test: set polling_interval to 1s, monitor CPU +``` + +### Baseload Accuracy +``` +1. Run 7+ full nights to build history +2. Verify fridge cycles logged but don't invalidate nights +3. Check app settings for baseload_state history +4. Manually inspect currentNightSamples to verify filtering +``` + +--- + +## Version History Key Milestones + +- **v3.11.10** - CPU optimization (caching, detection throttling), baseload near-zero fix +- **v3.9.29** - Baseload monitor introduced (sluipverbruik tracking) +- **v3.8.22** - WebSocket optimization, fetcQueue centralization +- **v3.0+** - Battery support, modular P1 driver, API v2 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index c842eeda..859cb86c 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,310 @@ # HomeWizard -# -This app let's you connect your HomeWizard to Homey. You can add your HomeWizard in the device section. Once done it will show up in the flow-editor, ready to be used! -V0.1.0: +Upon first deployment you need add the Homewizard unit first, then you can add the related/connected components from Homewizard to your Homey. -* Improved polling (far less requests to HomeWizard) -* Various bugfixes and improvements +NOTE! - ENABLE "LOCAL API" FOR YOUR DEVICE FIRST IN THE OFFICIAL HOMEWIZARD ENERGY APP BEFORE ADDING DEVICES -V0.0.9: +v3.13.8 -* Energylink + Wattcher support added (credits: Jeroen Tebbens) -* SIDENOTE: All devices paired before 0.0.9 (expect HomeWizard) should be re-paired! -* Make sure your solar meter is connected to s1 on energylink. +* Homewizard Legacy Device updates (CSS, flow and language) - thanks smarthomesvan +* Watermeter - battery based (via cloud hwenergy only 4x updates a day) +* P1 meters can now be connected via HomeWizard cloud API (Based on cloud API research by Sven Serlier) +* P1 (apiv2) - tariff trigger fix +* Bugfix: capability_already_exists (cloud_p1) +* Plugin Battery state of charge icon added as tile for dashboard -V0.0.8: +NOTE: This is best effort as this is cloud based and depends on your own internet and what Homewizard Energy platform allows. +If Homewizard Energy is down or is under maintenance or change their hwenergy web frontend you get errors or incorrect data. -* Heatlink support added (credits: Nick Bockmeulen) +v3.12.9 -V0.0.7: +* Plugin battery charge mode now selectable from UI +* Energy(apiv2) guard for add / remove "battery_group_charge_mode" -* Made fixes for app to work on 0.10.x +v3.12.7 -V0.0.5 & V0.0.6: +* P1 tuning TIMEOUT & Unreachable +* Removed pollingActive, unwanted side effect -* Made fixes for app to work on 0.9.x +v3.12.4 -V0.0.4: +* Baseload ignore return power (compensate battery return to grid datapoints) +* Plugin Battery LED brightness adjustment (user request) +* Bug fix: Battery Group (SoC missed when there are fetch errors) +* Bug fix: Polling deadlock fix for (energy, energy_socket, SDM230, SDM630, watermeter) +* Energy socket setAvailable fix +* Bugfix: _cacheSet undefined -* Added switching on/off scenes +(Websocket & caching) -V0.0.3: +* Optimized external meters hash calculation (eliminates array.map() garbage collection pressure) +* Battery group settings now cached with 60-second refresh -* Added a time-out of 10 sec -* Added extra logging +Baseload / sluipverbruik -V0.0.2: +* Detection algorithms now run every 30 seconds instead of on every power sample +* Eliminates expensive array scans during night hours -* Save HomeWizard’s as a device +v3.11.9 -V0.0.1: +* P1 energy modified to modular +* P1 energy_v2 modified to modular +* Heatlink additional code check on set target_temperature +* P1, changed order of processing, eletric first then gas/water +* P1 missed call in onPoll interval to reset daily calculation +* Bugfix: P1 (apiv2) polling mode - Charge mode fixes +* Bugfix: Group Battery State of Charge (increased timestamp check) +* Realtime pull from all batteries as fallback Battery Group State -* Use HomeWizard’s preset as a condition in flows -* Set HomeWizard’s preset as action in flows +v3.10.13 + +* Updated plugin battery mode names +* Added device name to debug messages +* SDM630 added per phase kwh meter tracking + daily kwh meter (estimate) +* More gas fix reset at night time (apiv1 and apiv2) +* Bugfix: incorrect daily reset during day of gas usage +* Bugfix: Energylink (watermeter) and Thermometer (battery) +NOTE: This is an estimate based on polling interval. If bad wifi or Homey can't reach the SDM630 the measured value will be lower than the actual data. + +v3.10.7 + +* Bugfix: Homewizard Legacy fetch (tab was empty, no entries while there were errors in the log) +* Remove fetchQueue feature in favor of capture debug information in the app settings page +* Watermeter daily usage added +* Bugfix: Device Fetch Debug wasn't updating only showed "Loading..." +* Bugfix: Circular Reference "device" +* Bugfix: SDM230(p1mode) - updateCapability missed +* Finetune debug log (ignore message circuit_open) +* Energy_socket finetune, added a device queue as a replacement for the earlier centralized fetchQueue +* Homewizard adaptive polling + tuning timeouts +* Cleanup device drivers with overcomplicated checks that ended up with polling deadlocks +* SDM230(p1mode) - Extra code handling for TIMEOUT issues +* Daily gas usage reset improvement (nighttime sometimes misses when there is no gas value received) + +v3.9.29 + +* Wsmanager optimize +* Homewizard legacy custom polling +* Driver.js (apiv2) log fix (this.log undefined) +* Thermometer rollback (name index matching doesnt work as expected) +* Homewizard legacy -> node-fetch and not the fetchQueue utility (bad user experience feedback) +* Baseload (sluipverbruik) improvement (fridge/freezer should not be flagged as invalid ) +* Homewizard app setting page with log or debug information for discovery, fetch failures, websocket problems and baseload samples +* Bugfix: Homewizard.poll (legacy unit) +* Homewizard Legacy fetch debug added to same section under Application settings +* Heatlink Legacy improvement +* Homewizard Legacy Preset improvement (UI picker in Homey app) +* Using external gas meter (timestamp X) instead of administrative meter +* Thermometer trigger and condition cards for no response for X hours. +* Improvement fetchQueue (protect against high cpu warning for devices on 1s polling) + +v3.9.20 + +* New Plugin Battery mode support (zero_charge_only & zero_discharge_only) +* Optional gas checkbox (default enabled) for P1 (apiv1 and apiv2). (User request) +* Added 15min power datapoint for Belgium (average_power_15m_w) P1(apiv2) (user request) +* Plugin Battery - Bugfix setMode for to_full (PUT) +* Updated SDM230_v2 and SDM630_v2 drivers +* Bugfix - Updated P1apiv2 check-battery-mode condition card +* Backward compatibilty fix for the new battery mode applied to older P1 firmware. +* Bugfix - Websocket payload battery mode adjustment +* Fixed: rare crash when _handleBatteries() ran after a device was deleted, causing Not Found: Device with ID … errors during setStoreValue. +* Phase overload notification setting added and a limiter to avoid notification flooding +* New Feature: Baseload (sluipverbruik) detection (experimental) +* Bugfix: energy_socket connection_error capability fix +* Bugfix: energy_v2 (handleBatteries) - device_not_found crash +* Bugfix: trigger cards for SDM230_v2 +* APIv2 change pairing: Modified the username that is used during pair made it unique per homey +* Bugfix: APIv2 pairing -> local/homey_xxxxxx +* Bugfix: SDM630v2 trigger cards removed (obsolete as these are default Homey) +* Finetune: P1(apiv2) websocket + polling, capability updates +* Finetune: energy_sockets (fetch / timeout) centralized +* Refractor code update for P1apiv1, SDM230, SDM630, watermeter +* Customizable phase overload warning + reset +* Phase 1 /3 fix for P1(apiv1) after refractor code update +* Bugfix: Fallback url for SDM230v2 and P1apiv2 (mDNS fail workaround) +* Bugfix: pairing problem "Cannot read properties of undefined (reading 'log') +* Homewizard legacy, clear some old callback methods +* Finetune async/await updates + +v3.8.22 + +* Finetune energy_v2 updates primary values are updated instant, other lesser values once every 10s +* Additional watchdog code to reconnect energy_v2 and plugin_battery upon firmware up/downgrades +* Websocket finetuning (energy_v2 and plugin battery) +* Centralized fetch queue for all fetch calls to spread all queries +* Removed interval check in onPoll loop +* Restore custom polling sockets (got removed by accident rollback) + +v3.8.18 + +* Bugfix: Failed to recreate agent: TypeError: Assignment to constant variable (energy) +* Adjustment to async/await code several drivers + +v3.8.16 + +* Updated APIv2 to add more text upon fetch failed +* Websocket based battery mode settings added (both condition and action) +* Websocket heartbeat (30s) to keep battery mode updated (workaround as battery mode is the only realtime update when it changes) +* P1 & EnergySocket driver (apiv1) http agent tuning (ETIMEOUT and ECONNRESET) + +v3.8.13 + +* Extra error handling (updateCapability) based on received crashreports +* Bugfix: ReferenceError: err is not defined (energy_socket) + +v3.8.11 + +* Rollback energy dongle code from earlier version v3.7.0 +* Strange SD630 problem on older Homey's +* Extra verbose logging in urls to expose mDNS problems for older Homeys (url) + +v3.8.8 + +* After attempting conditional fetch, roll back to node-fetch until 12.9.x releases (Homey Pro 2016 - 2019) +* Bugfix: SDM230-p1mode - error during initialization + +v3.7.9 + +* Extra check upon websocket creation to avoid crashes +* Plugin battery catch all error (unhandled exception) +* Additional checking and error handling on bad wifi connections (websocket based) +* (fix) Error: WebSocket is not open: readyState 0 (CONNECTING) +* Fetch was not defined for fetchWithTimeout function +* Missed net_frequency update, also made it 3 decimals +* Capability update fix (avoid removal check) + +v3.7.1 + +* Trigger card for battery SoC Drift (triggers on expected vs actual State-of-charge) +* Trigger card for battery error (based on energy returned to grid while battery group should be charging) +* Trigger card for battery netfrequency out of range +* Icon update for various capabilities +* Battery group details added to P1apiv2. (Charging state) +* Realtime data for P1 (apiv2) via Websocket +* Realtime data for Plugin Battery via Websocket +* Bugfixes/crashes on P1 (apiv2) - no gas data on first poll / ignore +* Websocket reconnect code for covering wifi disconnect & terminate issues +* Plugin Battery group fix (tracking combined set of batteries) - bugfix / Refenece error +* Netfrequency capability added for Plugin Battery +* Homewizard Legacy - code rollback (pairing problems after improvements) +* P1 (apiv2) - Added checkbox setting to fallback to polling if websocket is to heavy for Homey device + +v3.6.77 + +* Custom polling-interval option made for Homewizard Legacy unit (default 20s, when adjusted restart app to active it) + To adjust setting check the main unit advanced settings +* Energy sockets with poor wifi connection will have 3 attempts now +* Fallback url for P1 mode SDM230 / SDM630 + +v3.6.75 + +* Thermometer (Homewizard Legacy) - full code refractoring +* Homewizard Legacy doesnt support keep-alive, changed back to normal fetch / retry +* Finetune code keepAlive for other devices 10s +* Bugfix: number_of_phases setting incorrectly updated +* Added verbose mDNS discovery results for troubleshooting + +v3.6.73 + +* More try/catch code to avoid any crashes on Homewizard Legacy main unit getStatus fail (Device not found) +* Fine tune "estimated kwh" plugin battery calculation based on user feedback +* Code fixes: unhandledRejections CloudOn/Off for sockets and P1 + +v3.6.71 + +* Finetuning polling and capability during init phase of various drivers +* Added more logging to support diagnostic reports +* Bugfix SDM230 solar parameter was undefined +* Added an estimate charge available in plugin battery value +* Extra code checking for Homewizard Legacy (getStatus function) when there is a connection failure/device not found + +v3.6.67 + +* Enforcing interval clears on various devices when interval is reset +* try_authorize handler bugfix (interval / timeout) app crash logs + +v3.6.66 + +* Fall back url setting upon initial poll for P1, sockets, kwh's, watermeter. (older Homey Pro;s 2016/2019 seems to struggle with mDNS updates) +* Removed retry code for Homewizard legacy devices (changed to keeping http agent session open / keepAlive) +* Battery Group data removed from P1 after a fetch fail (bugfix) +* Increased timeouts (authorize / pairing APIv2) +* Language adjustment P1 warning (overload EN/NL) +NOTE: First time running this version will fail as the url setting is empty so it should improve onwards. + +v3.6.63 + +* SDM230 (p1 mode added) +* P1apiv2 - added daily usage kwh (resets at nightime) (does not cater for directly consumed solar-used energy as this does not pass the smart meter at all) +* Adjustment for P1 to look at Amp datapoints to detect 3-Phased devices in Norway +* HTTP - keepalive agent added to P1, sockets, APIv2 devices +* KeepAlive timeout increased from default 1000ms +* AbortController code added for APiv2 +* Wifi quality capability added (-48dBm is not always clear to users if it is good or bad) +* Bugfix: P1, missed setAvailable(). Code didn’t recover from a failed P1 connection and kept P1 offline + +v3.6.58 + +* Bugfix that was caused by experimental firmware Homey 12.5.2RC3 and slider capability that could not be removed +* Added energy flags for sockets so they can trace imported/exported energy in Homey Energy Dashboard (Home Batteries connected via sockets) +* Code cleanup +* Added some fine tuning to spread the API call's to the P1 + +v3.6.50 + +* Added phase monitoring +* Adjust settings to align with your energy grid +* Bugfix for sliders when gridconnection has 3 phases +* Actual gas meter measurement added (5min poll pending on smartmeter) +* P1apiv1 - Code refactored (clean up repetive lines) +* Extra plugin battery trigger cards (state change, time to full, time to empty) +* Removed sliders in GUI to show grid load per phases + +v3.6.40 + +* Cloud connection setting made available for P1, Sockets, Watermeter, SDM230, SDM630 +* Bugfix Offset watermeter (Cannot read properties of undefined - reading 'offset_water') + +v3.6.38 + +* P1(apiv2) gas meter bugfixes +* P1(apiv2) aggregated total usage added (support for PowerByTheHour app) +* Custom polling for Watermeter, SDM230, SDM630 and SDM630-p1 mode, Default 10s, adjust in advanced settings +* Action cards plugin battery - P1apiv2 device is required (P1 firmware version 6.0201 or higher) +* Wifi metric (dBm) added for P1(apiv2) and Plugin Battery +* Custom Polling interval added for Plugin Battery +* Daily usage imported power and gas (P1apiv1) - User request +* Plugin Battery: added time_to_empty and time_to_full (minutes) +* Trigger for battery mode change + +v3.6.6 + +* Homey Energy - Polling interval for all Energy devices (P1, kwh etc.) lowered to 1s (was 10s) +* Reverted interval back 10s as this has an increased load on some wifi networks and (older) homeys (Early2019) + +v3.6.2 + +* Massive code rework (credits to DCSBL for time and effort) +* Homey Energy dashhboard: Energylink meter_gas capability added +* Text fix in Plugin Battery driver +* APIv2 timer timeout problem + +v3.5.5 + +* Recode P1 APIv2, improved pairing process (DCSBL) +* Pairing process P1 and Plugin Battery aligned +* Plugin in Battery pairing text fix + +v3.5.2 + +* SDM630 clone added to allow P1 like use of kwh meter as a replacement for P1 dongle (users request) + +v3.5.1 + +* Coversion to homey-compose (DCSBL) +* Socket identification (push button led blink) (DCSBL) + +**You can sponsor my work by donating via paypal.** + +[![Donate with PayPal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/paypalme2/jtebbens) diff --git a/README.txt b/README.txt new file mode 100644 index 00000000..a15f6c3d --- /dev/null +++ b/README.txt @@ -0,0 +1,20 @@ +Connect your HomeWizard products with Homey. + +NOTE! - ENABLE "LOCAL API" FOR YOUR ENERGY SOCKET, WATERMETER, KWH METER FIRST IN THE OFFICIAL HOMEWIZARD ENERGY APP + +- P1 Meter +- kWh Meter 1 and 3 phase type (SDM230 and SDM630) +- Watermeter +- Energy Socket +- Plugin Battery + +OLD Homewizard Legacy (2012 device) +First install / pair your HomeWizard Base Station then you can add the following devices: +- Thermometers +- Heatlink (Control heating) +- Energylink (Power usage, Gas, Solar production & Water usage (via S1/S2), Total usage and production) +- Windmeter (Direction, Speed, Gusts) +- Rainmeter +- Wattcher (the alternative "Energylink" which only tracks usage from the blinking led) +- Motion sensor +- Smoke sensor diff --git a/app.js b/app.js index 0fbfd041..ca5e6b56 100644 --- a/app.js +++ b/app.js @@ -1,9 +1,73 @@ -"use strict"; +/* + * HomeWizard App for Homey + * Copyright (C) 2025 Jeroen Tebbens + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ -function init() { - var request = require('request'); - Homey.log("HomeWizard app ready!"); - +'use strict'; + +const Homey = require('homey'); + +// const v8 = require('v8'); + +const Testing = false; + +class HomeWizardApp extends Homey.App { + async onInit() { + this.log('HomeWizard app ready!'); + this.baseloadMonitor = null; + this.p1Source = null; + + // Debug: fetchQueue stats elke 10 seconden + // setInterval(() => { const stats = fetchQueue.stats(); this.log('fetchQueue stats:', stats); }, 1000); + + if (process.env.DEBUG === '1' && Testing) { + try { + require('inspector').waitForDebugger(); + } + catch (error) { + require('inspector').open(9225, '0.0.0.0', true); + } + + // Only enable memory monitor when running locally (CLI dev mode) + /* if (Homey.platform === 'local') { + this._memInterval = setInterval(() => { + try { + const hs = v8.getHeapStatistics(); + const heapUsed = (hs.used_heap_size / 1024 / 1024).toFixed(1); + const heapTotal = (hs.total_heap_size / 1024 / 1024).toFixed(1); + const external = (hs.external_memory / 1024 / 1024).toFixed(1); + + this.log( + `Memory(V8): HeapUsed=${heapUsed}MB HeapTotal=${heapTotal}MB External=${external}MB` + ); + } catch (err) { + this.error('Memory monitor failed:', err.message); + } + }, 60000); + } */ + } + } + + async onUninit() { + if (this._memInterval) { + clearInterval(this._memInterval); + this._memInterval = null; + } + } + } -module.exports.init = init; \ No newline at end of file +module.exports = HomeWizardApp; diff --git a/app.json b/app.json index da302a15..8c18b8fd 100755 --- a/app.json +++ b/app.json @@ -1,430 +1,5350 @@ { - "id": "com.homewizard", - "name": { - "en": "HomeWizard" - }, - "version": "0.1.0", - "compatibility": ">=0.9", - "description": { - "en": "Control HomeWizard using Homey" - }, - "category": "appliances", - "images": { - "large": "assets/images/large.jpg", - "small": "assets/images/small.jpg" - }, - "author": { - "name": "Jeroen Bos", - "email": "jeroenbos22@gmail.com" - }, - "contributors": { - "developers": [{ - "name": "Jeroen Bos", - "email": "jeroenbos22@gmail.com" - }, { - "name": "Nick Bockmeulen", - "email": "git@bockmeulen.nl" - }, { - "name": "Jeroen Tebbens", - "email": "jeroen@tebbens.net" - }] - }, - "flow": { - "triggers": [{ - "id": "power_used_changed", - "title": { - "en": "Power used changed", - "nl": "Huidig vermogen veranderd" - }, - "args": [{ - "name": "Energylink", - "type": "device", - "filter": "driver_uri=homey:app:com.homewizard&driver_id=energylink", - "placeholder": { - "en": "Which energylink", - "nl": "Welke energylink" - } - }], - "tokens": [{ - "name": "power_used", - "title": { - "en": "Watt", - "nl": "Watt" - } - }] - }, { - "id": "power_s1_changed", - "title": { - "en": "Power production changed", - "nl": "Huidige productie veranderd" - }, - "args": [{ - "name": "Energylink", - "type": "device", - "filter": "driver_uri=homey:app:com.homewizard&driver_id=energylink", - "placeholder": { - "en": "Which energylink", - "nl": "Welke energylink" - } - }], - "tokens": [{ - "name": "power_s1", - "title": { - "en": "Watt", - "nl": "Watt" - } - }] - }, { - "id": "meter_power_used_changed", - "title": { - "en": "Daily usage changed", - "nl": "Dag verbruik veranderd" - }, - "args": [{ - "name": "Energylink", - "type": "device", - "filter": "driver_uri=homey:app:com.homewizard&driver_id=energylink", - "placeholder": { - "en": "Which energylink", - "nl": "Welke energylink" - } - }], - "tokens": [{ - "name": "power_daytotal_used", - "title": { - "en": "kWh", - "nl": "kWh" - } - }] - }, { - "id": "meter_power_s1_changed", - "title": { - "en": "Daily production changed", - "nl": "Dag productie veranderd" - }, - "args": [{ - "name": "Energylink", - "type": "device", - "filter": "driver_uri=homey:app:com.homewizard&driver_id=energylink", - "placeholder": { - "en": "Which energylink", - "nl": "Welke energylink" - } - }], - "tokens": [{ - "name": "power_daytotal_s1", - "title": { - "en": "kWh", - "nl": "kWh" - } - }] - } - - ], - "conditions": [{ - "id": "check_preset", - "title": { - "en": "Preset !{{is|isn't}}", - "nl": "Preset !{{is|is niet}}" - }, - "args": [{ - "name": "preset", - "type": "dropdown", - "values": [{ - "id": "0", - "label": { - "en": "Home", - "nl": "Thuis" - } - }, { - "id": "1", - "label": { - "en": "Away", - "nl": "Afwezig" - } - }, { - "id": "2", - "label": { - "en": "Sleep", - "nl": "Slapen" - } - }, { - "id": "3", - "label": { - "en": "Holiday", - "nl": "Vakantie" - } - }] - }, { - "name": "device", - "type": "device", - "filter": "driver_id=homewizard" - }] - }], - "actions": [{ - "id": "set_preset", - "title": { - "en": "Activate preset", - "nl": "Activeer preset" - }, - "args": [{ - "name": "preset", - "type": "dropdown", - "values": [{ - "id": "0", - "label": { - "en": "Home", - "nl": "Thuis" - } - }, { - "id": "1", - "label": { - "en": "Away", - "nl": "Afwezig" - } - }, { - "id": "2", - "label": { - "en": "Sleep", - "nl": "Slapen" - } - }, { - "id": "3", - "label": { - "en": "Holiday", - "nl": "Vakantie" - } - }] - }, { - "name": "device", - "type": "device", - "filter": "driver_id=homewizard" - }] - }, { - "id": "switch_scene_on", - "title": { - "en": "Switch scene on", - "nl": "Zet scene aan" - }, - "args": [{ - "name": "scene", - "type": "autocomplete" - }, { - "name": "device", - "type": "device", - "filter": "driver_id=homewizard" - }] - }, { - "id": "switch_scene_off", - "title": { - "en": "Switch scene off", - "nl": "Zet scene uit" - }, - "args": [{ - "name": "scene", - "type": "autocomplete" - }, { - "name": "device", - "type": "device", - "filter": "driver_id=homewizard" - }] - }] - }, - "drivers": [{ - "id": "homewizard", - "name": { - "en": "HomeWizard", - "nl": "HomeWizard" - }, - "images": { - "large": "drivers/homewizard/assets/images/large.jpg", - "small": "drivers/homewizard/assets/images/small.jpg" - }, - "class": "appliances", - "capabilities": [], - "pair": [{ - "id": "start" - }, { - "id": "list_my_devices", - "template": "list_devices", - "navigation": { - "next": "add_my_devices" - } - }, { - "id": "add_my_devices", - "template": "add_devices" - }], - "settings": [{ - "type": "group", - "label": { - "en": "HomeWizard settings", - "nl": "HomeWizard instellingen" - }, - "children": [{ - "id": "homewizard_ip", - "type": "text", - "label": { - "en": "IP address", - "nl": "IP adres" - }, - "value": "" - }, { - "id": "homewizard_pass", - "type": "text", - "label": { - "en": "Password", - "nl": "Wachtwoord" - }, - "value": "" - }, { - "id": "homewizard_ledring", - "type": "checkbox", - "label": { - "en": "Use ledring", - "nl": "Gebruik ledring" - }, - "value": false - }] - }] - }, { - "id": "heatlink", - "name": { - "en": "Heatlink", - "nl": "Heatlink" - }, - "images": { - "large": "drivers/heatlink/assets/images/large.jpg", - "small": "drivers/heatlink/assets/images/small.jpg" - }, - "class": "thermostat", - "capabilities": [ - "measure_temperature", - "target_temperature" - ], - "pair": [{ - "id": "start" - }, { - "id": "list_my_devices", - "template": "list_devices", - "navigation": { - "next": "add_my_devices" - } - }, { - "id": "add_my_devices", - "template": "add_devices" - }] - }, { - "id": "energylink", - "name": { - "en": "Energylink", - "nl": "Energylink" - }, - "images": { - "large": "drivers/energylink/assets/images/large.jpg", - "small": "drivers/energylink/assets/images/small.jpg" - }, - "class": "sensor", - "capabilities": [ - "meter_power.used", "meter_power.s1", "meter_gas", "measure_power.used", "measure_power.s1" - ], - - "capabilitiesOptions": { - "meter_power.used": { - "title": { - "en": "Day usage", - "nl": "Dag gebruik" - } - }, - "meter_power.s1": { - "title": { - "en": "Day production", - "nl": "Dag opbrengst" - } - - }, - "measure_power.used": { - "title": { - "en": "Power current", - "nl": "Huidig vermogen" - } - - }, - "measure_power.s1": { - "title": { - "en": "Solar current", - "nl": "Huidige opbrengst" - } - - }, - "meter_gas": { - "title": { - "en": "Gas", - "nl": "Gas" - } - } - }, - - - "pair": [{ - "id": "start" - }, { - "id": "list_my_devices", - "template": "list_devices", - "navigation": { - "next": "add_my_devices" - } - }, { - "id": "add_my_devices", - "template": "add_devices" - }] - }, { - "id": "wattcher", - "name": { - "en": "Wattcher", - "nl": "Wattcher" - }, - "images": { - "large": "drivers/wattcher/assets/images/large.jpg", - "small": "drivers/wattcher/assets/images/small.jpg" - }, - "class": "sensor", - "capabilities": [ - "measure_power", - "meter_power" - ], - - "capabilitiesOptions": { - "meter_power": { - "title": { - "en": "Day usage", - "nl": "Dag totaal" - } - }, - - "measure_power": { - "title": { - "en": "Power current", - "nl": "Huidig vermogen" - } - - } - - }, - - "pair": [{ - "id": "start" - }, { - "id": "list_my_devices", - "template": "list_devices", - "navigation": { - "next": "add_my_devices" - } - }, { - "id": "add_my_devices", - "template": "add_devices" - }] - }], - "permissions": [ - "homey:manager:ledring" - ] + "_comment": "This file is generated. Please edit .homeycompose/app.json instead.", + "id": "com.homewizard", + "name": { + "en": "HomeWizard" + }, + "version": "3.13.8", + "platforms": [ + "local" + ], + "sdk": 3, + "brandColor": "#2fc052", + "compatibility": ">=12.9.0", + "description": { + "en": "Helps you understand and save" + }, + "category": [ + "energy", + "appliances", + "climate" + ], + "images": { + "xlarge": "assets/images/xlarge.png", + "large": "assets/images/large.png", + "small": "assets/images/small.png" + }, + "author": { + "name": "Jeroen Tebbens", + "email": "jeroen@tebbens.net" + }, + "contributors": { + "developers": [ + { + "name": "Jeroen Bos", + "email": "jeroenbos22@gmail.com" + }, + { + "name": "Nick Bockmeulen", + "email": "git@bockmeulen.nl" + }, + { + "name": "Jeroen Tebbens", + "email": "jeroen@tebbens.net" + }, + { + "name": "Freddie Welvering", + "email": "freddie@welvering.eu" + }, + { + "name": "Emile Nijssen", + "email": "emile@emilenijssen.nl" + }, + { + "name": "Dennie de Groot", + "email": "mail@denniedegroot.nl" + } + ] + }, + "contributing": { + "donate": { + "paypal": { + "username": "jtebbens" + } + } + }, + "bugs": { + "url": "https://community.homey.app/t/app-pro-homewizard/19267" + }, + "source": "https://github.com/jtebbens/com.homewizard", + "homeyCommunityTopicId": 19267, + "support": "https://community.homey.app/t/app-pro-homewizard/19267", + "permissions": [ + "homey:manager:ledring" + ], + "flow": { + "triggers": [ + { + "id": "leak_changed", + "title": { + "en": "Water leakage", + "nl": "Water lekkage" + }, + "args": [ + { + "name": "Kakusensor", + "type": "device", + "filter": "driver_id=kakusensor", + "placeholder": { + "en": "Which Sensor", + "nl": "Welke Sensor" + } + } + ], + "tokens": [ + { + "name": "leak_changed", + "type": "number", + "title": { + "en": "mm", + "nl": "mm" + }, + "example": 15 + } + ] + }, + { + "id": "meter_power.import", + "title": { + "en": "Import power changed", + "nl": "Importvermogen gewijzigd" + }, + "titleFormatted": { + "en": "Import power changed", + "nl": "Importvermogen gewijzigd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM230_v2" + } + ], + "tokens": [ + { + "name": "import_power", + "type": "number", + "title": { + "en": "Import Power (W)", + "nl": "Importvermogen (W)" + } + } + ] + }, + { + "id": "meter_power.export", + "title": { + "en": "Export power changed", + "nl": "Exportvermogen gewijzigd" + }, + "titleFormatted": { + "en": "Export power changed", + "nl": "Exportvermogen gewijzigd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM230_v2" + } + ], + "tokens": [ + { + "name": "export_power", + "type": "number", + "title": { + "en": "Export Power (W)", + "nl": "Exportvermogen (W)" + } + } + ] + }, + { + "id": "battery_mode_changed_SDM230_v2", + "title": { + "en": "Battery mode changed" + }, + "titleFormatted": { + "en": "Battery mode changed" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM230_v2" + } + ], + "tokens": [] + }, + { + "id": "battery_mode_changed_SDM630_v2", + "title": { + "en": "Battery mode changed" + }, + "titleFormatted": { + "en": "Battery mode changed" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM630_v2" + } + ], + "tokens": [] + }, + { + "id": "tariff_changed", + "title": { + "en": "Peak / Normal Tariff changed", + "nl": "Dal / Normaal Tarief veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy" + } + ], + "tokens": [ + { + "name": "tariff_changed", + "type": "number", + "title": { + "en": "tariff", + "nl": "tarief" + }, + "example": 1 + } + ] + }, + { + "id": "import_changed", + "title": { + "en": "Total used changed", + "nl": "Som gebruik veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy" + } + ], + "tokens": [ + { + "name": "import_changed", + "type": "number", + "title": { + "en": "import", + "nl": "import" + }, + "example": 1 + } + ] + }, + { + "id": "export_changed", + "title": { + "en": "Total delivered changed", + "nl": "Som teruglevering veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy" + } + ], + "tokens": [ + { + "name": "export_changed", + "type": "number", + "title": { + "en": "export", + "nl": "export" + }, + "example": 1 + } + ] + }, + { + "id": "battery_mode_changed", + "title": { + "en": "Battery mode changed" + }, + "titleFormatted": { + "en": "Battery mode changed" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy_v2" + } + ], + "tokens": [] + }, + { + "id": "battery_error_detected", + "title": { + "en": "Battery error detected" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy_v2" + } + ] + }, + { + "id": "battery_group_state_changed", + "title": { + "en": "Battery group state changed" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy_v2" + } + ] + }, + { + "id": "tariff_changed_v2", + "title": { + "en": "Peak / Normal Tariff changed", + "nl": "Dal / Normaal Tarief veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy_v2" + } + ], + "tokens": [ + { + "name": "tariff", + "type": "number", + "title": { + "en": "tariff", + "nl": "tarief" + }, + "example": 1 + } + ] + }, + { + "id": "import_changed_v2", + "title": { + "en": "Total used changed", + "nl": "Som gebruik veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy_v2" + } + ], + "tokens": [ + { + "name": "import", + "type": "number", + "title": { + "en": "import", + "nl": "import" + }, + "example": 1 + } + ] + }, + { + "id": "export_changed_v2", + "title": { + "en": "Total delivered changed", + "nl": "Som teruglevering veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy_v2" + } + ], + "tokens": [ + { + "name": "export", + "type": "number", + "title": { + "en": "export", + "nl": "export" + }, + "example": 1 + } + ] + }, + { + "id": "power_used_changed", + "title": { + "en": "Power used changed", + "nl": "Huidig vermogen veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energylink" + } + ], + "tokens": [ + { + "name": "power_used", + "type": "number", + "title": { + "en": "Watt", + "nl": "Watt" + }, + "example": 15 + } + ] + }, + { + "id": "power_s1_changed", + "title": { + "en": "Power production changed", + "nl": "Huidige productie veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energylink" + } + ], + "tokens": [ + { + "name": "power_s1", + "type": "number", + "title": { + "en": "Watt", + "nl": "Watt" + }, + "example": 15 + } + ] + }, + { + "id": "power_s2_changed", + "title": { + "en": "Power usage S2 changed", + "nl": "Huidige gebruik S2 veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energylink" + } + ], + "tokens": [ + { + "name": "power_s2", + "type": "number", + "title": { + "en": "Watt", + "nl": "Watt" + }, + "example": 15 + } + ] + }, + { + "id": "power_netto_changed", + "title": { + "en": "Daily netto usage changed", + "nl": "Dag netto verbruik veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energylink" + } + ], + "tokens": [ + { + "name": "netto_power_used", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + }, + { + "id": "meter_power_used_changed", + "title": { + "en": "Daily usage changed", + "nl": "Dag verbruik veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energylink" + } + ], + "tokens": [ + { + "name": "power_daytotal_used", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + }, + { + "id": "meter_power_aggregated_changed", + "title": { + "en": "Overall usage changed", + "nl": "Netto verbruik veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energylink" + } + ], + "tokens": [ + { + "name": "power_daytotal_aggr", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + }, + { + "id": "meter_power_s1_changed", + "title": { + "en": "Daily production changed", + "nl": "Dag productie veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energylink" + } + ], + "tokens": [ + { + "name": "power_daytotal_s1", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + }, + { + "id": "meter_power_s2_changed", + "title": { + "en": "Daily usage S2 changed", + "nl": "Dag gebruik S2 veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energylink" + } + ], + "tokens": [ + { + "name": "power_daytotal_s2", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + }, + { + "id": "meter_return_t1_changed", + "title": { + "en": "Meter return t1 changed", + "nl": "Meter teruglevering t1 veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energylink" + } + ], + "tokens": [ + { + "name": "meter_power_produced_t1", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + }, + { + "id": "meter_return_t2_changed", + "title": { + "en": "Meter return t2 changed", + "nl": "Meter teruglevering t2 veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energylink" + } + ], + "tokens": [ + { + "name": "meter_power_produced_t2", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + }, + { + "id": "preset_changed", + "title": { + "en": "Preset has changed", + "nl": "Preset is veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=homewizard" + } + ], + "tokens": [ + { + "name": "preset", + "type": "number", + "title": { + "en": "Preset", + "nl": "Preset" + }, + "example": 1 + }, + { + "name": "preset_text", + "type": "string", + "title": { + "en": "Text", + "nl": "Tekst" + }, + "example": "Home" + } + ] + }, + { + "id": "battery_state_changed", + "title": { + "en": "Battery state changed" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=plugin_battery" + }, + { + "name": "state", + "type": "dropdown", + "title": { + "en": "Charging state" + }, + "values": [ + { + "id": "charging", + "name": { + "en": "Charging" + } + }, + { + "id": "discharging", + "name": { + "en": "Discharging" + } + }, + { + "id": "idle", + "name": { + "en": "Idle" + } + } + ] + } + ], + "titleFormatted": { + "en": "Battery state changed to [[state]]" + } + }, + { + "id": "battery_low_runtime", + "title": { + "en": "Battery time to empty is low" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=plugin_battery" + }, + { + "name": "minutes", + "type": "number", + "title": { + "en": "Minutes remaining" + } + } + ], + "titleFormatted": { + "en": "Battery time to empty is [[minutes]] minutes" + } + }, + { + "id": "battery_full", + "title": { + "en": "Battery is fully charged" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=plugin_battery" + } + ] + }, + { + "id": "battery_soc_drift_detected", + "title": { + "en": "Battery SoC Drift Detected" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=plugin_battery" + } + ], + "titleFormatted": { + "en": "Battery SoC drift detected", + "nl": "Batterij SoC-afwijking gedetecteerd" + } + }, + { + "id": "net_frequency_out_of_range", + "title": { + "en": "Network frequency out of range", + "nl": "Netwerkfrequentie buiten bereik" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=plugin_battery" + } + ], + "titleFormatted": { + "en": "Network frequency is out of range", + "nl": "Netwerkfrequentie is buiten bereik" + } + }, + { + "id": "rainmeter_value_changed", + "title": { + "en": "Rainmeter value changed", + "nl": "Regenmeter waarde veranderd" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=rainmeter" + } + ], + "tokens": [ + { + "name": "rainmeter_changed", + "type": "number", + "title": { + "en": "mm", + "nl": "mm" + }, + "example": 15 + } + ] + }, + { + "id": "temp_not_changed_trigger", + "title": { + "en": "Temperature unchanged for X hours", + "nl": "Temperatuur niet veranderd sinds X uur" + }, + "titleFormatted": { + "en": "Temperature unchanged for [[hours]] hours", + "nl": "Temperatuur niet veranderd sinds [[hours]] uren" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=thermometer" + }, + { + "name": "hours", + "type": "number", + "title": { + "en": "Hours", + "nl": "Uren" + } + } + ] + } + ], + "conditions": [ + { + "title": { + "en": "Check Battery Mode", + "nl": "Controleer batterij Modus" + }, + "titleFormatted": { + "en": "Check battery mode !{{is|isn't}} [[mode]]", + "nl": "Controleer batterij modus !{{is|isn't}} [[mode]]" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy_v2" + }, + { + "type": "dropdown", + "name": "mode", + "values": [ + { + "id": "zero", + "label": { + "en": "Zero mode", + "nl": "Null op de meter" + } + }, + { + "id": "to_full", + "label": { + "en": "Full Charge", + "nl": "Volledig laden" + } + }, + { + "id": "standby", + "label": { + "en": "Standby", + "nl": "Standby" + } + }, + { + "id": "zero_charge_only", + "label": { + "en": "Zero mode, Charge Only", + "nl": "Nul op de Meter, Alleen laden" + } + }, + { + "id": "zero_discharge_only", + "label": { + "en": "Zero mode, Discharge Only", + "nl": "Nul op de Meter, Alleen ontladen" + } + } + ] + } + ], + "id": "check-battery-mode" + }, + { + "id": "check_preset", + "title": { + "en": "Preset !{{is|isn't}}", + "nl": "Preset !{{is|is niet}}" + }, + "titleFormatted": { + "en": "Check !{{is|isn't}} [[preset]]", + "nl": "Controleer !{{is|isn't}} [[preset]]" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=homewizard" + }, + { + "name": "preset", + "type": "dropdown", + "values": [ + { + "id": "0", + "label": { + "en": "Home", + "nl": "Thuis" + } + }, + { + "id": "1", + "label": { + "en": "Away", + "nl": "Afwezig" + } + }, + { + "id": "2", + "label": { + "en": "Sleep", + "nl": "Slapen" + } + }, + { + "id": "3", + "label": { + "en": "Holiday", + "nl": "Vakantie" + } + } + ] + } + ] + }, + { + "id": "temp_not_changed_hours", + "title": { + "en": "Temperature not changed for", + "nl": "Temperatuur niet veranderd sinds" + }, + "titleFormatted": { + "en": "Temperature not changed for [[hours]] hours", + "nl": "Temperatuur niet veranderd sinds [[hours]] uren" + }, + "hint": { + "en": "Checks if the temperature has not changed for the given number of hours.", + "nl": "Controleert of de temperatuur niet veranderd is gedurende het opgegeven aantal uren." + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=thermometer" + }, + { + "name": "hours", + "type": "number", + "title": { + "en": "Hours", + "nl": "Uren" + } + } + ] + } + ], + "actions": [ + { + "id": "sdm230-set-battery-to-full-charge-mode", + "title": { + "en": "Set battery to Full Charge mode", + "nl": "Zet batterij in Volledig Oplaad modus" + }, + "titleFormatted": { + "en": "Set battery to Full Charge mode", + "nl": "Zet batterij in Volledig Oplaad modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM230_v2" + } + ] + }, + { + "id": "sdm230-set-battery-to-zero-mode", + "title": { + "en": "Set battery to Zero mode", + "nl": "Zet batterij in Zero modus" + }, + "titleFormatted": { + "en": "Set battery to Zero mode", + "nl": "Zet batterij in Zero modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM230_v2" + } + ] + }, + { + "id": "sdm230-set-battery-to-standby-mode", + "title": { + "en": "Set battery to Standby mode", + "nl": "Zet batterij in Standby modus" + }, + "titleFormatted": { + "en": "Set battery to Standby mode", + "nl": "Zet batterij in Standby modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM230_v2" + } + ] + }, + { + "id": "sdm230-set-battery-to-zero-charge-only-mode", + "title": { + "en": "Set battery to Zero Charge Only mode", + "nl": "Zet batterij in Alleen Opladen modus" + }, + "titleFormatted": { + "en": "Set battery to Zero Charge Only mode", + "nl": "Zet batterij in Alleen Opladen modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM230_v2" + } + ] + }, + { + "id": "sdm230-set-battery-to-zero-discharge-only-mode", + "title": { + "en": "Set battery to Zero Discharge Only mode", + "nl": "Zet batterij in Alleen Ontladen modus" + }, + "titleFormatted": { + "en": "Set battery to Zero Discharge Only mode", + "nl": "Zet batterij in Alleen Ontladen modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM230_v2" + } + ] + }, + { + "id": "sdm630-set-battery-to-full-charge-mode", + "title": { + "en": "Set battery to Full Charge mode", + "nl": "Zet batterij in Volledig Oplaad modus" + }, + "titleFormatted": { + "en": "Set battery to Full Charge mode", + "nl": "Zet batterij in Volledig Oplaad modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM630_v2" + } + ] + }, + { + "id": "sdm630-set-battery-to-zero-mode", + "title": { + "en": "Set battery to Zero mode", + "nl": "Zet batterij in Zero modus" + }, + "titleFormatted": { + "en": "Set battery to Zero mode", + "nl": "Zet batterij in Zero modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM630_v2" + } + ] + }, + { + "id": "sdm630-set-battery-to-standby-mode", + "title": { + "en": "Set battery to Standby mode", + "nl": "Zet batterij in Standby modus" + }, + "titleFormatted": { + "en": "Set battery to Standby mode", + "nl": "Zet batterij in Standby modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM630_v2" + } + ] + }, + { + "id": "sdm630-set-battery-to-zero-charge-only-mode", + "title": { + "en": "Set battery to Zero Charge Only mode", + "nl": "Zet batterij in Alleen Opladen modus" + }, + "titleFormatted": { + "en": "Set battery to Zero Charge Only mode", + "nl": "Zet batterij in Alleen Opladen modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM630_v2" + } + ] + }, + { + "id": "sdm630-set-battery-to-zero-discharge-only-mode", + "title": { + "en": "Set battery to Zero Discharge Only mode", + "nl": "Zet batterij in Alleen Ontladen modus" + }, + "titleFormatted": { + "en": "Set battery to Zero Discharge Only mode", + "nl": "Zet batterij in Alleen Ontladen modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=SDM630_v2" + } + ] + }, + { + "id": "set-battery-to-full-charge-mode", + "title": { + "en": "Set battery to Full Charge mode", + "nl": "Zet batterij in Volledig Oplaad modus" + }, + "titleFormatted": { + "en": "Set battery to Full Charge mode", + "nl": "Zet batterij in Volledig Oplaad modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy_v2" + } + ] + }, + { + "id": "set-battery-to-zero-mode", + "title": { + "en": "Set battery to Zero mode", + "nl": "Zet batterij in Zero modus" + }, + "titleFormatted": { + "en": "Set battery to Zero mode", + "nl": "Zet batterij in Nul op de meter modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy_v2" + } + ] + }, + { + "id": "set-battery-to-standby-mode", + "title": { + "en": "Set battery to Standby mode", + "nl": "Zet batterij in Standby modus" + }, + "titleFormatted": { + "en": "Set battery to Standby mode", + "nl": "Zet batterij in Standby modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy_v2" + } + ] + }, + { + "id": "set-battery-to-zero-charge-only-mode", + "title": { + "en": "Set battery to Zero Charge Only mode", + "nl": "Zet batterij in Nul op de meter alleen opladen modus" + }, + "titleFormatted": { + "en": "Set battery to Zero Charge Only mode", + "nl": "Zet batterij in Nul op de meter alleen opladen modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy_v2" + } + ] + }, + { + "id": "set-battery-to-zero-discharge-only-mode", + "title": { + "en": "Set battery to Zero Discharge Only mode", + "nl": "Zet batterij in Nul op de meter, alleen ontladen modus" + }, + "titleFormatted": { + "en": "Set battery to Zero Discharge Only mode", + "nl": "Zet batterij in Nul op de meter, alleen ontladen modus" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=energy_v2" + } + ] + }, + { + "id": "set_preset", + "title": { + "en": "Activate preset", + "nl": "Activeer preset" + }, + "titleFormatted": { + "en": "Active preset [[preset]]", + "nl": "Activeer preset [[preset]]" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=homewizard" + }, + { + "name": "preset", + "type": "dropdown", + "values": [ + { + "id": "0", + "label": { + "en": "Home", + "nl": "Thuis" + } + }, + { + "id": "1", + "label": { + "en": "Away", + "nl": "Afwezig" + } + }, + { + "id": "2", + "label": { + "en": "Sleep", + "nl": "Slapen" + } + }, + { + "id": "3", + "label": { + "en": "Holiday", + "nl": "Vakantie" + } + } + ] + } + ] + }, + { + "id": "switch_scene_on", + "title": { + "en": "Switch scene on", + "nl": "Zet scene aan" + }, + "titleFormatted": { + "en": "Active scene [[scene]]", + "nl": "Activeer scene [[scene]]" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=homewizard" + }, + { + "name": "scene", + "type": "autocomplete" + } + ] + }, + { + "id": "switch_scene_off", + "title": { + "en": "Switch scene off", + "nl": "Zet scene uit" + }, + "titleFormatted": { + "en": "Deactive scene [[scene]]", + "nl": "Deactiveer scene [[scene]]" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=homewizard" + }, + { + "name": "scene", + "type": "autocomplete" + } + ] + }, + { + "id": "heatlink_off", + "title": { + "en": "Heatlink off", + "nl": "Zet heatlink uit" + }, + "titleFormatted": { + "en": "Deactive heatlink [[device]]", + "nl": "Deactiveer heatlink [[device]]" + }, + "args": [ + { + "type": "device", + "name": "device", + "filter": "driver_id=homewizard" + } + ] + } + ] + }, + "drivers": [ + { + "name": { + "en": "kWh Meter (1 phase)" + }, + "images": { + "large": "drivers/SDM230/assets/images/large.png", + "small": "drivers/SDM230/assets/images/small.png" + }, + "class": "socket", + "discovery": "SDM230", + "platforms": [ + "local" + ], + "capabilities": [ + "measure_power", + "meter_power", + "measure_current", + "measure_power.l1", + "measure_voltage", + "alarm_connectivity", + "rssi" + ], + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + }, + "insights": true + }, + "measure_voltage": { + "title": { + "en": "Current Voltage", + "nl": "Huidig Voltage" + }, + "insights": true + }, + "measure_current": { + "title": { + "en": "Current Amp", + "nl": "Huidig Amp" + }, + "insights": true + } + }, + "pair": [ + { + "id": "start", + "navigation": { + "next": "list_devices" + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ], + "id": "SDM230", + "settings": [ + { + "id": "polling_interval", + "type": "number", + "label": { + "en": "Polling interval" + }, + "value": 10, + "min": 1, + "unit": { + "en": "s" + } + }, + { + "id": "cloud", + "type": "number", + "label": { + "en": "Cloud connection 1=on 0=off", + "nl": "Cloud verbinding 1=actief 0=uit" + }, + "value": 1 + }, + { + "id": "url", + "type": "text", + "label": { + "en": "url", + "nl": "url" + } + } + ] + }, + { + "name": { + "en": "kWh Meter (1 phase) P1 mode" + }, + "images": { + "large": "drivers/SDM230-p1mode/assets/images/large.png", + "small": "drivers/SDM230-p1mode/assets/images/small.png" + }, + "class": "socket", + "discovery": "SDM230", + "platforms": [ + "local" + ], + "capabilities": [ + "measure_power", + "meter_power", + "measure_current", + "measure_power.l1", + "measure_voltage", + "rssi" + ], + "energy": { + "cumulative": true, + "cumulativeImportedCapability": "meter_power.consumed.t1", + "cumulativeExportedCapability": "meter_power.produced.t1" + }, + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + }, + "insights": true + }, + "measure_voltage": { + "title": { + "en": "Current Voltage", + "nl": "Huidig Voltage" + }, + "insights": true + }, + "measure_current": { + "title": { + "en": "Current Amp", + "nl": "Huidig Amp" + }, + "insights": true + } + }, + "pair": [ + { + "id": "start", + "navigation": { + "next": "list_devices" + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ], + "id": "SDM230-p1mode", + "settings": [ + { + "id": "polling_interval", + "type": "number", + "label": { + "en": "Polling interval" + }, + "value": 10, + "min": 1, + "unit": { + "en": "s" + } + }, + { + "id": "cloud", + "type": "number", + "label": { + "en": "Cloud connection 1=on 0=off", + "nl": "Cloud verbinding 1=actief 0=uit" + }, + "value": 1 + }, + { + "id": "url", + "type": "text", + "label": { + "en": "url", + "nl": "url" + } + }, + { + "id": "baseload_notifications", + "type": "checkbox", + "label": { + "en": "Enable baseload notifications", + "nl": "Sluipverbruik meldingen inschakelen" + }, + "value": true + } + ] + }, + { + "name": { + "en": "kWh Meter 1P (APIv2)" + }, + "images": { + "large": "drivers/SDM230_v2/assets/images/large.png", + "small": "drivers/SDM230_v2/assets/images/small.png" + }, + "class": "socket", + "discovery": "SDM230_v2", + "platforms": [ + "local" + ], + "capabilities": [ + "measure_power", + "meter_power", + "measure_current", + "meter_power.import", + "meter_power.export", + "measure_voltage" + ], + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "meter_power.import": { + "decimals": 3, + "title": { + "en": "Total usage", + "nl": "Totaal gebruik" + } + }, + "meter_power.export": { + "decimals": 3, + "title": { + "en": "Total deliver", + "nl": "Totaal teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + }, + "insights": true + }, + "measure_voltage": { + "title": { + "en": "Current Voltage", + "nl": "Huidig Voltage" + }, + "insights": true + }, + "measure_current": { + "title": { + "en": "Current Amp", + "nl": "Huidig Amp" + }, + "insights": true + }, + "measure_power.battery_group_power_w": { + "title": { + "en": "Battery group Current combined Power", + "nl": "Battery groep Huidig samengesteld vermogen" + } + }, + "measure_power.battery_group_target_power_w": { + "title": { + "en": "Battery group Target Power", + "nl": "Battery groep Doel vermogen" + } + }, + "measure_power.battery_group_max_consumption_w": { + "title": { + "en": "Battery group Max allowed Consumption Power", + "nl": "Battery groep Max toegestaand gebruiksvermogen" + } + }, + "measure_power.battery_group_max_production_w": { + "title": { + "en": "Battery group Max allowed Production Power", + "nl": "Battery groep Max toegestaand leveringssvermogen" + } + } + }, + "pair": [ + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "authorize" + }, + "options": { + "singular": true + } + }, + { + "id": "authorize", + "navigation": { + "prev": "list_devices" + } + } + ], + "id": "SDM230_v2", + "settings": [ + { + "id": "polling_interval", + "type": "number", + "label": { + "en": "Polling interval" + }, + "value": 10, + "min": 1, + "unit": { + "en": "s" + } + } + ] + }, + { + "name": { + "en": "kWh Meter (3 phase)" + }, + "images": { + "large": "drivers/SDM630/assets/images/large.png", + "small": "drivers/SDM630/assets/images/small.png" + }, + "class": "socket", + "discovery": "SDM630", + "platforms": [ + "local" + ], + "capabilities": [ + "measure_power", + "meter_power", + "meter_power.l1", + "meter_power.l2", + "meter_power.l3", + "measure_power.l1", + "measure_power.l2", + "measure_power.l3", + "meter_power.day.l1", + "meter_power.day.l2", + "meter_power.day.l3", + "measure_current.l1", + "measure_current.l2", + "measure_current.l3", + "measure_voltage.l1", + "measure_voltage.l2", + "measure_voltage.l3", + "rssi" + ], + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "measure_power.l2": { + "title": { + "en": "Current usage phase 2", + "nl": "Huidig gebruik fase 2" + } + }, + "measure_power.l3": { + "title": { + "en": "Current usage phase 3", + "nl": "Huidig gebruik fase 3" + } + }, + "meter_power.day.l1": { + "decimals": 3, + "title": { + "en": "Daily usage Phase 1", + "nl": "Dagverbruik Fase 1" + } + }, + "meter_power.day.l2": { + "decimals": 3, + "title": { + "en": "Daily usage Phase 2", + "nl": "Dagverbruik Fase 2" + } + }, + "meter_power.day.l3": { + "decimals": 3, + "title": { + "en": "Daily usage Phase 3", + "nl": "Dagverbruik Fase 3" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + } + }, + "meter_power.l1": { + "decimals": 3, + "title": { + "en": "Total usage KWh Phase 1", + "nl": "Totaal verbruik KWh Fase 1" + } + }, + "meter_power.l2": { + "decimals": 3, + "title": { + "en": "Total usage KWh Phase 2", + "nl": "Totaal verbruik KWh Fase 2" + } + }, + "meter_power.l3": { + "decimals": 3, + "title": { + "en": "Total usage KWh Phase 3", + "nl": "Totaal verbruik KWh Fase 3" + } + }, + "measure_current.l1": { + "title": { + "en": "Current Amp phase 1", + "nl": "Huidig Amp fase 1" + } + }, + "measure_current.l2": { + "title": { + "en": "Current Amp phase 2", + "nl": "Huidig Amp fase 2" + } + }, + "measure_current.l3": { + "title": { + "en": "Current Amp phase 3", + "nl": "Huidig Amp fase 3" + } + }, + "measure_voltage.l1": { + "title": { + "en": "Current Voltage phase 1", + "nl": "Huidig Voltage fase 1" + } + }, + "measure_voltage.l2": { + "title": { + "en": "Current Voltage phase 2", + "nl": "Huidig Voltage fase 2" + } + }, + "measure_voltage.l3": { + "title": { + "en": "Current Voltage phase 3", + "nl": "Huidig Voltage fase 3" + } + } + }, + "pair": [ + { + "id": "start", + "navigation": { + "next": "list_devices" + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ], + "id": "SDM630", + "settings": [ + { + "id": "polling_interval", + "type": "number", + "label": { + "en": "Polling interval" + }, + "value": 10, + "min": 1, + "unit": { + "en": "s" + } + }, + { + "id": "cloud", + "type": "number", + "label": { + "en": "Cloud connection 1=on 0=off", + "nl": "Cloud verbinding 1=actief 0=uit" + }, + "value": 1 + }, + { + "id": "url", + "type": "text", + "label": { + "en": "url", + "nl": "url" + } + } + ] + }, + { + "name": { + "en": "kWh Meter (3 phase) P1 mode" + }, + "images": { + "large": "drivers/SDM630-p1mode/assets/images/large.png", + "small": "drivers/SDM630-p1mode/assets/images/small.png" + }, + "class": "socket", + "discovery": "SDM630", + "platforms": [ + "local" + ], + "capabilities": [ + "measure_power", + "meter_power", + "measure_power.l1", + "measure_power.l2", + "measure_power.l3", + "measure_current.l1", + "measure_current.l2", + "measure_current.l3", + "measure_voltage.l1", + "measure_voltage.l2", + "measure_voltage.l3", + "rssi" + ], + "energy": { + "cumulative": true, + "cumulativeImportedCapability": "meter_power.consumed.t1", + "cumulativeExportedCapability": "meter_power.produced.t1" + }, + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + } + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "measure_power.l2": { + "title": { + "en": "Current usage phase 2", + "nl": "Huidig gebruik fase 2" + } + }, + "measure_power.l3": { + "title": { + "en": "Current usage phase 3", + "nl": "Huidig gebruik fase 3" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + } + }, + "measure_current.l1": { + "title": { + "en": "Current Amp phase 1", + "nl": "Huidig Amp fase 1" + } + }, + "measure_current.l2": { + "title": { + "en": "Current Amp phase 2", + "nl": "Huidig Amp fase 2" + } + }, + "measure_current.l3": { + "title": { + "en": "Current Amp phase 3", + "nl": "Huidig Amp fase 3" + } + }, + "measure_voltage.l1": { + "title": { + "en": "Current Voltage phase 1", + "nl": "Huidig Voltage fase 1" + } + }, + "measure_voltage.l2": { + "title": { + "en": "Current Voltage phase 2", + "nl": "Huidig Voltage fase 2" + } + }, + "measure_voltage.l3": { + "title": { + "en": "Current Voltage phase 3", + "nl": "Huidig Voltage fase 3" + } + } + }, + "pair": [ + { + "id": "start", + "navigation": { + "next": "list_devices" + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ], + "id": "SDM630-p1mode", + "settings": [ + { + "id": "polling_interval", + "type": "number", + "label": { + "en": "Polling interval" + }, + "value": 10, + "unit": { + "en": "s" + } + }, + { + "id": "url", + "type": "text", + "label": { + "en": "url", + "nl": "url" + } + } + ] + }, + { + "name": { + "en": "kWh Meter 3P (APIv2)" + }, + "images": { + "large": "drivers/SDM630_v2/assets/images/large.png", + "small": "drivers/SDM630_v2/assets/images/small.png" + }, + "class": "socket", + "discovery": "SDM630_v2", + "platforms": [ + "local" + ], + "capabilities": [ + "measure_power", + "meter_power", + "meter_power.import", + "meter_power.export", + "measure_power.l1", + "measure_power.l2", + "measure_power.l3", + "measure_current", + "measure_current.l1", + "measure_current.l2", + "measure_current.l3", + "measure_voltage.l1", + "measure_voltage.l2", + "measure_voltage.l3" + ], + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "meter_power.import": { + "decimals": 3, + "title": { + "en": "Total usage", + "nl": "Totaal gebruik" + } + }, + "meter_power.export": { + "decimals": 3, + "title": { + "en": "Total deliver", + "nl": "Totaal teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + }, + "insights": true + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "measure_power.l2": { + "title": { + "en": "Current usage phase 2", + "nl": "Huidig gebruik fase 2" + } + }, + "measure_power.l3": { + "title": { + "en": "Current usage phase 3", + "nl": "Huidig gebruik fase 3" + } + }, + "measure_current": { + "title": { + "en": "Current Amp", + "nl": "Huidig Amp" + } + }, + "measure_current.l1": { + "title": { + "en": "Current Amp phase 1", + "nl": "Huidig Amp fase 1" + } + }, + "measure_current.l2": { + "title": { + "en": "Current Amp phase 2", + "nl": "Huidig Amp fase 2" + } + }, + "measure_current.l3": { + "title": { + "en": "Current Amp phase 3", + "nl": "Huidig Amp fase 3" + } + }, + "measure_power.battery_group_power_w": { + "title": { + "en": "Battery group Current combined Power", + "nl": "Battery groep Huidig samengesteld vermogen" + } + }, + "measure_power.battery_group_target_power_w": { + "title": { + "en": "Battery group Target Power", + "nl": "Battery groep Doel vermogen" + } + }, + "measure_power.battery_group_max_consumption_w": { + "title": { + "en": "Battery group Max allowed Consumption Power", + "nl": "Battery groep Max toegestaand gebruiksvermogen" + } + }, + "measure_power.battery_group_max_production_w": { + "title": { + "en": "Battery group Max allowed Production Power", + "nl": "Battery groep Max toegestaand leveringssvermogen" + } + } + }, + "pair": [ + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "authorize" + }, + "options": { + "singular": true + } + }, + { + "id": "authorize", + "navigation": { + "prev": "list_devices" + } + } + ], + "id": "SDM630_v2", + "settings": [ + { + "id": "polling_interval", + "type": "number", + "label": { + "en": "Polling interval" + }, + "value": 10, + "min": 1, + "unit": { + "en": "s" + } + } + ] + }, + { + "name": { + "en": "P1 Meter (Cloud)", + "nl": "P1 Meter (Cloud)" + }, + "class": "sensor", + "capabilities": [], + "capabilitiesOptions": { + "meter_power.peak": { + "title": { + "en": "Power meter tariff 1", + "nl": "Energiemeter tarief 1" + } + }, + "meter_power.offpeak": { + "title": { + "en": "Power meter tariff 2", + "nl": "Energiemeter tarief 2" + } + }, + "meter_power.producedPeak": { + "title": { + "en": "Production tariff 1", + "nl": "Productie tarief 1" + } + }, + "meter_power.producedOffpeak": { + "title": { + "en": "Production tariff 2", + "nl": "Productie tarief 2" + } + }, + "meter_power.returned": { + "title": { + "en": "Returned Power", + "nl": "Teruggeleverde Energie" + } + }, + "measure_voltage.l1": { + "title": { + "en": "Voltage L1", + "nl": "Spanning L1" + } + }, + "measure_current.l1": { + "title": { + "en": "Current L1", + "nl": "Stroom L1" + } + } + }, + "energy": { + "cumulative": true, + "cumulativeImportedCapability": "meter_power", + "cumulativeExportedCapability": "meter_power.returned" + }, + "platforms": [ + "local" + ], + "connectivity": [ + "cloud" + ], + "images": { + "small": "/drivers/cloud_p1/assets/images/small.png", + "large": "/drivers/cloud_p1/assets/images/large.png", + "xlarge": "/drivers/cloud_p1/assets/images/xlarge.png" + }, + "pair": [ + { + "id": "login" + } + ], + "repair": [ + { + "id": "login" + } + ], + "settings": [ + { + "type": "group", + "label": { + "en": "Cloud Connection", + "nl": "Cloud Verbinding" + }, + "children": [ + { + "id": "cloud_email", + "type": "text", + "label": { + "en": "Email", + "nl": "E-mail" + }, + "value": "", + "hint": { + "en": "Your HomeWizard Energy account email", + "nl": "Uw HomeWizard Energy account e-mail" + } + }, + { + "id": "cloud_password", + "type": "password", + "label": { + "en": "Password", + "nl": "Wachtwoord" + }, + "value": "", + "hint": { + "en": "Your HomeWizard Energy account password", + "nl": "Uw HomeWizard Energy account wachtwoord" + } + }, + { + "id": "location_id", + "type": "text", + "label": { + "en": "Location ID", + "nl": "Locatie ID" + }, + "value": "", + "hint": { + "en": "Internal location identifier", + "nl": "Interne locatie-identificatie" + } + }, + { + "id": "location_name", + "type": "text", + "label": { + "en": "Location Name", + "nl": "Locatie Naam" + }, + "value": "", + "hint": { + "en": "Name of your home in HomeWizard Energy app", + "nl": "Naam van uw woning in HomeWizard Energy app" + } + } + ] + } + ], + "id": "cloud_p1" + }, + { + "id": "cloud_watermeter", + "name": { + "en": "Watermeter (cloud)", + "nl": "Watermeter (cloud)" + }, + "class": "sensor", + "platforms": [ + "local" + ], + "capabilities": [ + "meter_water", + "meter_water.daily" + ], + "capabilitiesOptions": { + "meter_water.daily": { + "decimals": 3, + "title": { + "en": "Water usage today", + "nl": "Waterverbruik vandaag" + } + }, + "meter_water": { + "decimals": 3, + "title": { + "en": "Water usage total", + "nl": "Waterverbruik totaal" + } + } + }, + "energy": { + "cumulative": true + }, + "images": { + "small": "/drivers/cloud_watermeter/assets/images/small.png", + "large": "/drivers/cloud_watermeter/assets/images/large.png", + "xlarge": "/drivers/cloud_watermeter/assets/images/xlarge.png" + }, + "pair": [ + { + "id": "login", + "template": "login_credentials", + "options": { + "title": { + "en": "Login to HomeWizard", + "nl": "Inloggen bij HomeWizard" + }, + "usernameLabel": { + "en": "Email", + "nl": "E-mail" + }, + "usernamePlaceholder": { + "en": "your@email.com", + "nl": "jouw@email.com" + }, + "passwordLabel": { + "en": "Password", + "nl": "Wachtwoord" + }, + "passwordPlaceholder": { + "en": "Password", + "nl": "Wachtwoord" + } + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ], + "settings": [ + { + "type": "group", + "label": { + "en": "General settings", + "nl": "Algemene instellingen" + }, + "children": [ + { + "id": "poll_interval", + "type": "number", + "label": { + "en": "Polling interval (seconds)", + "nl": "Polling interval (seconden)" + }, + "value": 900, + "min": 300, + "max": 3600, + "hint": { + "en": "How often to fetch data from HomeWizard Cloud (default: 900s / 15 min)", + "nl": "Hoe vaak data ophalen van HomeWizard Cloud (standaard: 900s / 15 min)" + } + }, + { + "id": "manual_offset", + "type": "number", + "label": { + "en": "Manual offset (m³)", + "nl": "Handmatige correctie (m³)" + }, + "value": 0, + "hint": { + "en": "Add or subtract from the total meter reading. Use this to match your water company's meter reading.", + "nl": "Tel op of trek af van de totale meterstand. Gebruik dit om overeen te komen met de meterstand van je waterbedrijf." + } + } + ] + } + ] + }, + { + "name": { + "en": "P1 Meter" + }, + "images": { + "large": "drivers/energy/assets/images/large.png", + "small": "drivers/energy/assets/images/small.png" + }, + "class": "sensor", + "discovery": "energy", + "platforms": [ + "local" + ], + "capabilities": [ + "identify", + "measure_power", + "meter_gas", + "measure_gas", + "meter_water", + "meter_power", + "meter_power.consumed.t1", + "meter_power.produced.t1", + "meter_power.consumed.t2", + "meter_power.produced.t2", + "meter_power.consumed.t3", + "meter_power.produced.t3", + "meter_power.consumed", + "meter_power.returned", + "meter_power.daily", + "measure_power.l1", + "measure_power.l2", + "measure_power.l3", + "measure_current.l1", + "measure_current.l2", + "measure_current.l3", + "net_load_phase1", + "net_load_phase2", + "net_load_phase3", + "net_load_phase1_pct", + "net_load_phase2_pct", + "net_load_phase3_pct", + "measure_voltage.l1", + "measure_voltage.l2", + "measure_voltage.l3", + "measure_power.montly_power_peak", + "rssi", + "wifi_quality", + "tariff", + "long_power_fail_count", + "voltage_sag_l1", + "voltage_sag_l2", + "voltage_sag_l3", + "voltage_swell_l1", + "voltage_swell_l2", + "voltage_swell_l3", + "connection_error", + "measure_frequency", + "alarm_connectivity" + ], + "energy": { + "cumulative": true, + "cumulativeImportedCapability": "meter_power.consumed", + "cumulativeExportedCapability": "meter_power.returned" + }, + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + } + }, + "meter_gas": { + "decimals": 3, + "title": { + "en": "Gasmeter", + "nl": "Gasmeter" + } + }, + "meter_gas.daily": { + "decimals": 3, + "title": { + "en": "Day Usage Gas", + "nl": "Dag verbruik Gas" + } + }, + "meter_water": { + "decimals": 3, + "title": { + "en": "Watermeter", + "nl": "Watermeter" + } + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "measure_power.l2": { + "title": { + "en": "Current usage phase 2", + "nl": "Huidig gebruik fase 2" + } + }, + "measure_power.l3": { + "title": { + "en": "Current usage phase 3", + "nl": "Huidig gebruik fase 3" + } + }, + "measure_current.l1": { + "title": { + "en": "Current Amp phase 1", + "nl": "Huidig Amp fase 1" + } + }, + "measure_current.l2": { + "title": { + "en": "Current Amp phase 2", + "nl": "Huidig Amp fase 2" + } + }, + "measure_current.l3": { + "title": { + "en": "Current Amp phase 3", + "nl": "Huidig Amp fase 3" + } + }, + "net_load_phase1_pct": { + "title": { + "en": "Phase 1 load %", + "nl": "Fase 1 belasting %" + } + }, + "net_load_phase2_pct": { + "title": { + "en": "Phase 2 load %", + "nl": "Fase 2 belasting %" + } + }, + "net_load_phase3_pct": { + "title": { + "en": "Phase 3 load %", + "nl": "Fase 3 belasting %" + } + }, + "measure_voltage.l1": { + "title": { + "en": "Current Voltage phase 1", + "nl": "Huidig Voltage fase 1" + } + }, + "measure_voltage.l2": { + "title": { + "en": "Current Voltage phase 2", + "nl": "Huidig Voltage fase 2" + } + }, + "measure_voltage.l3": { + "title": { + "en": "Current Voltage phase 3", + "nl": "Huidig Voltage fase 3" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power.consumed.t2": { + "decimals": 3, + "title": { + "en": "Total t2 usage", + "nl": "Totaal t2 gebruik" + } + }, + "meter_power.produced.t2": { + "decimals": 3, + "title": { + "en": "Total t2 deliver", + "nl": "Totaal t2 teruglevering" + } + }, + "meter_power.consumed.t3": { + "decimals": 3, + "title": { + "en": "Total t3 usage", + "nl": "Totaal t3 gebruik" + } + }, + "meter_power.produced.t3": { + "decimals": 3, + "title": { + "en": "Total t3 deliver", + "nl": "Totaal t3 teruglevering" + } + }, + "meter_power.consumed": { + "decimals": 3, + "title": { + "en": "Sum Consumed", + "nl": "Som gebruik" + } + }, + "meter_power.returned": { + "decimals": 3, + "title": { + "en": "Sum returned", + "nl": "Som teruglevering" + } + }, + "meter_power.daily": { + "decimals": 3, + "title": { + "en": "Daily usage", + "nl": "Dag verbruik" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + } + }, + "measure_frequency": { + "decimals": 3, + "title": { + "en": "Current Frequency", + "nl": "Huidige Frequentie" + } + }, + "measure_power.montly_power_peak": { + "title": { + "en": "Monthly Power Peak", + "nl": "Maandelijks piekvermogen" + } + } + }, + "pair": [ + { + "id": "start", + "navigation": { + "next": "list_devices" + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ], + "id": "energy", + "settings": [ + { + "id": "polling_interval", + "type": "number", + "label": { + "en": "Polling interval" + }, + "value": 10, + "min": 1, + "unit": { + "en": "s" + } + }, + { + "id": "number_of_phases", + "type": "number", + "label": { + "en": "Amount of phase(s)", + "nl": "Aantal fase(s)" + }, + "value": 1 + }, + { + "id": "phase_capacity", + "type": "number", + "label": { + "en": "Phase capacity A", + "nl": "Fase capaciteit A" + }, + "value": 40, + "unit": { + "en": "A" + } + }, + { + "id": "cloud", + "type": "number", + "label": { + "en": "Cloud connection 1=on 0=off", + "nl": "Cloud verbinding 1=actief 0=uit" + }, + "value": 1 + }, + { + "id": "url", + "type": "text", + "label": { + "en": "url", + "nl": "url" + } + }, + { + "id": "show_gas", + "type": "checkbox", + "label": { + "en": "Show gas meter", + "nl": "Gas meter weergeven" + }, + "value": true + }, + { + "id": "baseload_notifications", + "type": "checkbox", + "label": { + "en": "Enable baseload notifications", + "nl": "Sluipverbruik meldingen inschakelen" + }, + "value": true + }, + { + "id": "phase_overload_notifications", + "type": "checkbox", + "label": { + "en": "Phase overload notifications", + "nl": "Fase-overbelasting meldingen" + }, + "value": true + }, + { + "id": "phase_overload_threshold", + "type": "number", + "label": { + "en": "Phase overload warning threshold (%)", + "nl": "Fase overbelasting waarschuwing (%)" + }, + "value": 97, + "min": 50, + "max": 120, + "step": 1 + }, + { + "id": "phase_overload_reset", + "type": "number", + "label": { + "en": "Phase overload reset threshold (%)", + "nl": "Fase overbelasting reset (%)" + }, + "value": 85, + "min": 20, + "max": 100, + "step": 1 + } + ] + }, + { + "name": { + "en": "Energy Socket" + }, + "images": { + "large": "drivers/energy_socket/assets/images/large.png", + "small": "drivers/energy_socket/assets/images/small.png" + }, + "class": "socket", + "discovery": "energy_socket", + "platforms": [ + "local" + ], + "capabilities": [ + "onoff", + "dim", + "identify", + "locked", + "measure_power", + "meter_power", + "meter_power.consumed.t1", + "meter_power.produced.t1", + "measure_power.l1", + "rssi", + "connection_error", + "alarm_connectivity" + ], + "energy": { + "meterPowerImportedCapability": "meter_power.consumed.t1", + "meterPowerExportedCapability": "meter_power.produced.t1" + }, + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + } + }, + "measure_voltage.l1": { + "title": { + "en": "Current Voltage phase 1", + "nl": "Huidig Voltage fase 1" + }, + "insights": true + } + }, + "settings": [ + { + "type": "group", + "label": { + "en": "Energy Socket settings", + "nl": "Energy Socket instellingen" + }, + "children": [ + { + "id": "offset_socket", + "type": "number", + "label": { + "en": "Offset Watt usage", + "nl": "Compensatie watt gebruik" + }, + "value": 0, + "unit": { + "en": "watt", + "nl": "watt" + } + }, + { + "id": "offset_polling", + "type": "number", + "label": { + "en": "Polling in seconds", + "nl": "Interval in seconden" + }, + "value": 10, + "min": 1 + }, + { + "id": "cloud", + "type": "number", + "label": { + "en": "Cloud connection 1=on 0=off", + "nl": "Cloud verbinding 1=actief 0=uit" + }, + "value": 1 + } + ] + } + ], + "pair": [ + { + "id": "start", + "navigation": { + "next": "list_devices" + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ], + "id": "energy_socket" + }, + { + "name": { + "en": "P1 Meter (apiv2)" + }, + "images": { + "large": "drivers/energy_v2/assets/images/large.png", + "small": "drivers/energy_v2/assets/images/small.png" + }, + "class": "sensor", + "discovery": "energy_v2", + "platforms": [ + "local" + ], + "capabilities": [ + "identify", + "measure_power", + "meter_gas", + "meter_gas.daily", + "meter_water", + "meter_power", + "meter_power.consumed.t1", + "meter_power.produced.t1", + "meter_power.consumed.t2", + "meter_power.produced.t2", + "meter_power.consumed.t3", + "meter_power.produced.t3", + "meter_power.consumed.t4", + "meter_power.produced.t4", + "meter_power.consumed", + "meter_power.returned", + "meter_power.daily", + "measure_power.l1", + "measure_power.l2", + "measure_power.l3", + "measure_current", + "measure_current.l1", + "measure_current.l2", + "measure_current.l3", + "net_load_phase1_pct", + "net_load_phase2_pct", + "net_load_phase3_pct", + "measure_voltage.l1", + "measure_voltage.l2", + "measure_voltage.l3", + "measure_power.montly_power_peak", + "measure_power.average_power_15m_w", + "tariff", + "long_power_fail_count", + "voltage_sag_l1", + "voltage_sag_l2", + "voltage_sag_l3", + "voltage_swell_l1", + "voltage_swell_l2", + "voltage_swell_l3", + "rssi", + "wifi_quality", + "measure_power.battery_group_power_w", + "measure_power.battery_group_target_power_w", + "measure_power.battery_group_max_consumption_w", + "measure_power.battery_group_max_production_w", + "connection_error", + "battery_group_total_capacity_kwh", + "battery_group_average_soc", + "battery_group_state", + "battery_group_charge_mode" + ], + "energy": { + "cumulative": true, + "cumulativeImportedCapability": "meter_power.consumed", + "cumulativeExportedCapability": "meter_power.returned" + }, + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + } + }, + "meter_gas": { + "decimals": 3, + "title": { + "en": "Gasmeter", + "nl": "Gasmeter" + } + }, + "meter_gas.daily": { + "decimals": 3, + "title": { + "en": "Day Usage Gas", + "nl": "Dag verbruik Gas" + } + }, + "meter_water": { + "decimals": 3, + "title": { + "en": "Watermeter", + "nl": "Watermeter" + } + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "measure_power.l2": { + "title": { + "en": "Current usage phase 2", + "nl": "Huidig gebruik fase 2" + } + }, + "measure_power.l3": { + "title": { + "en": "Current usage phase 3", + "nl": "Huidig gebruik fase 3" + } + }, + "measure_current": { + "title": { + "en": "Current Amp", + "nl": "Huidig Amp" + } + }, + "measure_current.l1": { + "title": { + "en": "Current Amp phase 1", + "nl": "Huidig Amp fase 1" + } + }, + "measure_current.l2": { + "title": { + "en": "Current Amp phase 2", + "nl": "Huidig Amp fase 2" + } + }, + "measure_current.l3": { + "title": { + "en": "Current Amp phase 3", + "nl": "Huidig Amp fase 3" + } + }, + "net_load_phase1_pct": { + "title": { + "en": "Phase 1 load %", + "nl": "Fase 1 belasting %" + } + }, + "net_load_phase2_pct": { + "title": { + "en": "Phase 2 load %", + "nl": "Fase 2 belasting %" + } + }, + "net_load_phase3_pct": { + "title": { + "en": "Phase 3 load %", + "nl": "Fase 3 belasting %" + } + }, + "measure_voltage": { + "title": { + "en": "Current Voltage", + "nl": "Huidig Voltage" + } + }, + "measure_voltage.l1": { + "title": { + "en": "Current Voltage phase 1", + "nl": "Huidig Voltage fase 1" + } + }, + "measure_voltage.l2": { + "title": { + "en": "Current Voltage phase 2", + "nl": "Huidig Voltage fase 2" + } + }, + "measure_voltage.l3": { + "title": { + "en": "Current Voltage phase 3", + "nl": "Huidig Voltage fase 3" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power.consumed.t2": { + "decimals": 3, + "title": { + "en": "Total t2 usage", + "nl": "Totaal t2 gebruik" + } + }, + "meter_power.produced.t2": { + "decimals": 3, + "title": { + "en": "Total t2 deliver", + "nl": "Totaal t2 teruglevering" + } + }, + "meter_power.consumed.t3": { + "decimals": 3, + "title": { + "en": "Total t3 usage", + "nl": "Totaal t3 gebruik" + } + }, + "meter_power.produced.t3": { + "decimals": 3, + "title": { + "en": "Total t3 deliver", + "nl": "Totaal t3 teruglevering" + } + }, + "meter_power.consumed.t4": { + "decimals": 3, + "title": { + "en": "Total t4 usage", + "nl": "Totaal t4 gebruik" + } + }, + "meter_power.produced.t4": { + "decimals": 3, + "title": { + "en": "Total t4 deliver", + "nl": "Totaal t4 teruglevering" + } + }, + "meter_power.consumed": { + "decimals": 3, + "title": { + "en": "Sum Consumed", + "nl": "Som gebruik" + } + }, + "meter_power.returned": { + "decimals": 3, + "title": { + "en": "Sum returned", + "nl": "Som teruglevering" + } + }, + "meter_power.daily": { + "decimals": 3, + "title": { + "en": "Daily usage", + "nl": "Dag verbruik" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + } + }, + "measure_power.montly_power_peak": { + "title": { + "en": "Monthly Power Peak", + "nl": "Maandelijks piekvermogen" + } + }, + "measure_power.average_power_15m_w": { + "title": { + "en": "Active average power over 15 minutes", + "nl": "Actief gemiddeld vermogen over 15 minuten" + } + }, + "measure_power.battery_group_power_w": { + "title": { + "en": "Battery group Current combined Power", + "nl": "Battery groep Huidig samengesteld vermogen" + } + }, + "measure_power.battery_group_target_power_w": { + "title": { + "en": "Battery group Target Power", + "nl": "Battery groep Doel vermogen" + } + }, + "measure_power.battery_group_max_consumption_w": { + "title": { + "en": "Battery group Max allowed Consumption Power", + "nl": "Battery groep Max toegestaand gebruiksvermogen" + } + }, + "measure_power.battery_group_max_production_w": { + "title": { + "en": "Battery group Max allowed Production Power", + "nl": "Battery groep Max toegestaand leveringssvermogen" + } + }, + "battery_group_total_capacity_kwh": { + "type": "number", + "getable": true, + "setable": false, + "uiComponent": "sensor", + "icon": "assets/battery.svg", + "units": { + "en": "kWh" + }, + "title": { + "en": "Battery Group Total Capacity" + }, + "insights": false + }, + "battery_group_average_soc": { + "type": "number", + "getable": true, + "setable": false, + "uiComponent": "sensor", + "icon": "assets/battery.svg", + "units": { + "en": "%" + }, + "title": { + "en": "Battery Group Average SoC" + }, + "insights": true + }, + "battery_group_state": { + "type": "string", + "getable": true, + "setable": false, + "uiComponent": "sensor", + "icon": "assets/battery.svg", + "title": { + "en": "Battery Group Charge state", + "nl": "Battery Groep Laadt status" + }, + "insights": true + }, + "rssi": { + "type": "number", + "title": { + "en": "WiFi Signal", + "nl": "WiFi signaal" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "icon": "assets/rssi.svg", + "units": { + "en": "dBm", + "nl": "dBm" + } + } + }, + "pair": [ + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "authorize" + }, + "options": { + "singular": true + } + }, + { + "id": "authorize", + "navigation": { + "prev": "list_devices" + } + } + ], + "id": "energy_v2", + "settings": [ + { + "id": "mode", + "type": "dropdown", + "value": "plugin-battery", + "label": { + "en": "Plugin Battery mode", + "nl": "Plugin‑batterijmodus" + }, + "values": [ + { + "id": "zero", + "label": { + "en": "Zero mode", + "nl": "Nul op de meter" + } + }, + { + "id": "to_full", + "label": { + "en": "Full charge", + "nl": "Volledig opladen" + } + }, + { + "id": "standby", + "label": { + "en": "Standby", + "nl": "Stand‑by" + } + }, + { + "id": "zero_charge_only", + "label": { + "en": "Zero mode, Charge allowed", + "nl": "Nul op de meter, laden toegestaan" + } + }, + { + "id": "zero_discharge_only", + "label": { + "en": "Zero mode, Discharge allowed", + "nl": "Nul op de meter, ontladen toegestaan" + } + } + ] + }, + { + "id": "polling_interval", + "type": "number", + "label": { + "en": "Polling interval" + }, + "value": 10, + "min": 1, + "unit": { + "en": "s" + } + }, + { + "id": "grid_phase_amps", + "type": "number", + "label": { + "en": "Grid phase Amps", + "nl": "Net fase aansluiting" + }, + "value": 40, + "unit": { + "en": "A", + "nl": "A" + } + }, + { + "id": "cloud", + "type": "number", + "label": { + "en": "Cloud connection 1=on 0=off", + "nl": "Cloud verbinding 1=actief 0=uit" + }, + "value": 1 + }, + { + "id": "url", + "type": "text", + "label": { + "en": "url", + "nl": "url" + } + }, + { + "id": "use_polling", + "type": "checkbox", + "label": { + "en": "Use polling instead of WebSocket", + "nl": "Gebruik polling in plaats van WebSocket" + }, + "value": true + }, + { + "id": "show_gas", + "type": "checkbox", + "label": { + "en": "Show gas meter", + "nl": "Gas meter weergeven" + }, + "value": true + }, + { + "id": "phase_overload_notifications", + "type": "checkbox", + "label": { + "en": "Phase overload notifications", + "nl": "Fase-overbelasting meldingen" + }, + "value": true + } + ] + }, + { + "name": { + "en": "Energylink", + "nl": "Energylink" + }, + "images": { + "large": "drivers/energylink/assets/images/large.jpg", + "small": "drivers/energylink/assets/images/small.jpg" + }, + "platforms": [ + "local" + ], + "class": "sensor", + "capabilities": [ + "measure_power", + "meter_power.used", + "meter_power.aggr", + "meter_power.s1", + "meter_power.s2", + "meter_power", + "measure_power.used", + "measure_power.netto", + "measure_power.s1", + "measure_power.s2", + "measure_power.s1other", + "meter_power.s1other", + "measure_power.s2other", + "meter_power.s2other", + "meter_gas.today", + "meter_gas.reading", + "meter_water", + "measure_water", + "meter_power.consumed.t1", + "meter_power.produced.t1", + "meter_power.consumed.t2", + "meter_power.produced.t2" + ], + "energy": { + "cumulative": true, + "cumulativeImportedCapability": "meter_power.used", + "cumulativeExportedCapability": "meter_power.s1" + }, + "capabilitiesOptions": { + "meter_power.used": { + "decimals": 3, + "title": { + "en": "Day usage", + "nl": "Dag gebruik" + }, + "insights": true + }, + "meter_power.aggr": { + "decimals": 3, + "title": { + "en": "Overall usage", + "nl": "Netto gebruik" + }, + "insights": true + }, + "meter_power.s1": { + "decimals": 3, + "title": { + "en": "Day production S1", + "nl": "Dag opbrengst S1" + }, + "insights": true + }, + "meter_power.s2": { + "decimals": 3, + "title": { + "en": "Day production S2", + "nl": "Dag opbrengst S2" + }, + "insights": true + }, + "measure_power.s1other": { + "title": { + "en": "Power current S1 other", + "nl": "Huidig vermogen S1 other" + }, + "insights": true + }, + "meter_power.s1other": { + "decimals": 3, + "title": { + "en": "Day usage S1 other", + "nl": "Dag gebruik S1 other" + }, + "insights": true + }, + "measure_power.used": { + "title": { + "en": "Power current", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "measure_power.s2other": { + "title": { + "en": "Power current S2 other", + "nl": "Huidig vermogen S2 other" + }, + "insights": true + }, + "meter_power.s2other": { + "decimals": 3, + "title": { + "en": "Day usage S2 other", + "nl": "Dag gebruik S2 other" + }, + "insights": true + }, + "measure_power.netto": { + "title": { + "en": "Netto Power current", + "nl": "Netto Huidig vermogen" + } + }, + "measure_power.s1": { + "title": { + "en": "Solar current S1", + "nl": "Huidige opbrengst S1" + }, + "insights": true + }, + "measure_power.s2": { + "title": { + "en": "Solar current S2", + "nl": "Huidige opbrengst S2" + }, + "insights": true + }, + "meter_gas.today": { + "decimals": 3, + "title": { + "en": "Gas", + "nl": "Gas" + }, + "insights": true + }, + "meter_gas.reading": { + "decimals": 3, + "title": { + "en": "Meter reading gas", + "nl": "Meterstand Gas" + }, + "insights": true + }, + "meter_water": { + "decimals": 3, + "title": { + "en": "Water Total", + "nl": "Water Totaal" + }, + "insights": true + }, + "measure_water": { + "title": { + "en": "Water l./m", + "nl": "Water l./m" + }, + "insights": true + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Meter reading low", + "nl": "Stand laag tarief" + }, + "insights": true + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Meter reading produced low", + "nl": "Stand terug levering laag" + }, + "insights": true + }, + "meter_power.consumed.t2": { + "decimals": 3, + "title": { + "en": "Meter reading normal", + "nl": "Stand verbruik normaal" + }, + "insights": true + }, + "meter_power.produced.t2": { + "decimals": 3, + "title": { + "en": "Meter reading produced normal", + "nl": "Stand terug levering normaal" + }, + "insights": true + }, + "meter_power": { + "title": { + "en": "Aggregated meter", + "nl": "Geaggregeerde meterstand" + }, + "insights": true + } + }, + "pair": [ + { + "id": "start" + }, + { + "id": "list_my_devices", + "template": "list_devices", + "navigation": { + "next": "add_my_devices" + } + }, + { + "id": "add_my_devices", + "template": "add_devices" + } + ], + "id": "energylink" + }, + { + "name": { + "en": "Heatlink", + "nl": "Heatlink" + }, + "images": { + "large": "drivers/heatlink/assets/images/large.jpg", + "small": "drivers/heatlink/assets/images/small.jpg" + }, + "platforms": [ + "local" + ], + "class": "thermostat", + "capabilities": [ + "measure_temperature", + "target_temperature", + "measure_temperature.heatlink", + "measure_temperature.boiler", + "central_heating_pump", + "central_heating_flame", + "warm_water", + "measure_pressure" + ], + "capabilitiesOptions": { + "measure_temperature.heatlink": { + "title": { + "en": "Heatlink target temperature", + "nl": "Heatlink doel temperatuur" + } + }, + "measure_temperature.boiler": { + "title": { + "en": "Boiler temperature", + "nl": "Ketel temperatuur" + } + }, + "measure_pressure": { + "decimals": 1, + "title": { + "en": "Water pressure", + "nl": "Waterdruk" + }, + "units": { + "en": "Bar", + "nl": "Bar" + } + } + }, + "pair": [ + { + "id": "start" + }, + { + "id": "list_my_devices", + "template": "list_devices", + "navigation": { + "next": "add_my_devices" + } + }, + { + "id": "add_my_devices", + "template": "add_devices" + } + ], + "id": "heatlink" + }, + { + "name": { + "en": "HomeWizard Base Station", + "nl": "HomeWizard Base Station" + }, + "images": { + "large": "drivers/homewizard/assets/images/large.jpg", + "small": "drivers/homewizard/assets/images/small.jpg" + }, + "class": "other", + "platforms": [ + "local" + ], + "capabilities": [], + "pair": [ + { + "id": "start" + }, + { + "id": "list_my_devices", + "template": "list_devices", + "navigation": { + "next": "add_my_devices" + } + }, + { + "id": "add_my_devices", + "template": "add_devices" + } + ], + "settings": [ + { + "type": "group", + "label": { + "en": "HomeWizard settings", + "nl": "HomeWizard instellingen" + }, + "children": [ + { + "id": "homewizard_ip", + "type": "text", + "label": { + "en": "IP address", + "nl": "IP adres" + }, + "value": "" + }, + { + "id": "homewizard_pass", + "type": "text", + "label": { + "en": "Password", + "nl": "Wachtwoord" + }, + "value": "" + }, + { + "id": "homewizard_ledring", + "type": "checkbox", + "label": { + "en": "Use ledring", + "nl": "Gebruik ledring" + }, + "value": false + }, + { + "id": "poll_interval", + "type": "number", + "label": { + "en": "Polling interval (seconds)", + "nl": "Polling-interval (seconden)" + }, + "value": 30, + "min": 10, + "max": 3600 + } + ] + } + ], + "id": "homewizard" + }, + { + "name": { + "en": "Smoke, Motion and door senors", + "nl": "Rook, beweging en deur sensors" + }, + "images": { + "large": "drivers/kakusensors/assets/images/large.jpg", + "small": "drivers/kakusensors/assets/images/small.jpg" + }, + "platforms": [ + "local" + ], + "class": "sensor", + "capabilities": [], + "pair": [ + { + "id": "start" + }, + { + "id": "list_my_devices", + "template": "list_devices", + "navigation": { + "next": "add_my_devices" + } + }, + { + "id": "add_my_devices", + "template": "add_devices" + } + ], + "id": "kakusensors" + }, + { + "name": { + "en": "Plugin Battery" + }, + "images": { + "large": "drivers/plugin_battery/assets/images/large.png", + "small": "drivers/plugin_battery/assets/images/small.png" + }, + "class": "battery", + "discovery": "plugin_battery", + "platforms": [ + "local" + ], + "capabilities": [ + "identify", + "dim", + "meter_power.import", + "meter_power.export", + "measure_battery", + "battery_charging_state", + "measure_power", + "measure_current", + "measure_voltage", + "measure_frequency", + "cycles", + "rssi", + "time_to_full", + "time_to_empty", + "estimate_kwh" + ], + "energy": { + "homeBattery": true, + "meterPowerImportedCapability": "meter_power.import", + "meterPowerExportedCapability": "meter_power.export" + }, + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + } + }, + "meter_power.import": { + "decimals": 3, + "title": { + "en": "Total battery import", + "nl": "Totaal batterij import" + } + }, + "meter_power.export": { + "decimals": 3, + "title": { + "en": "Total Battery Export", + "nl": "Totaal Batterij export" + } + }, + "measure_voltage": { + "title": { + "en": "Current Voltage", + "nl": "Huidig Voltage" + } + }, + "measure_current": { + "title": { + "en": "Current Amp", + "nl": "Huidig Amp" + } + }, + "measure_battery": { + "title": { + "en": "Battery Level", + "nl": "Batterij niveau" + } + }, + "measure_frequency": { + "decimals": 3, + "title": { + "en": "Current Frequency", + "nl": "Huidige Frequentie" + } + }, + "battery_charging_state": { + "title": { + "en": "Battery State", + "nl": "Batterij status" + } + }, + "rssi": { + "type": "number", + "title": { + "en": "WiFi Signal", + "nl": "WiFi signaal" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "icon": "assets/rssi.svg", + "units": { + "en": "dBm", + "nl": "dBm" + } + }, + "time_to_full": { + "type": "number", + "title": { + "en": "Time until full charge", + "nl": "Tijd tot vol geladen" + }, + "getable": true, + "setable": true, + "insights": true, + "uiComponent": "sensor", + "icon": "assets/clock.svg", + "units": { + "en": "min", + "nl": "min" + } + }, + "time_to_empty": { + "type": "number", + "title": { + "en": "Time until discharged", + "nl": "Tijd tot ontladen" + }, + "getable": true, + "setable": true, + "insights": true, + "uiComponent": "sensor", + "icon": "assets/clock.svg", + "units": { + "en": "min", + "nl": "min" + } + }, + "estimate_kwh": { + "type": "number", + "title": { + "en": "Est. kWh in battery", + "nl": "Geschatte kWh in batterij" + }, + "getable": true, + "setable": true, + "insights": true, + "uiComponent": "sensor", + "icon": "assets/tariff.svg", + "units": { + "en": "kWh", + "nl": "kWh" + } + } + }, + "widgets": [ + { + "id": "battery_soc_widget", + "name": { + "en": "Battery State of Charge", + "nl": "Batterij laadniveau" + }, + "description": { + "en": "Display battery percentage charge level", + "nl": "Toon batterij laadpercentage" + }, + "template": "generic", + "class": "battery", + "capabilities": [ + "measure_battery" + ] + } + ], + "pair": [ + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "authorize" + }, + "options": { + "singular": true + } + }, + { + "id": "authorize", + "navigation": { + "prev": "list_devices" + } + } + ], + "id": "plugin_battery", + "settings": [ + { + "id": "polling_interval", + "type": "number", + "label": { + "en": "Polling interval" + }, + "value": 10, + "unit": { + "en": "s" + } + }, + { + "id": "url", + "type": "text", + "label": { + "en": "url", + "nl": "url" + } + }, + { + "id": "use_polling", + "type": "checkbox", + "label": { + "en": "Use polling instead of WebSocket", + "nl": "Gebruik polling in plaats van WebSocket" + }, + "value": true + } + ] + }, + { + "name": { + "en": "Rainmeter", + "nl": "Regen meter" + }, + "images": { + "large": "drivers/rainmeter/assets/images/large.jpg", + "small": "drivers/rainmeter/assets/images/small.jpg" + }, + "platforms": [ + "local" + ], + "class": "sensor", + "capabilities": [ + "measure_rain.last3h", + "measure_rain.total" + ], + "capabilitiesOptions": { + "measure_rain.last3h": { + "title": { + "en": "Last 3 hours rain", + "nl": "Laatste 3 uur regen" + } + }, + "measure_rain.total": { + "title": { + "en": "Rainfall today", + "nl": "Regenval vandaag" + } + } + }, + "pair": [ + { + "id": "start" + }, + { + "id": "list_my_devices", + "template": "list_devices", + "navigation": { + "next": "add_my_devices" + } + }, + { + "id": "add_my_devices", + "template": "add_devices" + } + ], + "id": "rainmeter" + }, + { + "name": { + "en": "Thermometer", + "nl": "Thermometer" + }, + "images": { + "large": "drivers/thermometer/assets/images/large.jpg", + "small": "drivers/thermometer/assets/images/small.jpg" + }, + "platforms": [ + "local" + ], + "class": "sensor", + "capabilities": [ + "measure_temperature", + "measure_humidity" + ], + "energy": { + "batteries": [ + "AA", + "AA" + ] + }, + "settings": [ + { + "type": "group", + "label": { + "en": "Sensor offset", + "nl": "Sensor compensatie" + }, + "children": [ + { + "id": "offset_temperature", + "type": "number", + "label": { + "en": "Temperature", + "nl": "Temperatuur" + }, + "value": 0 + }, + { + "id": "offset_humidity", + "type": "number", + "label": { + "en": "Humidity", + "nl": "Vochtigheid" + }, + "value": 0 + } + ] + } + ], + "pair": [ + { + "id": "start" + }, + { + "id": "list_my_devices", + "template": "list_devices", + "navigation": { + "next": "add_my_devices" + } + }, + { + "id": "add_my_devices", + "template": "add_devices" + } + ], + "id": "thermometer" + }, + { + "name": { + "en": "Watermeter" + }, + "images": { + "large": "drivers/watermeter/assets/images/large.png", + "small": "drivers/watermeter/assets/images/small.png" + }, + "class": "sensor", + "discovery": "watermeter", + "platforms": [ + "local" + ], + "capabilities": [ + "identify", + "measure_water", + "meter_water", + "rssi" + ], + "energy": { + "cumulative": true + }, + "capabilitiesOptions": { + "measure_water": { + "type": "number", + "title": { + "en": "Water L/min", + "nl": "Water L/min" + }, + "units": { + "en": "L/min" + }, + "desc": { + "en": "Water flow in Liters per minute (L/min)", + "nl": "Waterdoorstroming in Liters per minuut (L/min)" + }, + "chartType": "stepLine", + "decimals": 1, + "getable": true, + "setable": false + }, + "meter_water": { + "decimals": 3, + "title": { + "en": "Total usage", + "nl": "Totaal verbruik" + } + }, + "meter_water.daily": { + "decimals": 3, + "title": { + "en": "Daily water usage", + "nl": "Dagverbruik water" + }, + "units": { + "en": "m³", + "nl": "m³" + } + } + }, + "settings": [ + { + "type": "group", + "label": { + "en": "Watermeter offset", + "nl": "Watermeter compensatie" + }, + "children": [ + { + "id": "offset_water", + "type": "number", + "label": { + "en": "Offset watermeter m3", + "nl": "compensatie watermeter m3" + }, + "value": 0 + }, + { + "id": "offset_polling", + "type": "number", + "label": { + "en": "Polling in seconds", + "nl": "Interval in seconden" + }, + "value": 10 + }, + { + "id": "cloud", + "type": "number", + "label": { + "en": "Cloud connection 1=on 0=off", + "nl": "Cloud verbinding 1=actief 0=uit" + }, + "value": 1 + } + ] + } + ], + "pair": [ + { + "id": "start", + "navigation": { + "next": "list_devices" + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ], + "id": "watermeter" + }, + { + "name": { + "en": "Wattcher", + "nl": "Wattcher" + }, + "images": { + "large": "drivers/wattcher/assets/images/large.jpg", + "small": "drivers/wattcher/assets/images/small.jpg" + }, + "platforms": [ + "local" + ], + "class": "sensor", + "capabilities": [ + "measure_power", + "meter_power" + ], + "capabilitiesOptions": { + "meter_power": { + "title": { + "en": "Day usage", + "nl": "Dag totaal" + } + }, + "measure_power": { + "title": { + "en": "Power current", + "nl": "Huidig vermogen" + } + } + }, + "pair": [ + { + "id": "start" + }, + { + "id": "list_my_devices", + "template": "list_devices", + "navigation": { + "next": "add_my_devices" + } + }, + { + "id": "add_my_devices", + "template": "add_devices" + } + ], + "id": "wattcher" + }, + { + "name": { + "en": "Windmeter", + "nl": "Windmeter" + }, + "images": { + "large": "drivers/windmeter/assets/images/large.jpg", + "small": "drivers/windmeter/assets/images/small.jpg" + }, + "platforms": [ + "local" + ], + "class": "sensor", + "capabilities": [ + "measure_wind_angle", + "measure_wind_strength.cur", + "measure_wind_strength.min", + "measure_wind_strength.max", + "measure_gust_strength", + "measure_temperature.real", + "measure_temperature.windchill" + ], + "capabilitiesOptions": { + "measure_wind_angle": { + "title": { + "en": "Wind Angle", + "nl": "Wind richting" + } + }, + "measure_wind_strength.cur": { + "title": { + "en": "Wind Strength current", + "nl": "Wind sterkte huidige" + } + }, + "measure_wind_strength.min": { + "title": { + "en": "Wind Strength lowest", + "nl": "Wind Sterkte laagste" + } + }, + "measure_wind_strength.max": { + "title": { + "en": "Wind Strength highest", + "nl": "Wind Sterkte hoogste" + } + }, + "measure_gust_strength": { + "title": { + "en": "Wind Gusts", + "nl": "Ruk wind sterkte" + } + }, + "measure_temperature.real": { + "title": { + "en": "Temperature Real", + "nl": "Temperatuur Echt" + } + }, + "measure_temperature.windchill": { + "title": { + "en": "Temperature windchill", + "nl": "Gevoelstemperatuur" + } + } + }, + "pair": [ + { + "id": "start" + }, + { + "id": "list_my_devices", + "template": "list_devices", + "navigation": { + "next": "add_my_devices" + } + }, + { + "id": "add_my_devices", + "template": "add_devices" + } + ], + "id": "windmeter" + } + ], + "capabilities": { + "battery_group_average_soc": { + "type": "number", + "title": { + "en": "Battery Group Average SoC", + "nl": "Batterij Groep Lading" + }, + "getable": true, + "setable": false, + "insights": true, + "uiComponent": "sensor", + "icon": "assets/battery.svg", + "units": { + "en": "%", + "nl": "%" + } + }, + "battery_group_charge_mode": { + "type": "enum", + "title": { + "en": "Battery Group Charge Mode", + "nl": "Battery Groep Oplaadmodus" + }, + "getable": true, + "setable": true, + "uiComponent": "picker", + "insights": true, + "icon": "assets/battery.svg", + "values": [ + { + "id": "zero", + "title": { + "en": "Zero (Net Zero)", + "nl": "Nul op de meter" + } + }, + { + "id": "zero_charge_only", + "title": { + "en": "Zero – Charge Only", + "nl": "NOM – Alleen laden" + } + }, + { + "id": "zero_discharge_only", + "title": { + "en": "Zero – Discharge Only", + "nl": "NOM – Alleen ontladen" + } + }, + { + "id": "standby", + "title": { + "en": "Standby", + "nl": "Stand‑by" + } + }, + { + "id": "to_full", + "title": { + "en": "Full Charge", + "nl": "Volledig laden" + } + } + ] + }, + "battery_group_state": { + "type": "string", + "title": { + "en": "Battery Group Charge state", + "nl": "Battery Groep Laadt status" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "icon": "assets/battery.svg" + }, + "battery_group_total_capacity_kwh": { + "type": "number", + "title": { + "en": "Battery Group Total Capacity" + }, + "units": { + "en": "kWh" + }, + "getable": true, + "setable": false, + "insights": false, + "icon": "assets/battery.svg", + "uiComponent": "sensor" + }, + "central_heating_flame": { + "type": "boolean", + "title": { + "en": "Central Heating Burner", + "nl": "CV brander" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/flame.svg" + }, + "central_heating_pump": { + "type": "boolean", + "title": { + "en": "Central Heating", + "nl": "Central Verwarming" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/central_heating.svg" + }, + "connection_error": { + "type": "string", + "title": { + "en": "Connection Error", + "nl": "Verbindingsfout" + }, + "getable": true, + "setable": false, + "insights": true, + "icon": "assets/icon.svg" + }, + "cycles": { + "type": "number", + "title": { + "en": "Number of battery cycles", + "nl": "Aantal battery laadcycli" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "icon": "assets/cycles.svg", + "units": { + "en": "cycles", + "nl": "cycli" + } + }, + "estimate_kwh": { + "type": "number", + "title": { + "en": "Estimate kWh in battery", + "nl": "Geschatte kWh in batterij" + }, + "getable": true, + "setable": true, + "insights": true, + "uiComponent": "sensor", + "icon": "assets/tariff.svg", + "units": { + "en": "kWh", + "nl": "kWh" + } + }, + "identify": { + "type": "boolean", + "title": { + "en": "Identify", + "nl": "Identificeren" + }, + "getable": false, + "setable": true, + "uiComponent": "button", + "insights": false, + "icon": "assets/magnify.svg" + }, + "long_power_fail_count": { + "type": "number", + "title": { + "en": "Power failures", + "nl": "Stroomstoringen" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" + }, + "measure_gas": { + "type": "number", + "title": { + "en": "Current gas usage", + "nl": "Huidig gasverbruik" + }, + "getable": true, + "setable": false, + "decimals": 3, + "uiComponent": "sensor", + "insights": true, + "units": { + "en": "m3", + "nl": "m3" + }, + "icon": "assets/icon.svg" + }, + "net_load_phase1": { + "type": "number", + "title": { + "en": "Phase 1 load", + "nl": "Fase 1 belasting" + }, + "titleFormatted": { + "en": "Phase 1 load", + "nl": "Fase 1 belasting" + }, + "desc": { + "en": "Phase 1 load", + "nl": "Fase 1 belasting" + }, + "min": 0, + "max": 100, + "step": 1, + "decimals": 0, + "chartType": "stepLine", + "getable": true, + "setable": false, + "uiComponent": "slider", + "insights": true, + "units": { + "en": "A", + "nl": "A" + }, + "icon": "assets/icon.svg" + }, + "net_load_phase1_pct": { + "type": "number", + "title": { + "en": "Phase 1 load", + "nl": "Fase 1 belasting" + }, + "titleFormatted": { + "en": "Phase 1 load", + "nl": "Fase 1 belasting" + }, + "desc": { + "en": "Phase 1 load", + "nl": "Fase 1 belasting" + }, + "decimals": 0, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "units": { + "en": "%", + "nl": "%" + }, + "icon": "assets/icon.svg" + }, + "net_load_phase2": { + "type": "number", + "title": { + "en": "Phase 2 load", + "nl": "Fase 2 belasting" + }, + "titleFormatted": { + "en": "Phase 2 load", + "nl": "Fase 2 belasting" + }, + "desc": { + "en": "Phase 2 load", + "nl": "Fase 2 belasting" + }, + "min": 0, + "max": 100, + "step": 1, + "decimals": 0, + "chartType": "stepLine", + "getable": true, + "setable": false, + "uiComponent": "slider", + "insights": true, + "units": { + "en": "A", + "nl": "A" + }, + "icon": "assets/icon.svg" + }, + "net_load_phase2_pct": { + "type": "number", + "title": { + "en": "Phase 2 load", + "nl": "Fase 2 belasting" + }, + "titleFormatted": { + "en": "Phase 2 load", + "nl": "Fase 2 belasting" + }, + "desc": { + "en": "Phase 2 load", + "nl": "Fase 2 belasting" + }, + "decimals": 0, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "units": { + "en": "%", + "nl": "%" + }, + "icon": "assets/icon.svg" + }, + "net_load_phase3": { + "type": "number", + "title": { + "en": "Phase 3 load", + "nl": "Fase 3 belasting" + }, + "titleFormatted": { + "en": "Phase 3 load", + "nl": "Fase 3 belasting" + }, + "desc": { + "en": "Phase 3 load", + "nl": "Fase 3 belasting" + }, + "min": 0, + "max": 100, + "step": 1, + "decimals": 0, + "chartType": "stepLine", + "getable": true, + "setable": false, + "uiComponent": "slider", + "insights": true, + "units": { + "en": "A", + "nl": "A" + }, + "icon": "assets/icon.svg" + }, + "net_load_phase3_pct": { + "type": "number", + "title": { + "en": "Phase 3 load", + "nl": "Fase 3 belasting" + }, + "titleFormatted": { + "en": "Phase 3 load", + "nl": "Fase 3 belasting" + }, + "desc": { + "en": "Phase 3 load", + "nl": "Fase 3 belasting" + }, + "decimals": 0, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "units": { + "en": "%", + "nl": "%" + }, + "icon": "assets/icon.svg" + }, + "preset": { + "type": "enum", + "title": { + "en": "Preset", + "nl": "Preset" + }, + "getable": true, + "setable": true, + "uiComponent": "picker", + "values": [ + { + "id": "0", + "title": { + "nl": "Thuis", + "en": "Home" + } + }, + { + "id": "1", + "title": { + "nl": "Weg", + "en": "Away" + } + }, + { + "id": "2", + "title": { + "nl": "Slapen", + "en": "Sleep" + } + }, + { + "id": "3", + "title": { + "nl": "Vakantie", + "en": "Holiday" + } + } + ] + }, + "rssi": { + "type": "number", + "title": { + "en": "WiFi Signal", + "nl": "WiFi signaal" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "icon": "assets/rssi.svg", + "units": { + "en": "RSSI", + "nl": "RSSI" + } + }, + "tariff": { + "type": "number", + "title": { + "en": "Active tariff", + "nl": "Tarief actief" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/tariff.svg", + "units": { + "en": "Tariff", + "nl": "Tarief" + } + }, + "time_to_empty": { + "type": "number", + "title": { + "en": "Time until discharged", + "nl": "Tijd tot ontladen" + }, + "getable": true, + "setable": false, + "insights": true, + "uiComponent": "sensor", + "icon": "assets/clock.svg", + "units": { + "en": "min", + "nl": "min" + } + }, + "time_to_full": { + "type": "number", + "title": { + "en": "Time until full charge", + "nl": "Tijd tot vol geladen" + }, + "getable": true, + "setable": false, + "insights": true, + "uiComponent": "sensor", + "icon": "assets/clock.svg", + "units": { + "en": "min", + "nl": "min" + } + }, + "voltage_sag_l1": { + "type": "number", + "title": { + "en": "Net dip L1", + "nl": "Net dip L1" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" + }, + "voltage_sag_l2": { + "type": "number", + "title": { + "en": "Net dip L2", + "nl": "Net dip L2" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" + }, + "voltage_sag_l3": { + "type": "number", + "title": { + "en": "Net dip L3", + "nl": "Net dip L3" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" + }, + "voltage_swell_l1": { + "type": "number", + "title": { + "en": "Net peak L1", + "nl": "Net piek L1" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" + }, + "voltage_swell_l2": { + "type": "number", + "title": { + "en": "Net peak L2", + "nl": "Net piek L2" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" + }, + "voltage_swell_l3": { + "type": "number", + "title": { + "en": "Net peak L3", + "nl": "Net piek L3" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/power_fail.svg" + }, + "warm_water": { + "type": "boolean", + "title": { + "en": "Warm water", + "nl": "Warm water" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": false, + "icon": "assets/shower.svg" + }, + "wifi_quality": { + "type": "string", + "title": { + "en": "WiFi State", + "nl": "WiFi Status" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "icon": "assets/rssi.svg" + } + }, + "discovery": { + "energy": { + "type": "mdns-sd", + "mdns-sd": { + "name": "hwenergy", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "host", + "match": { + "type": "regex", + "value": "^p1meter-" + } + }, + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-P1" + } + } + ] + ] + }, + "energy_socket": { + "type": "mdns-sd", + "mdns-sd": { + "name": "hwenergy", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "host", + "match": { + "type": "regex", + "value": "^energysocket-" + } + }, + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-SKT" + } + } + ] + ] + }, + "energy_v2": { + "type": "mdns-sd", + "mdns-sd": { + "name": "homewizard", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "host", + "match": { + "type": "regex", + "value": "^p1meter-" + } + }, + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-P1" + } + } + ] + ] + }, + "plugin_battery": { + "type": "mdns-sd", + "mdns-sd": { + "name": "homewizard", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "host", + "match": { + "type": "regex", + "value": "^battery-" + } + }, + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-BAT" + } + } + ] + ] + }, + "SDM230": { + "type": "mdns-sd", + "mdns-sd": { + "name": "hwenergy", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "SDM230-wifi" + } + } + ], + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-KWH1" + } + } + ] + ] + }, + "SDM230_v2": { + "type": "mdns-sd", + "mdns-sd": { + "name": "homewizard", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "SDM230-wifi" + } + } + ], + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-KWH1" + } + } + ] + ] + }, + "SDM630": { + "type": "mdns-sd", + "mdns-sd": { + "name": "hwenergy", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "SDM630-wifi" + } + } + ], + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-KWH3" + } + } + ] + ] + }, + "SDM630_v2": { + "type": "mdns-sd", + "mdns-sd": { + "name": "homewizard", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "SDM630-wifi" + } + } + ], + [ + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-KWH3" + } + } + ] + ] + }, + "watermeter": { + "type": "mdns-sd", + "mdns-sd": { + "name": "hwenergy", + "protocol": "tcp" + }, + "id": "{{txt.serial}}", + "conditions": [ + [ + { + "field": "host", + "match": { + "type": "regex", + "value": "^watermeter-" + } + }, + { + "field": "txt.product_type", + "match": { + "type": "string", + "value": "HWE-WTR" + } + } + ] + ] + } + } } \ No newline at end of file diff --git a/assets/battery.svg b/assets/battery.svg new file mode 100644 index 00000000..202e8ba5 --- /dev/null +++ b/assets/battery.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/central_heating.svg b/assets/central_heating.svg new file mode 100644 index 00000000..ae3fbd28 --- /dev/null +++ b/assets/central_heating.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/clock.svg b/assets/clock.svg new file mode 100644 index 00000000..d7c59d99 --- /dev/null +++ b/assets/clock.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/cycles.svg b/assets/cycles.svg new file mode 100644 index 00000000..8c7360f1 --- /dev/null +++ b/assets/cycles.svg @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/flame.svg b/assets/flame.svg new file mode 100644 index 00000000..068005cc --- /dev/null +++ b/assets/flame.svg @@ -0,0 +1,79 @@ + + + + + + + + image/svg+xml + + + + + + + + Layer 1 + + + + + + diff --git a/assets/icon.svg b/assets/icon.svg index 0b58b4f2..03cd43ab 100644 --- a/assets/icon.svg +++ b/assets/icon.svg @@ -1,89 +1,29 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + logo-svg + + \ No newline at end of file diff --git a/assets/images/large.jpg b/assets/images/large.jpg deleted file mode 100644 index e468c088..00000000 Binary files a/assets/images/large.jpg and /dev/null differ diff --git a/assets/images/large.png b/assets/images/large.png new file mode 100644 index 00000000..2517c261 Binary files /dev/null and b/assets/images/large.png differ diff --git a/assets/images/small.jpg b/assets/images/small.jpg deleted file mode 100644 index b22669d0..00000000 Binary files a/assets/images/small.jpg and /dev/null differ diff --git a/assets/images/small.png b/assets/images/small.png new file mode 100644 index 00000000..a973e27d Binary files /dev/null and b/assets/images/small.png differ diff --git a/assets/images/xlarge.png b/assets/images/xlarge.png new file mode 100644 index 00000000..9ec4f231 Binary files /dev/null and b/assets/images/xlarge.png differ diff --git a/assets/magnify.svg b/assets/magnify.svg new file mode 100644 index 00000000..e888746a --- /dev/null +++ b/assets/magnify.svg @@ -0,0 +1 @@ + diff --git a/assets/power_fail.svg b/assets/power_fail.svg new file mode 100644 index 00000000..9a4a6aae --- /dev/null +++ b/assets/power_fail.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/rssi.svg b/assets/rssi.svg new file mode 100644 index 00000000..e98392f6 --- /dev/null +++ b/assets/rssi.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/shower.svg b/assets/shower.svg new file mode 100644 index 00000000..3634bce1 --- /dev/null +++ b/assets/shower.svg @@ -0,0 +1,69 @@ + + + + +Created by potrace 1.15, written by Peter Selinger 2001-2017 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/tariff.svg b/assets/tariff.svg new file mode 100644 index 00000000..ca38a2b8 --- /dev/null +++ b/assets/tariff.svg @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/check-structure.sh b/check-structure.sh new file mode 100644 index 00000000..00261300 --- /dev/null +++ b/check-structure.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +echo "=== Cloud P1 Driver File Structure Check ===" +echo "" + +echo "Checking driver directory..." +if [ -d "drivers/cloud_p1" ]; then + echo "✓ drivers/cloud_p1/ exists" + ls -la drivers/cloud_p1/ +else + echo "✗ drivers/cloud_p1/ NOT FOUND" + echo " Should be: drivers/cloud_p1/" + if [ -d "drivers/cloud-p1" ]; then + echo " Found: drivers/cloud-p1/ (WRONG - use underscore not dash)" + fi +fi + +echo "" +echo "Checking pair directory..." +if [ -d "drivers/cloud_p1/pair" ]; then + echo "✓ drivers/cloud_p1/pair/ exists" + ls -la drivers/cloud_p1/pair/ +else + echo "✗ drivers/cloud_p1/pair/ NOT FOUND" +fi + +echo "" +echo "Checking required files..." +files=( + "drivers/cloud_p1/driver.js" + "drivers/cloud_p1/device.js" + "drivers/cloud_p1/driver.compose.json" + "drivers/cloud_p1/pair/login.html" + "drivers/cloud_p1/pair/list_locations.html" + "lib/homewizard-cloud-api.js" +) + +for file in "${files[@]}"; do + if [ -f "$file" ]; then + echo "✓ $file" + else + echo "✗ $file MISSING" + fi +done + +echo "" +echo "Checking app.json (generated)..." +if [ -f "app.json" ]; then + echo "✓ app.json exists" + if grep -q "cloud_p1" app.json; then + echo "✓ app.json contains cloud_p1 driver" + echo "" + echo "Pair flow in app.json:" + cat app.json | grep -A 30 '"id": "cloud_p1"' | grep -A 20 '"pair"' + else + echo "✗ app.json does NOT contain cloud_p1 driver" + echo " Run: homey app build" + fi +else + echo "✗ app.json NOT FOUND" + echo " Run: homey app build" +fi + +echo "" +echo "Checking package.json dependencies..." +if [ -f "package.json" ]; then + if grep -q '"ws"' package.json; then + echo "✓ ws dependency found" + else + echo "✗ ws dependency MISSING" + echo " Add: \"ws\": \"^8.16.0\" to dependencies" + fi +else + echo "✗ package.json NOT FOUND" +fi + +echo "" +echo "=== Summary ===" +echo "If any files are missing or in wrong location, fix and run:" +echo " homey app build" +echo " homey app install" \ No newline at end of file diff --git a/drivers/SDM230-p1mode/assets/icon.svg b/drivers/SDM230-p1mode/assets/icon.svg new file mode 100644 index 00000000..b6d798c2 --- /dev/null +++ b/drivers/SDM230-p1mode/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drivers/SDM230-p1mode/assets/images/large.png b/drivers/SDM230-p1mode/assets/images/large.png new file mode 100644 index 00000000..263d225a Binary files /dev/null and b/drivers/SDM230-p1mode/assets/images/large.png differ diff --git a/drivers/SDM230-p1mode/assets/images/small.png b/drivers/SDM230-p1mode/assets/images/small.png new file mode 100644 index 00000000..e43504db Binary files /dev/null and b/drivers/SDM230-p1mode/assets/images/small.png differ diff --git a/drivers/SDM230-p1mode/device.js b/drivers/SDM230-p1mode/device.js new file mode 100644 index 00000000..e0f97a06 --- /dev/null +++ b/drivers/SDM230-p1mode/device.js @@ -0,0 +1,348 @@ +'use strict'; + +const Homey = require('homey'); +const fetch = require('node-fetch'); +const http = require('http'); +const BaseloadMonitor = require('../../includes/utils/baseloadMonitor'); + + +async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { + ...options, + signal: controller.signal, + }); + return res; + } catch (err) { + if (err.name === 'AbortError') { + throw new Error('TIMEOUT'); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +/** + * Stable capability updater — never removes capabilities. + */ +async function updateCapability(device, capability, value) { + try { + const current = device.getCapabilityValue(capability); + + // --- SAFE REMOVE --- + // Removal is allowed only when: + // 1) the new value is null + // 2) the current value in Homey is also null + + if (value == null && current == null) { + if (device.hasCapability(capability)) { + await device.removeCapability(capability); + device.log(`🗑️ Removed capability "${capability}"`); + } + return; + } + + // --- ADD IF MISSING --- + if (!device.hasCapability(capability)) { + try { + await device.addCapability(capability); + device.log(`➕ Added capability "${capability}"`); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + device.log(`Capability already exists: ${capability} — ignoring`); + } else { + throw err; + } + } + } + + // --- UPDATE --- + if (current !== value) { + await device.setCapabilityValue(capability, value); + } + + } catch (err) { + if (err.message === 'device_not_found') { + device.log(`⚠️ Skipping capability "${capability}" — device not found`); + return; + } + device.error(`❌ Failed updateCapability("${capability}")`, err); + } +} + +module.exports = class HomeWizardEnergyDevice230 extends Homey.Device { + + async onInit() { + + this._debugLogs = []; + + this.agent = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 10000, + }); + + + const settings = this.getSettings(); + + if (settings.polling_interval == null) { + await this.setSettings({ polling_interval: 10 }); + } + + const interval = Math.max(settings.polling_interval, 2); + + if (this.onPollInterval) clearInterval(this.onPollInterval); + this.onPollInterval = setInterval(() => { + this.onPoll().catch(this.error); + }, interval * 1000); + + if (this.getClass() === 'sensor') { + this.setClass('socket'); + } + + // Required capabilities + const requiredCaps = [ + 'measure_power', + 'meter_power.consumed.t1', + 'measure_power.l1', + 'rssi' + ]; + + for (const cap of requiredCaps) { + if (!this.hasCapability(cap)) { + try { + await this.addCapability(cap); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + this.log(`Capability already exists: ${cap} — ignoring`); + } else { + this.error(err); + } + } + } + } + + // Baseload monitor + this._baseloadNotificationsEnabled = this.getSetting('baseload_notifications') ?? true; + + const app = this.homey.app; + if (!app.baseloadMonitor) { + app.baseloadMonitor = new BaseloadMonitor(this.homey); + } + + app.baseloadMonitor.registerP1Device(this); + app.baseloadMonitor.trySetMaster(this); + app.baseloadMonitor.setNotificationsEnabledForDevice(this, this._baseloadNotificationsEnabled); + } + + onDeleted() { + if (this.onPollInterval) { + clearInterval(this.onPollInterval); + this.onPollInterval = null; + } + + const app = this.homey.app; + if (app.baseloadMonitor) { + app.baseloadMonitor.unregisterP1Device(this); + } + } + + onDiscoveryAvailable(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this._debugLog(`Discovery available: ${this.url}`); + this.setAvailable(); + } + + onDiscoveryAddressChanged(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this._debugLog(`Discovery address changed: ${this.url}`); + this.setAvailable(); + } + + onDiscoveryLastSeenChanged(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this._debugLog(`Discovery last seen: ${this.url}`); + this.setAvailable(); + } + + /** + * Debug logger (batched writes) + */ +_debugLog(msg) { + try { + if (!this._debugBuffer) this._debugBuffer = []; + const ts = new Date().toLocaleString('nl-NL', { hour12: false, timeZone: 'Europe/Amsterdam' }); + const driverName = this.driver.id; + const deviceName = this.getName(); + const safeMsg = typeof msg === 'string' ? msg : (msg instanceof Error ? msg.message : JSON.stringify(msg)); + const line = `${ts} [${driverName}] [${deviceName}] ${safeMsg}`; + this._debugBuffer.push(line); + if (this._debugBuffer.length > 20) this._debugBuffer.shift(); + if (!this._debugFlushTimeout) { + this._debugFlushTimeout = setTimeout(() => { + this._flushDebugLogs(); + this._debugFlushTimeout = null; + }, 5000); + } + } catch (err) { + this.error('Failed to write debug logs:', err.message || err); + } +} +_flushDebugLogs() { + if (!this._debugBuffer || this._debugBuffer.length === 0) return; + try { + const logs = this.homey.settings.get('debug_logs') || []; + logs.push(...this._debugBuffer); + if (logs.length > 500) logs.splice(0, logs.length - 500); + this.homey.settings.set('debug_logs', logs); + this._debugBuffer = []; + } catch (err) { + this.error('Failed to flush debug logs:', err.message || err); + } +} + + async setCloudOn() { + if (!this.url) return; + + const res = await fetchWithTimeout(`${this.url}/system`, { + agent: this.agent, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cloud_enabled: true }) + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + async setCloudOff() { + if (!this.url) return; + + const res = await fetchWithTimeout(`${this.url}/system`, { + agent: this.agent, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cloud_enabled: false }) + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + _onNewPowerValue(power) { + const app = this.homey.app; + if (app.baseloadMonitor) { + app.baseloadMonitor.updatePowerFromDevice(this, power); + } + } + + /** + * GET /data + */ +async onPoll() { + const settings = this.getSettings(); + + if (!this.url) { + if (settings.url) { + this.url = settings.url; + } else { + await this.setUnavailable('Missing URL'); + return; + } + } + + try { + + const res = await fetchWithTimeout(`${this.url}/data`, { + agent: this.agent, + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + + const data = await res.json(); + if (!data || typeof data !== 'object') throw new Error('Invalid JSON'); + + // CAPABILITY UPDATES + await updateCapability(this, 'rssi', data.wifi_strength); + + const power = this.getClass() === 'solarpanel' + ? data.active_power_w * -1 + : data.active_power_w; + + await updateCapability(this, 'measure_power', power); + this._onNewPowerValue(power); + + await updateCapability(this, 'meter_power.consumed.t1', data.total_power_import_t1_kwh); + + const l1 = this.getClass() === 'solarpanel' + ? data.active_power_l1_w * -1 + : data.active_power_l1_w; + + await updateCapability(this, 'measure_power.l1', l1); + + if (data.total_power_export_t1_kwh > 1) { + await updateCapability(this, 'meter_power.produced.t1', data.total_power_export_t1_kwh); + } + + const net = data.total_power_import_t1_kwh - data.total_power_export_t1_kwh; + await updateCapability(this, 'meter_power', net); + + if (data.active_voltage_v !== undefined) { + await updateCapability(this, 'measure_voltage', data.active_voltage_v); + } + + if (data.active_current_a !== undefined) { + await updateCapability(this, 'measure_current', data.active_current_a); + } + + await this.setAvailable(); + + } catch (err) { + + if (err.message === 'TIMEOUT') { + this._debugLog('SDM230 P1-mode timeout — no new telegrams, keeping last known values'); + // No return — allow finally to run + } else { + this._debugLog(`Poll failed: ${err.message}`); + this.log('Polling error:', err.message || err); + this.setUnavailable(err.message || 'Polling error').catch(this.error); + } + } + +} + + + async onSettings(event) { + const { newSettings, oldSettings, changedKeys } = event; + + if (changedKeys.includes('polling_interval')) { + clearInterval(this.onPollInterval); + + const interval = Math.max(newSettings.polling_interval, 2); + + this.onPollInterval = setInterval(() => { + this.onPoll().catch(this.error); + }, interval * 1000); + } + + if (changedKeys.includes('cloud')) { + if (newSettings.cloud == 1) { + this.setCloudOn(); + } else { + this.setCloudOff(); + } + } + + if (changedKeys.includes('baseload_notifications')) { + this._baseloadNotificationsEnabled = newSettings.baseload_notifications; + + const app = this.homey.app; + if (app.baseloadMonitor) { + app.baseloadMonitor.setNotificationsEnabledForDevice(this, this._baseloadNotificationsEnabled); + } + } + } +}; diff --git a/drivers/SDM230-p1mode/driver.compose.json b/drivers/SDM230-p1mode/driver.compose.json new file mode 100644 index 00000000..e99a1c41 --- /dev/null +++ b/drivers/SDM230-p1mode/driver.compose.json @@ -0,0 +1,97 @@ +{ + "name": { + "en": "kWh Meter (1 phase) P1 mode" + }, + "images": { + "large": "drivers/SDM230-p1mode/assets/images/large.png", + "small": "drivers/SDM230-p1mode/assets/images/small.png" + }, + "class": "socket", + "discovery": "SDM230", + "platforms": [ + "local" + ], + "capabilities": [ + "measure_power", + "meter_power", + "measure_current", + "measure_power.l1", + "measure_voltage", + "rssi" + ], + "energy": { + "cumulative": true, + "cumulativeImportedCapability": "meter_power.consumed.t1", + "cumulativeExportedCapability": "meter_power.produced.t1" + }, + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + }, + "insights": true + }, + "measure_voltage": { + "title": { + "en": "Current Voltage", + "nl": "Huidig Voltage" + }, + "insights": true + }, + "measure_current": { + "title": { + "en": "Current Amp", + "nl": "Huidig Amp" + }, + "insights": true + } + }, + "pair": [ + { + "id": "start", + "navigation": { + "next": "list_devices" + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ] +} \ No newline at end of file diff --git a/drivers/SDM230-p1mode/driver.js b/drivers/SDM230-p1mode/driver.js new file mode 100644 index 00000000..a1a0dc29 --- /dev/null +++ b/drivers/SDM230-p1mode/driver.js @@ -0,0 +1,5 @@ +'use strict'; + +const driver = require('../../includes/v1/driver.js'); + +module.exports = driver; diff --git a/drivers/SDM230-p1mode/driver.settings.compose.json b/drivers/SDM230-p1mode/driver.settings.compose.json new file mode 100644 index 00000000..a934ea6c --- /dev/null +++ b/drivers/SDM230-p1mode/driver.settings.compose.json @@ -0,0 +1,34 @@ +[ + { + "id": "polling_interval", + "type": "number", + "label": { "en": "Polling interval" }, + "value": 10, + "min": 1, + "unit": { "en": "s" } + }, + { + "id": "cloud", + "type": "number", + "label": { "en": "Cloud connection 1=on 0=off", + "nl": "Cloud verbinding 1=actief 0=uit" + }, + "value": 1 + }, + { + "id": "url", + "type": "text", + "label": { "en": "url", + "nl": "url" + } + }, + { + "id": "baseload_notifications", + "type": "checkbox", + "label": { + "en": "Enable baseload notifications", + "nl": "Sluipverbruik meldingen inschakelen" + }, + "value": true + } +] \ No newline at end of file diff --git a/drivers/SDM230-p1mode/pair/start.html b/drivers/SDM230-p1mode/pair/start.html new file mode 100644 index 00000000..633815c6 --- /dev/null +++ b/drivers/SDM230-p1mode/pair/start.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/drivers/SDM230/assets/icon.svg b/drivers/SDM230/assets/icon.svg new file mode 100644 index 00000000..b6d798c2 --- /dev/null +++ b/drivers/SDM230/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drivers/SDM230/assets/images/large.png b/drivers/SDM230/assets/images/large.png new file mode 100644 index 00000000..263d225a Binary files /dev/null and b/drivers/SDM230/assets/images/large.png differ diff --git a/drivers/SDM230/assets/images/small.png b/drivers/SDM230/assets/images/small.png new file mode 100644 index 00000000..e43504db Binary files /dev/null and b/drivers/SDM230/assets/images/small.png differ diff --git a/drivers/SDM230/device.js b/drivers/SDM230/device.js new file mode 100644 index 00000000..82f888d8 --- /dev/null +++ b/drivers/SDM230/device.js @@ -0,0 +1,346 @@ +'use strict'; + +const Homey = require('homey'); +const fetch = require('node-fetch'); + +const http = require('http'); + +async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { + ...options, + signal: controller.signal, + }); + return res; + } catch (err) { + if (err.name === 'AbortError') { + throw new Error('TIMEOUT'); + } + throw err; + } finally { + clearTimeout(timer); + } +} + + + + +/** + * Safe capability updater + */ +async function updateCapability(device, capability, value) { + try { + const current = device.getCapabilityValue(capability); + + // --- SAFE REMOVE --- + // Removal is allowed only when: + // 1) the new value is null + // 2) the current value in Homey is also null + + if (value == null && current == null) { + if (device.hasCapability(capability)) { + await device.removeCapability(capability); + device.log(`🗑️ Removed capability "${capability}"`); + } + return; + } + + // --- ADD IF MISSING --- + if (!device.hasCapability(capability)) { + await device.addCapability(capability); + device.log(`➕ Added capability "${capability}"`); + } + + // --- UPDATE --- + if (current !== value) { + await device.setCapabilityValue(capability, value); + } + + } catch (err) { + if (err.message === 'device_not_found') { + device.log(`⚠️ Skipping capability "${capability}" — device not found`); + return; + } + device.error(`❌ Failed updateCapability("${capability}")`, err); + } +} +module.exports = class HomeWizardEnergyDevice230 extends Homey.Device { + + async onInit() { + this._debugLogs = []; + + // KeepAlive agent (blijft) + this.agent = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 10000, + }); + + + // await this.setUnavailable(`${this.getName()} ${this.homey.__('device.init')}`); + + const settings = this.getSettings(); + + if (settings.polling_interval == null) { + await this.setSettings({ polling_interval: 10 }); + } + + const interval = Math.max(settings.polling_interval, 2); + const offset = Math.floor(Math.random() * interval * 1000); + + if (this.onPollInterval) clearInterval(this.onPollInterval); + + setTimeout(() => { + this.onPoll().catch(this.error); + this.onPollInterval = setInterval(() => { + this.onPoll().catch(this.error); + }, interval * 1000); + }, offset); + + if (this.getClass() === 'sensor') { + this.setClass('socket'); + this.log('Changed class from sensor to socket'); + } + + const requiredCaps = [ + 'measure_power', + 'meter_power.consumed.t1', + 'measure_power.l1', + 'rssi', + 'meter_power' + ]; + + for (const cap of requiredCaps) { + if (!this.hasCapability(cap)) { + try { + await this.addCapability(cap); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + this.log(`Capability already exists: ${cap} — ignoring`); + } else { + this.error(err); + } + } + } + } + } + + onDeleted() { + if (this.onPollInterval) { + clearInterval(this.onPollInterval); + this.onPollInterval = null; + } + } + + /** + * Discovery — simpel gehouden + */ + onDiscoveryAvailable(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this.setAvailable(); + } + + onDiscoveryAddressChanged(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this._debugLog(`🔄 Discovery address changed: ${this.url}`); + this.setAvailable(); + } + + onDiscoveryLastSeenChanged(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this.setAvailable(); + } + + /** + * Per‑device debug logger + */ +_debugLog(msg) { + try { + if (!this._debugBuffer) this._debugBuffer = []; + const ts = new Date().toLocaleString('nl-NL', { hour12: false, timeZone: 'Europe/Amsterdam' }); + const driverName = this.driver.id; + const deviceName = this.getName(); + const safeMsg = typeof msg === 'string' ? msg : (msg instanceof Error ? msg.message : JSON.stringify(msg)); + const line = `${ts} [${driverName}] [${deviceName}] ${safeMsg}`; + this._debugBuffer.push(line); + if (this._debugBuffer.length > 20) this._debugBuffer.shift(); + if (!this._debugFlushTimeout) { + this._debugFlushTimeout = setTimeout(() => { + this._flushDebugLogs(); + this._debugFlushTimeout = null; + }, 5000); + } + } catch (err) { + this.error('Failed to write debug logs:', err.message || err); + } +} +_flushDebugLogs() { + if (!this._debugBuffer || this._debugBuffer.length === 0) return; + try { + const logs = this.homey.settings.get('debug_logs') || []; + logs.push(...this._debugBuffer); + if (logs.length > 500) logs.splice(0, logs.length - 500); + this.homey.settings.set('debug_logs', logs); + this._debugBuffer = []; + } catch (err) { + this.error('Failed to flush debug logs:', err.message || err); + } +} + + + + /** + * PUT /system cloud on/off — zonder timeout wrapper + */ + async setCloudOn() { + if (!this.url) return; + + try { + const res = await fetchWithTimeout(`${this.url}/system`, { + agent: this.agent, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cloud_enabled: true }) + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + + this.log('Cloud enabled'); + + } catch (err) { + this._debugLog(`Cloud ON failed: ${err.code || ''} ${err.message || err}`); + this.error('Failed to enable cloud:', err); + } + } + + async setCloudOff() { + if (!this.url) return; + + try { + const res = await fetchWithTimeout(`${this.url}/system`, { + agent: this.agent, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cloud_enabled: false }) + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + + this.log('Cloud disabled'); + + } catch (err) { + this._debugLog(`Cloud OFF failed: ${err.code || ''} ${err.message || err}`); + this.error('Failed to disable cloud:', err); + } + } + + /** + * GET /data + */ + async onPoll() { + const settings = this.getSettings(); + + // URL alleen uit settings; nooit terugschrijven + if (!this.url) { + if (settings.url) { + this.url = settings.url; + this.log(`Restored URL from settings: ${this.url}`); + } else { + //this.setUnavailable('Missing URL').catch(this.error); + this.log('❌ Missing URL, skipping poll'); + await updateCapability(this, 'alarm_connectivity', true); + return; + } + } + + + try { + + const res = await fetchWithTimeout(`${this.url}/data`, { + agent: this.agent, + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + + const text = await res.text(); + + let data; + try { + data = JSON.parse(text); + } catch (err) { + this.error('JSON parse error:', err.message, 'Body:', text?.slice(0, 200)); + throw new Error('Invalid JSON'); + } + + if (!data || typeof data !== 'object') { + throw new Error('Invalid JSON'); + } + + + await updateCapability(this, 'rssi', data.wifi_strength); + await updateCapability(this, 'alarm_connectivity', false); + + const power = this.getClass() === 'solarpanel' + ? data.active_power_w * -1 + : data.active_power_w; + await updateCapability(this, 'measure_power', power); + + await updateCapability(this, 'meter_power.consumed.t1', data.total_power_import_t1_kwh); + + const l1 = this.getClass() === 'solarpanel' + ? data.active_power_l1_w * -1 + : data.active_power_l1_w; + await updateCapability(this, 'measure_power.l1', l1); + + if (data.total_power_export_t1_kwh > 1) { + await updateCapability(this, 'meter_power.produced.t1', data.total_power_export_t1_kwh); + } + + const net = data.total_power_import_t1_kwh - data.total_power_export_t1_kwh; + await updateCapability(this, 'meter_power', net); + + + await updateCapability(this, 'measure_voltage', data.active_voltage_v); + await updateCapability(this, 'measure_current', data.active_current_a); + + await this.setAvailable(); + + } catch (err) { + this._debugLog(`❌ ${err.code || ''} ${err.message || err}`); + this.error('Polling failed:', err); + //this.setUnavailable(err.message || 'Polling error').catch(this.error); + await updateCapability(this, 'alarm_connectivity', true); + + } + + } + + onSettings(event) { + const { newSettings, changedKeys } = event; + + for (const key of changedKeys) { + + if (key === 'polling_interval') { + const interval = newSettings.polling_interval; + + if (typeof interval === 'number' && interval > 0) { + if (this.onPollInterval) clearInterval(this.onPollInterval); + this.onPollInterval = setInterval(() => { + this.onPoll().catch(this.error); + }, interval * 1000); + } else { + this.log('Invalid polling interval:', interval); + } + } + + if (key === 'cloud') { + if (newSettings.cloud == 1) this.setCloudOn(); + else this.setCloudOff(); + } + } + } +}; diff --git a/drivers/SDM230/driver.compose.json b/drivers/SDM230/driver.compose.json new file mode 100644 index 00000000..d73d57a1 --- /dev/null +++ b/drivers/SDM230/driver.compose.json @@ -0,0 +1,93 @@ +{ + "name": { + "en": "kWh Meter (1 phase)" + }, + "images": { + "large": "drivers/SDM230/assets/images/large.png", + "small": "drivers/SDM230/assets/images/small.png" + }, + "class": "socket", + "discovery": "SDM230", + "platforms": [ + "local" + ], + "capabilities": [ + "measure_power", + "meter_power", + "measure_current", + "measure_power.l1", + "measure_voltage", + "alarm_connectivity", + "rssi" + ], + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + }, + "insights": true + }, + "measure_voltage": { + "title": { + "en": "Current Voltage", + "nl": "Huidig Voltage" + }, + "insights": true + }, + "measure_current": { + "title": { + "en": "Current Amp", + "nl": "Huidig Amp" + }, + "insights": true + } + }, + "pair": [ + { + "id": "start", + "navigation": { + "next": "list_devices" + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ] +} \ No newline at end of file diff --git a/drivers/SDM230/driver.js b/drivers/SDM230/driver.js new file mode 100644 index 00000000..a1a0dc29 --- /dev/null +++ b/drivers/SDM230/driver.js @@ -0,0 +1,5 @@ +'use strict'; + +const driver = require('../../includes/v1/driver.js'); + +module.exports = driver; diff --git a/drivers/SDM230/driver.settings.compose.json b/drivers/SDM230/driver.settings.compose.json new file mode 100644 index 00000000..857734d9 --- /dev/null +++ b/drivers/SDM230/driver.settings.compose.json @@ -0,0 +1,25 @@ +[ + { + "id": "polling_interval", + "type": "number", + "label": { "en": "Polling interval" }, + "value": 10, + "min": 1, + "unit": { "en": "s" } + }, + { + "id": "cloud", + "type": "number", + "label": { "en": "Cloud connection 1=on 0=off", + "nl": "Cloud verbinding 1=actief 0=uit" + }, + "value": 1 + }, + { + "id": "url", + "type": "text", + "label": { "en": "url", + "nl": "url" + } + } +] \ No newline at end of file diff --git a/drivers/SDM230/pair/start.html b/drivers/SDM230/pair/start.html new file mode 100644 index 00000000..633815c6 --- /dev/null +++ b/drivers/SDM230/pair/start.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/drivers/SDM230_v2/assets/icon.svg b/drivers/SDM230_v2/assets/icon.svg new file mode 100644 index 00000000..b6d798c2 --- /dev/null +++ b/drivers/SDM230_v2/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drivers/SDM230_v2/assets/images/large.png b/drivers/SDM230_v2/assets/images/large.png new file mode 100644 index 00000000..263d225a Binary files /dev/null and b/drivers/SDM230_v2/assets/images/large.png differ diff --git a/drivers/SDM230_v2/assets/images/small.png b/drivers/SDM230_v2/assets/images/small.png new file mode 100644 index 00000000..e43504db Binary files /dev/null and b/drivers/SDM230_v2/assets/images/small.png differ diff --git a/drivers/SDM230_v2/device.js b/drivers/SDM230_v2/device.js new file mode 100644 index 00000000..cee1d7b1 --- /dev/null +++ b/drivers/SDM230_v2/device.js @@ -0,0 +1,460 @@ +'use strict'; + +const Homey = require('homey'); +const api = require('../../includes/v2/Api'); + +// const POLL_INTERVAL = 1000 * 1; // 1 seconds + +function normalizeBatteryMode(data) { + const knownModes = [ + 'zero', + 'standby', + 'to_full', + 'zero_charge_only', + 'zero_discharge_only' + ]; + + let rawMode = data.mode; + + if (typeof rawMode === 'string') { + rawMode = rawMode.trim(); + try { rawMode = JSON.parse(rawMode); } + catch { rawMode = rawMode.replace(/^["']+|["']+$/g, ''); } + } + + if (knownModes.includes(rawMode)) return rawMode; + + if (Array.isArray(data.permissions)) { + const perms = [...data.permissions].sort().join(','); + if (perms === '') return 'standby'; + if (perms === 'charge_allowed,discharge_allowed') return 'zero'; + if (perms === 'charge_allowed') return 'zero_charge_only'; + if (perms === 'discharge_allowed') return 'zero_discharge_only'; + } + + return 'standby'; +} + +module.exports = class HomeWizardEnergyDevice230V2 extends Homey.Device { + + async onInit() { + + // await this.setUnavailable(`${this.getName()} ${this.homey.__('device.init')}`); + + this.token = await this.getStoreValue('token'); + //this.log('Token:', this.token); + + await this._updateCapabilities(); + await this._registerCapabilityListeners(); + + const settings = this.getSettings(); + this.log('Settings for SDM230 apiv2: ', settings.polling_interval); + + // Check if polling interval is set in settings else set default value + if (settings.polling_interval === undefined) { + settings.polling_interval = 10; // Default to 10 second if not set + await this.setSettings({ + // Update settings in Homey + polling_interval: 10, + }); + } + + // Register flow card listeners only once (prevent "already registered" warnings) + if (!this.homey.app._flowListenersRegistered_SDM230) { + this.homey.app._flowListenersRegistered_SDM230 = true; + + // Condition Card + const ConditionCardCheckBatteryMode = this.homey.flow.getConditionCard('check-battery-mode'); + ConditionCardCheckBatteryMode.registerRunListener(async (args, state) => { + // this.log('CheckBatteryModeCard'); + + return new Promise(async (resolve, reject) => { + try { + const response = await api.getMode(this.url, this.token); // NEEDS TESTING WITH SDM230 and BATTERY + + if (!response) { + return resolve(false); + } + + const normalized = normalizeBatteryMode(response); + return resolve(args.mode === normalized); + + + } catch (error) { + this.log('Error retrieving mode:', error); + return resolve(false); // Or reject(error), depending on your error-handling approach + } + }); + }); + + this.homey.flow.getActionCard('sdm230-set-battery-to-zero-mode') + .registerRunListener(async () => { + this.log('ActionCard: Set Battery to Zero Mode'); + // this.log('This url:', this.url); + // this.log('This token:', this.token); + return new Promise(async (resolve, reject) => { + try { + const response = await api.setMode(this.url, this.token, 'zero'); + + if (!response) return resolve(false); + + const normalized = normalizeBatteryMode(response); + return resolve(normalized); + + } catch (error) { + this.log('Error set mode to zero:', error); + return resolve(false); // Or reject(error), depending on your error-handling approach + } + }); + }); + + this.homey.flow.getActionCard('sdm230-set-battery-to-full-charge-mode') + .registerRunListener(async () => { + this.log('ActionCard: Set Battery to Full Charge Mode'); + // this.log('This url:', this.url); + // this.log('This token:', this.token); + return new Promise(async (resolve, reject) => { + try { + const response = await api.setMode(this.url, this.token, 'to_full'); + + if (!response) return resolve(false); + + const normalized = normalizeBatteryMode(response); + return resolve(normalized); + + } catch (error) { + this.log('Error set mode to full charge:', error); + return resolve(false); // Or reject(error), depending on your error-handling approach + } + }); + }); + + this.homey.flow.getActionCard('sdm230-set-battery-to-standby-mode') + .registerRunListener(async () => { + this.log('ActionCard: Set Battery to Standby Mode'); + // this.log('This url:', this.url); + // this.log('This token:', this.token); + return new Promise(async (resolve, reject) => { + try { + const response = await api.setMode(this.url, this.token, 'standby'); + + if (!response) return resolve(false); + + const normalized = normalizeBatteryMode(response); + return resolve(normalized); + + } catch (error) { + this.log('Error set mode to standby:', error); + return resolve(false); // Or reject(error), depending on your error-handling approach + } + }); + }); + + // Zero Charge Only + this.homey.flow.getActionCard('sdm230-set-battery-to-zero-charge-only-mode') + .registerRunListener(async () => { + this.log('ActionCard: Set Battery to Zero Charge Only Mode'); + + try { + const response = await api.setMode(this.url, this.token, 'zero_charge_only'); + if (!response) return false; + + const normalized = normalizeBatteryMode(response); + return normalized; + + } catch (error) { + this.error('Error set mode to zero_charge_only:', error); + return false; + } + }); + + // Zero Discharge Only + this.homey.flow.getActionCard('sdm230-set-battery-to-zero-discharge-only-mode') + .registerRunListener(async () => { + this.log('ActionCard: Set Battery to Zero Discharge Only Mode'); + + try { + const response = await api.setMode(this.url, this.token, 'zero_discharge_only'); + if (!response) return false; + + const normalized = normalizeBatteryMode(response); + return normalized; + + } catch (error) { + this.error('Error set mode to zero_discharge_only:', error); + return false; + } + }); + + } // End of _flowListenersRegistered_SDM230 guard + + this.onPollInterval = setInterval(this.onPoll.bind(this), 1000 * settings.polling_interval); + + this._triggerFlowPrevious = {}; + + + } + + onDeleted() { + if (this.onPollInterval) { + clearInterval(this.onPollInterval); + this.onPollInterval = null; + } + } + + onDiscoveryAvailable(discoveryResult) { + this.url = `https://${discoveryResult.address}`; + this.log(`URL: ${this.url}`); + this.onPoll(); + } + + onDiscoveryAddressChanged(discoveryResult) { + this.url = `https://${discoveryResult.address}`; + this.log(`URL: ${this.url}`); + this.log('onDiscoveryAddressChanged'); + this.onPoll(); + } + + onDiscoveryLastSeenChanged(discoveryResult) { + this.url = `https://${discoveryResult.address}`; + this.log(`URL: ${this.url}`); + this.setAvailable(); + this.onPoll(); + } + + /** + * Helper function to update capabilities configuration. + * This function is called when the device is initialized. + */ + async _updateCapabilities() { + if (!this.hasCapability('identify')) { + try { + await this.addCapability('identify'); + this.log(`created capability identify for ${this.getName()}`); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + this.log('Capability already exists: identify — ignoring'); + } else { + this.error(err); + } + } + } + + // Remove capabilities that are not needed + if (this.hasCapability('measure_power.power_w')) { + await this.removeCapability('measure_power.power_w').catch(this.error); + this.log(`removed capability measure_power.power_w for ${this.getName()}`); + } + } + + /** + * Helper function to register capability listeners. + * This function is called when the device is initialized. + */ + async _registerCapabilityListeners() { + this.registerCapabilityListener('identify', async (value) => { + await api.identify(this.url, this.token); + }); + } + + /** + * Helper function for 'optional' capabilities. + * This function is called when the device is initialized. + * It will create the capability if it doesn't exist. + * + * We do not remove capabilities here, as we assume the user may want to keep them. + * Besides that we assume that the P1 Meter is connected to a smart meter that does not change often. + * + * @param {string} capability The capability to set + * @param {*} value The value to set + * @returns {Promise} A promise that resolves when the capability is set + */ + async _setCapabilityValue(capability, value) { + // Test if value is undefined, if so, we don't set the capability + if (value === undefined) { + return; + } + + // Create a new capability if it doesn't exist + if (!this.hasCapability(capability)) { + try { + await this.addCapability(capability); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + this.log(`Capability already exists: ${capability} — ignoring`); + } else { + this.error(err); + } + } + } + + // Set the capability value + await this.setCapabilityValue(capability, value).catch(this.error); + } + + /** + * Helper function to trigger flows on change. + * This function is called when the device is initialized. + * + * We use this function to trigger flows when the value changes. + * We store the previous value in a variable. + * + * @param {*} flow_id Flow ID name + * @param {*} value The value to check for changes + * @returns {Promise} A promise that resolves when the flow is triggered + */ + async _triggerFlowOnChange(flow_id, value) { + + // Ignore if value is undefined + if (value === undefined) { + return; + } + + // Check if the value is undefined + // If so, we assume this is the first time we are setting the value + // We cannot trust the the 'trigger' function to be called with the correct value + if (this._triggerFlowPrevious[flow_id] === undefined) { + this._triggerFlowPrevious[flow_id] = value; + return; + } + + // Return of the value is the same as the previous value + if (this._triggerFlowPrevious[flow_id] === value) { + + // We don't need to trigger the flow + return; + } + + // It is a bit 'costly' to get the flow card every time + // But we can assume the trigger does not change often + const flow = this.homey.flow.getDeviceTriggerCard(flow_id); + if (flow === undefined) { + this.error('Flow not found'); + return; + } + + // Update value and trigger the flow + this._triggerFlowPrevious[flow_id] = value; + flow.trigger(this, { [flow_id]: value }).catch(this.error); + } + + async onPoll() { + try { + const settings = this.getSettings(); + + // 1. Restore URL if runtime is empty + if (!this.url) { + if (settings.url) { + this.url = settings.url; + } else { + await this.setUnavailable('Missing URL'); + return; + } + } + + // 2. Sync settings if discovery changed the URL + if (this.url && this.url !== settings.url) { + await this.setSettings({ url: this.url }).catch(this.error); + } + + // Refresh token if missing + if (!this.token) { + this.token = await this.getStoreValue('token'); + } + + // --- Main API calls --- + const data = await api.getMeasurement(this.url, this.token); + + const setCapabilityPromises = []; + + // Power + setCapabilityPromises.push(this._setCapabilityValue('measure_power', data.power_w)); + + // Import + setCapabilityPromises.push(this._setCapabilityValue('meter_power.import', data.energy_import_kwh)); + + // Export (only if non-zero) + if (data.energy_export_kwh !== 0) { + setCapabilityPromises.push(this._setCapabilityValue('meter_power.export', data.energy_export_kwh)); + } + + // Aggregated meter_power + if (!this.hasCapability('meter_power')) { + await this.addCapability('meter_power').catch(this.error); + } + if (data.energy_import_kwh !== undefined) { + const calcValue = data.energy_import_kwh - data.energy_export_kwh; + if (this.getCapabilityValue('meter_power') !== calcValue) { + setCapabilityPromises.push(this._setCapabilityValue('meter_power', calcValue)); + } + } + + // Voltage & Current + setCapabilityPromises.push(this._setCapabilityValue('measure_voltage', data.voltage_v)); + setCapabilityPromises.push(this._setCapabilityValue('measure_current', data.current_a)); + + await Promise.allSettled(setCapabilityPromises); + + // --- Battery mode handling --- + const batteryMode = await api.getMode(this.url, this.token); + + if (batteryMode) { + const normalized = normalizeBatteryMode(batteryMode); + + // Update settings if changed + if (settings.mode !== normalized) { + await this.setSettings({ mode: normalized }); + } + + // Update capabilities + await this._setCapabilityValue('measure_power.battery_group_power_w', batteryMode.power_w ?? null); + await this._setCapabilityValue('measure_power.battery_group_target_power_w', batteryMode.target_power_w ?? null); + await this._setCapabilityValue('measure_power.battery_group_max_consumption_w', batteryMode.max_consumption_w ?? null); + await this._setCapabilityValue('measure_power.battery_group_max_production_w', batteryMode.max_production_w ?? null); + + // Flow triggers + await this._triggerFlowOnChange('battery_mode_changed_SDM230_v2', normalized); + //await this._triggerFlowOnChange('measure_power.battery_group_power_w', batteryMode.power_w ?? null); + } + + + + // Trigger flows when values change + //await this._triggerFlowOnChange('measure_power', data.power_w); + await this._triggerFlowOnChange('meter_power.import', data.energy_import_kwh); + await this._triggerFlowOnChange('meter_power.export', data.energy_export_kwh); + //await this._triggerFlowOnChange('measure_voltage', data.voltage_v); + //await this._triggerFlowOnChange('measure_current', data.current_a); + + // If everything succeeded + await this.setAvailable(); + + } catch (err) { + this.error('Polling failed:', err); + await this.setUnavailable(err).catch(this.error); + } +} + + + onSettings(MySettings) { + this.log('Settings updated'); + this.log('Settings:', MySettings); + // Update interval polling + if ('polling_interval' in MySettings.oldSettings + && MySettings.oldSettings.polling_interval !== MySettings.newSettings.polling_interval + ) { + this.log('Polling_interval for P1 changed to:', MySettings.newSettings.polling_interval); + clearInterval(this.onPollInterval); + // this.onPollInterval = setInterval(this.onPoll.bind(this), MySettings.newSettings.polling_interval * 1000); + this.onPollInterval = setInterval(this.onPoll.bind(this), 1000 * this.getSettings().polling_interval); + } + if ('mode' in MySettings.oldSettings + && MySettings.oldSettings.mode !== MySettings.newSettings.mode + ) { + this.log('Mode for Plugin Battery via SDM230 advanced settings changed to:', MySettings.newSettings.mode); + api.setMode(this.url, this.token, MySettings.newSettings.mode); + } + // return true; + } + +}; diff --git a/drivers/SDM230_v2/driver.compose.json b/drivers/SDM230_v2/driver.compose.json new file mode 100644 index 00000000..7cbe6fd3 --- /dev/null +++ b/drivers/SDM230_v2/driver.compose.json @@ -0,0 +1,123 @@ +{ + "name": { + "en": "kWh Meter 1P (APIv2)" + }, + "images": { + "large": "drivers/SDM230_v2/assets/images/large.png", + "small": "drivers/SDM230_v2/assets/images/small.png" + }, + "class": "socket", + "discovery": "SDM230_v2", + "platforms": [ + "local" + ], + "capabilities": [ + "measure_power", + "meter_power", + "measure_current", + "meter_power.import", + "meter_power.export", + "measure_voltage" + ], + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "meter_power.import": { + "decimals": 3, + "title": { + "en": "Total usage", + "nl": "Totaal gebruik" + } + }, + "meter_power.export": { + "decimals": 3, + "title": { + "en": "Total deliver", + "nl": "Totaal teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + }, + "insights": true + }, + "measure_voltage": { + "title": { + "en": "Current Voltage", + "nl": "Huidig Voltage" + }, + "insights": true + }, + "measure_current": { + "title": { + "en": "Current Amp", + "nl": "Huidig Amp" + }, + "insights": true + }, + "measure_power.battery_group_power_w": { + "title": { + "en": "Battery group Current combined Power", + "nl": "Battery groep Huidig samengesteld vermogen" + } + }, + "measure_power.battery_group_target_power_w": { + "title": { + "en": "Battery group Target Power", + "nl": "Battery groep Doel vermogen" + } + }, + "measure_power.battery_group_max_consumption_w": { + "title": { + "en": "Battery group Max allowed Consumption Power", + "nl": "Battery groep Max toegestaand gebruiksvermogen" + } + }, + "measure_power.battery_group_max_production_w": { + "title": { + "en": "Battery group Max allowed Production Power", + "nl": "Battery groep Max toegestaand leveringssvermogen" + } + } + }, + "pair": [ + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "authorize" + }, + "options": { "singular": true } + }, + { + "id": "authorize", + "navigation": + { + "prev": "list_devices" + } + } + ], + "id": "SDM230_v2", + "settings": [ + { + "id": "polling_interval", + "type": "number", + "label": { + "en": "Polling interval" + }, + "value": 10, + "min": 1, + "unit": { + "en": "s" + } + } + ] +} \ No newline at end of file diff --git a/drivers/SDM230_v2/driver.flow.compose.json b/drivers/SDM230_v2/driver.flow.compose.json new file mode 100644 index 00000000..d6ceedc1 --- /dev/null +++ b/drivers/SDM230_v2/driver.flow.compose.json @@ -0,0 +1,117 @@ +{ + "triggers": [ + { + "id": "meter_power.import", + "title": { + "en": "Import power changed", + "nl": "Importvermogen gewijzigd" + }, + "titleFormatted": { + "en": "Import power changed", + "nl": "Importvermogen gewijzigd" + }, + "args": [], + "tokens": [ + { + "name": "import_power", + "type": "number", + "title": { + "en": "Import Power (W)", + "nl": "Importvermogen (W)" + } + } + ] + }, + { + "id": "meter_power.export", + "title": { + "en": "Export power changed", + "nl": "Exportvermogen gewijzigd" + }, + "titleFormatted": { + "en": "Export power changed", + "nl": "Exportvermogen gewijzigd" + }, + "args": [], + "tokens": [ + { + "name": "export_power", + "type": "number", + "title": { + "en": "Export Power (W)", + "nl": "Exportvermogen (W)" + } + } + ] + }, + { + "id": "battery_mode_changed_SDM230_v2", + "title": { "en": "Battery mode changed" }, + "titleFormatted": { "en": "Battery mode changed" }, + "args": [], + "tokens": [] + } + ], + "actions": [ + { + "id": "sdm230-set-battery-to-full-charge-mode", + "title": { + "en": "Set battery to Full Charge mode", + "nl": "Zet batterij in Volledig Oplaad modus" + }, + "titleFormatted": { + "en": "Set battery to Full Charge mode", + "nl": "Zet batterij in Volledig Oplaad modus" + }, + "args": [] + }, + { + "id": "sdm230-set-battery-to-zero-mode", + "title": { + "en": "Set battery to Zero mode", + "nl": "Zet batterij in Zero modus" + }, + "titleFormatted": { + "en": "Set battery to Zero mode", + "nl": "Zet batterij in Zero modus" + }, + "args": [] + }, + { + "id": "sdm230-set-battery-to-standby-mode", + "title": { + "en": "Set battery to Standby mode", + "nl": "Zet batterij in Standby modus" + }, + "titleFormatted": { + "en": "Set battery to Standby mode", + "nl": "Zet batterij in Standby modus" + }, + "args": [] + }, + { + "id": "sdm230-set-battery-to-zero-charge-only-mode", + "title": { + "en": "Set battery to Zero Charge Only mode", + "nl": "Zet batterij in Alleen Opladen modus" + }, + "titleFormatted": { + "en": "Set battery to Zero Charge Only mode", + "nl": "Zet batterij in Alleen Opladen modus" + }, + "args": [] + }, + { + "id": "sdm230-set-battery-to-zero-discharge-only-mode", + "title": { + "en": "Set battery to Zero Discharge Only mode", + "nl": "Zet batterij in Alleen Ontladen modus" + }, + "titleFormatted": { + "en": "Set battery to Zero Discharge Only mode", + "nl": "Zet batterij in Alleen Ontladen modus" + }, + "args": [] + } + ] +} diff --git a/drivers/SDM230_v2/driver.js b/drivers/SDM230_v2/driver.js new file mode 100644 index 00000000..fa129193 --- /dev/null +++ b/drivers/SDM230_v2/driver.js @@ -0,0 +1,6 @@ +'use strict'; + +const Homey = require('homey'); +const driver = require('../../includes/v2/Driver'); + +module.exports = driver; diff --git a/drivers/SDM230_v2/pair/authorize.html b/drivers/SDM230_v2/pair/authorize.html new file mode 100644 index 00000000..7fb8dd67 --- /dev/null +++ b/drivers/SDM230_v2/pair/authorize.html @@ -0,0 +1,48 @@ +
+

+

+

+

+ +
+ + diff --git a/drivers/SDM630-p1mode/assets/icon.svg b/drivers/SDM630-p1mode/assets/icon.svg new file mode 100644 index 00000000..2dba88f5 --- /dev/null +++ b/drivers/SDM630-p1mode/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drivers/SDM630-p1mode/assets/images/large.png b/drivers/SDM630-p1mode/assets/images/large.png new file mode 100644 index 00000000..058db062 Binary files /dev/null and b/drivers/SDM630-p1mode/assets/images/large.png differ diff --git a/drivers/SDM630-p1mode/assets/images/small.png b/drivers/SDM630-p1mode/assets/images/small.png new file mode 100644 index 00000000..e3883a2d Binary files /dev/null and b/drivers/SDM630-p1mode/assets/images/small.png differ diff --git a/drivers/SDM630-p1mode/device.js b/drivers/SDM630-p1mode/device.js new file mode 100644 index 00000000..44af677f --- /dev/null +++ b/drivers/SDM630-p1mode/device.js @@ -0,0 +1,195 @@ +'use strict'; + +const Homey = require('homey'); + +const fetch = require('node-fetch'); +// const POLL_INTERVAL = 1000 * 1; // 1 seconds + +// const Homey2023 = Homey.platform === 'local' && Homey.platformVersion === 2; + +async function updateCapability(device, capability, value) { + try { + const current = device.getCapabilityValue(capability); + + // --- SAFE REMOVE --- + // Removal is allowed only when: + // 1) the new value is null + // 2) the current value in Homey is also null + + if (value == null && current == null) { + if (device.hasCapability(capability)) { + await device.removeCapability(capability); + device.log(`🗑️ Removed capability "${capability}"`); + } + return; + } + + // --- ADD IF MISSING --- + if (!device.hasCapability(capability)) { + try { + await device.addCapability(capability); + device.log(`➕ Added capability "${capability}"`); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + device.log(`Capability already exists: ${capability} — ignoring`); + } else { + throw err; + } + } + } + + // --- UPDATE --- + if (current !== value) { + await device.setCapabilityValue(capability, value); + } + + } catch (err) { + if (err.message === 'device_not_found') { + device.log(`⚠️ Skipping capability "${capability}" — device not found`); + return; + } + device.error(`❌ Failed updateCapability("${capability}")`, err); + } +} + +module.exports = class HomeWizardEnergyDevice630 extends Homey.Device { + +async onInit() { + // await this.setUnavailable(`${this.getName()} ${this.homey.__('device.init')}`); + const settings = this.getSettings(); + this.log('Settings for SDM630:', settings.polling_interval); + + if (settings.polling_interval == null) { + settings.polling_interval = 10; + await this.setSettings({ polling_interval: 10 }); + } + + this.onPollInterval = setInterval(this.onPoll.bind(this), 1000 * settings.polling_interval); + +// if (this.getClass() === 'sensor') { +// this.setClass('socket'); +// this.log('Changed sensor to socket.'); +// } + +} + + + onDeleted() { + if (this.onPollInterval) { + clearInterval(this.onPollInterval); + this.onPollInterval = null; + } + } + + onDiscoveryAvailable(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this.log(`URL: ${this.url}`); + this.onPoll(); + } + + onDiscoveryAddressChanged(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this.log(`URL: ${this.url}`); + this.log('onDiscoveryAddressChanged'); + this.onPoll(); + } + + onDiscoveryLastSeenChanged(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this.log(`URL: ${this.url}`); + this.setAvailable(); + this.onPoll(); + } + + async onPoll() { + const settings = this.getSettings(); + + if (!this.url) { + if (settings.url) { + this.url = settings.url; + this.log(`ℹ️ this.url was empty, restored from settings: ${this.url}`); + } else { + this.error('❌ this.url is empty and no fallback settings.url found — aborting poll'); + await this.setUnavailable().catch(this.error); + return; + } + } + + try { + let res = await fetch(`${this.url}/data`); + if (!res || !res.ok) { + await new Promise((resolve) => setTimeout(resolve, 60000)); + res = await fetch(`${this.url}/data`); + if (!res || !res.ok) throw new Error(res ? res.statusText : 'Unknown error during fetch'); + } + + const data = await res.json(); + + // Core capabilities + await updateCapability(this, 'rssi', data.wifi_strength).catch(this.error); + await updateCapability(this, 'measure_power', data.active_power_w).catch(this.error); + await updateCapability(this, 'measure_power.active_power_w', data.active_power_w).catch(this.error); + await updateCapability(this, 'meter_power.consumed.t1', data.total_power_import_t1_kwh).catch(this.error); + + // Solar export + if (data.total_power_export_t1_kwh > 1) { + await updateCapability(this, 'meter_power.produced.t1', data.total_power_export_t1_kwh).catch(this.error); + } else { + await updateCapability(this, 'meter_power.produced.t1', null).catch(this.error); + } + + // Aggregated meter + await updateCapability( + this, + 'meter_power', + data.total_power_import_t1_kwh - data.total_power_export_t1_kwh + ).catch(this.error); + + // Always update 3‑phase values + await updateCapability(this, 'measure_power.l1', data.active_power_l1_w).catch(this.error); + await updateCapability(this, 'measure_power.l2', data.active_power_l2_w).catch(this.error); + await updateCapability(this, 'measure_power.l3', data.active_power_l3_w).catch(this.error); + + // Voltage per phase + await updateCapability(this, 'measure_voltage.l1', data.active_voltage_l1_v).catch(this.error); + await updateCapability(this, 'measure_voltage.l2', data.active_voltage_l2_v).catch(this.error); + await updateCapability(this, 'measure_voltage.l3', data.active_voltage_l3_v).catch(this.error); + + // Current per phase + await updateCapability(this, 'measure_current.l1', data.active_current_l1_a).catch(this.error); + await updateCapability(this, 'measure_current.l2', data.active_current_l2_a).catch(this.error); + await updateCapability(this, 'measure_current.l3', data.active_current_l3_a).catch(this.error); + + // Update settings URL if changed + if (this.url !== settings.url) { + this.log('SDM630-p1mode - Updating settings url'); + await this.setSettings({ url: this.url }); + } + + this.setAvailable().catch(this.error); + + } catch (err) { + this.error(err); + this.setUnavailable(err).catch(this.error); + } +} + + + + async onSettings(MySettings) { + this.log('Settings updated'); + this.log('Settings:', MySettings); + // Update interval polling + if ( + 'polling_interval' in MySettings.oldSettings + && MySettings.oldSettings.polling_interval !== MySettings.newSettings.polling_interval + ) { + this.log('Polling_interval for SDM630-p1 changed to:', MySettings.newSettings.polling_interval); + clearInterval(this.onPollInterval); + // this.onPollInterval = setInterval(this.onPoll.bind(this), MySettings.newSettings.polling_interval * 1000); + this.onPollInterval = setInterval(this.onPoll.bind(this), 1000 * this.getSettings().polling_interval); + } + // return true; + } + +}; diff --git a/drivers/SDM630-p1mode/driver.compose.json b/drivers/SDM630-p1mode/driver.compose.json new file mode 100644 index 00000000..65259eb4 --- /dev/null +++ b/drivers/SDM630-p1mode/driver.compose.json @@ -0,0 +1,135 @@ +{ + "name": { + "en": "kWh Meter (3 phase) P1 mode" + }, + "images": { + "large": "drivers/SDM630-p1mode/assets/images/large.png", + "small": "drivers/SDM630-p1mode/assets/images/small.png" + }, + "class": "socket", + "discovery": "SDM630", + "platforms": [ + "local" + ], + "capabilities": [ + "measure_power", + "meter_power", + "measure_power.l1", + "measure_power.l2", + "measure_power.l3", + "measure_current.l1", + "measure_current.l2", + "measure_current.l3", + "measure_voltage.l1", + "measure_voltage.l2", + "measure_voltage.l3", + "rssi" + ], + "energy": { + "cumulative": true, + "cumulativeImportedCapability": "meter_power.consumed.t1", + "cumulativeExportedCapability": "meter_power.produced.t1" + }, + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + } + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "measure_power.l2": { + "title": { + "en": "Current usage phase 2", + "nl": "Huidig gebruik fase 2" + } + }, + "measure_power.l3": { + "title": { + "en": "Current usage phase 3", + "nl": "Huidig gebruik fase 3" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + } + }, + "measure_current.l1": { + "title": { + "en": "Current Amp phase 1", + "nl": "Huidig Amp fase 1" + } + }, + "measure_current.l2": { + "title": { + "en": "Current Amp phase 2", + "nl": "Huidig Amp fase 2" + } + }, + "measure_current.l3": { + "title": { + "en": "Current Amp phase 3", + "nl": "Huidig Amp fase 3" + } + }, + "measure_voltage.l1": { + "title": { + "en": "Current Voltage phase 1", + "nl": "Huidig Voltage fase 1" + } + }, + "measure_voltage.l2": { + "title": { + "en": "Current Voltage phase 2", + "nl": "Huidig Voltage fase 2" + } + }, + "measure_voltage.l3": { + "title": { + "en": "Current Voltage phase 3", + "nl": "Huidig Voltage fase 3" + } + } + }, + "pair": [ + { + "id": "start", + "navigation": { + "next": "list_devices" + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ] +} \ No newline at end of file diff --git a/drivers/SDM630-p1mode/driver.js b/drivers/SDM630-p1mode/driver.js new file mode 100644 index 00000000..a1a0dc29 --- /dev/null +++ b/drivers/SDM630-p1mode/driver.js @@ -0,0 +1,5 @@ +'use strict'; + +const driver = require('../../includes/v1/driver.js'); + +module.exports = driver; diff --git a/drivers/SDM630-p1mode/driver.settings.compose.json b/drivers/SDM630-p1mode/driver.settings.compose.json new file mode 100644 index 00000000..fb0f7f36 --- /dev/null +++ b/drivers/SDM630-p1mode/driver.settings.compose.json @@ -0,0 +1,16 @@ +[ + { + "id": "polling_interval", + "type": "number", + "label": { "en": "Polling interval" }, + "value": 10, + "unit": { "en": "s" } + }, + { + "id": "url", + "type": "text", + "label": { "en": "url", + "nl": "url" + } + } +] \ No newline at end of file diff --git a/drivers/SDM630-p1mode/pair/start.html b/drivers/SDM630-p1mode/pair/start.html new file mode 100644 index 00000000..633815c6 --- /dev/null +++ b/drivers/SDM630-p1mode/pair/start.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/drivers/SDM630/assets/icon.svg b/drivers/SDM630/assets/icon.svg new file mode 100644 index 00000000..2dba88f5 --- /dev/null +++ b/drivers/SDM630/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drivers/SDM630/assets/images/large.png b/drivers/SDM630/assets/images/large.png new file mode 100644 index 00000000..058db062 Binary files /dev/null and b/drivers/SDM630/assets/images/large.png differ diff --git a/drivers/SDM630/assets/images/small.png b/drivers/SDM630/assets/images/small.png new file mode 100644 index 00000000..e3883a2d Binary files /dev/null and b/drivers/SDM630/assets/images/small.png differ diff --git a/drivers/SDM630/device.js b/drivers/SDM630/device.js new file mode 100644 index 00000000..46bb0d65 --- /dev/null +++ b/drivers/SDM630/device.js @@ -0,0 +1,360 @@ +'use strict'; + +const Homey = require('homey'); +const fetch = require('node-fetch'); +const http = require('http'); + +async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { + ...options, + signal: controller.signal, + }); + return res; + } catch (err) { + if (err.name === 'AbortError') { + throw new Error('TIMEOUT'); + } + throw err; + } finally { + clearTimeout(timer); + } +} + + + +/** + * Shared keep‑alive agent (blijft) + */ +const agent = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 11000 +}); + +/** + * Stable capability updater — deletion‑safe + */ +async function updateCapability(device, capability, value) { + try { + const current = device.getCapabilityValue(capability); + + // --- SAFE REMOVE --- + // Removal is allowed only when: + // 1) the new value is null + // 2) the current value in Homey is also null + + if (value == null && current == null) { + if (device.hasCapability(capability)) { + await device.removeCapability(capability); + device.log(`🗑️ Removed capability "${capability}"`); + } + return; + } + + // --- ADD IF MISSING --- + if (!device.hasCapability(capability)) { + try { + await device.addCapability(capability); + device.log(`➕ Added capability "${capability}"`); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + device.log(`Capability already exists: ${capability} — ignoring`); + } else { + throw err; + } + } + } + + // --- UPDATE --- + if (current !== value) { + await device.setCapabilityValue(capability, value); + } + + } catch (err) { + if (err.message === 'device_not_found') { + device.log(`⚠️ Skipping capability "${capability}" — device not found`); + return; + } + device.error(`❌ Failed updateCapability("${capability}")`, err); + } +} + +module.exports = class HomeWizardEnergyDevice630 extends Homey.Device { + + async onInit() { + + this._debugLogs = []; + + const settings = this.getSettings(); + + if (settings.polling_interval == null) { + await this.setSettings({ polling_interval: 10 }); + } + + const interval = Math.max(settings.polling_interval, 2); + const offset = Math.floor(Math.random() * interval * 1000); + + if (this.onPollInterval) clearInterval(this.onPollInterval); + + setTimeout(() => { + this.onPoll().catch(this.error); + this.onPollInterval = setInterval(() => { + this.onPoll().catch(this.error); + }, interval * 1000); + }, offset); + + if (this.getClass() === 'sensor') { + this.setClass('socket'); + } + } + + onDeleted() { + if (this.onPollInterval) { + clearInterval(this.onPollInterval); + this.onPollInterval = null; + } + } + + /** + * Discovery + */ + onDiscoveryAvailable(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this.setAvailable(); + } + + onDiscoveryAddressChanged(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this._debugLog(`Discovery address changed: ${this.url}`); + this.setAvailable(); + } + + onDiscoveryLastSeenChanged(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this.setAvailable(); + } + + /** + * Debug logger (batched writes) + */ + _debugLog(msg) { + try { + if (!this._debugBuffer) this._debugBuffer = []; + const ts = new Date().toLocaleString('nl-NL', { hour12: false, timeZone: 'Europe/Amsterdam' }); + const driverName = this.driver.id; + const deviceName = this.getName(); + const safeMsg = typeof msg === 'string' ? msg : (msg instanceof Error ? msg.message : JSON.stringify(msg)); + const line = `${ts} [${driverName}] [${deviceName}] ${safeMsg}`; + this._debugBuffer.push(line); + if (this._debugBuffer.length > 20) this._debugBuffer.shift(); + if (!this._debugFlushTimeout) { + this._debugFlushTimeout = setTimeout(() => { + this._flushDebugLogs(); + this._debugFlushTimeout = null; + }, 5000); + } + } catch (err) { + this.error('Failed to write debug logs:', err.message || err); + } +} +_flushDebugLogs() { + if (!this._debugBuffer || this._debugBuffer.length === 0) return; + try { + const logs = this.homey.settings.get('debug_logs') || []; + logs.push(...this._debugBuffer); + if (logs.length > 500) logs.splice(0, logs.length - 500); + this.homey.settings.set('debug_logs', logs); + this._debugBuffer = []; + } catch (err) { + this.error('Failed to flush debug logs:', err.message || err); + } +} + + /** + * Cloud toggles + */ + async setCloudOn() { + if (!this.url) return; + + const res = await fetchWithTimeout(`${this.url}/system`, { + agent, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cloud_enabled: true }) + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + async setCloudOff() { + if (!this.url) return; + + const res = await fetchWithTimeout(`${this.url}/system`, { + agent, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cloud_enabled: false }) + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + /** + * GET /data + */ + async onPoll() { + const settings = this.getSettings(); + + if (!this.url) { + if (settings.url) { + this.url = settings.url; + } else { + await this.setUnavailable('Missing URL'); + return; + } + } + + try { + + const res = await fetchWithTimeout(`${this.url}/data`, { + agent, + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + + const data = await res.json(); + if (!data || typeof data !== 'object') throw new Error('Invalid JSON'); + + const tasks = []; + + // 1‑fase + totaal + tasks.push(updateCapability(this, 'measure_power', data.active_power_w)); + tasks.push(updateCapability(this, 'meter_power.consumed.t1', data.total_power_import_t1_kwh)); + tasks.push(updateCapability(this, 'rssi', data.wifi_strength)); + + if (data.total_power_export_t1_kwh > 1) { + tasks.push(updateCapability(this, 'meter_power.produced.t1', data.total_power_export_t1_kwh)); + } + + const net = data.total_power_import_t1_kwh - data.total_power_export_t1_kwh; + tasks.push(updateCapability(this, 'meter_power', net)); + + // 3‑fase power + tasks.push(updateCapability(this, 'measure_power.l1', data.active_power_l1_w)); + tasks.push(updateCapability(this, 'measure_power.l2', data.active_power_l2_w)); + tasks.push(updateCapability(this, 'measure_power.l3', data.active_power_l3_w)); + + // 3‑fase voltage + tasks.push(updateCapability(this, 'measure_voltage.l1', data.active_voltage_l1_v)); + tasks.push(updateCapability(this, 'measure_voltage.l2', data.active_voltage_l2_v)); + tasks.push(updateCapability(this, 'measure_voltage.l3', data.active_voltage_l3_v)); + + // 3‑fase current + tasks.push(updateCapability(this, 'measure_current.l1', data.active_current_l1_a)); + tasks.push(updateCapability(this, 'measure_current.l2', data.active_current_l2_a)); + tasks.push(updateCapability(this, 'measure_current.l3', data.active_current_l3_a)); + + // --- Phase energy meters (derived kWh) --- + const intervalSec = Math.max(settings.polling_interval, 2); + + // --- Local day detection (NO UTC) --- + const todayKey = new Date().toLocaleDateString('nl-NL', { + timeZone: 'Europe/Amsterdam' + }); + + const lastDayKey = this.getStoreValue('day_date'); + + // Daily reset when local calendar day changes + if (lastDayKey !== todayKey) { + await this.setStoreValue('day_l1', 0); + await this.setStoreValue('day_l2', 0); + await this.setStoreValue('day_l3', 0); + await this.setStoreValue('day_date', todayKey); + this.log('Daily phase energy counters reset (local day change)'); + } + + // Initialize total energy store values if missing + if (this.getStoreValue('meter_l1') == null) await this.setStoreValue('meter_l1', 0); + if (this.getStoreValue('meter_l2') == null) await this.setStoreValue('meter_l2', 0); + if (this.getStoreValue('meter_l3') == null) await this.setStoreValue('meter_l3', 0); + + // Initialize daily energy store values if missing + if (this.getStoreValue('day_l1') == null) await this.setStoreValue('day_l1', 0); + if (this.getStoreValue('day_l2') == null) await this.setStoreValue('day_l2', 0); + if (this.getStoreValue('day_l3') == null) await this.setStoreValue('day_l3', 0); + + // Convert W → kWh increment (can be negative = export) + const incL1 = (data.active_power_l1_w || 0) * (intervalSec / 3600); + const incL2 = (data.active_power_l2_w || 0) * (intervalSec / 3600); + const incL3 = (data.active_power_l3_w || 0) * (intervalSec / 3600); + + // Update total kWh + const newL1 = this.getStoreValue('meter_l1') + incL1; + const newL2 = this.getStoreValue('meter_l2') + incL2; + const newL3 = this.getStoreValue('meter_l3') + incL3; + + await this.setStoreValue('meter_l1', newL1); + await this.setStoreValue('meter_l2', newL2); + await this.setStoreValue('meter_l3', newL3); + + // Update daily kWh + const newDayL1 = this.getStoreValue('day_l1') + incL1; + const newDayL2 = this.getStoreValue('day_l2') + incL2; + const newDayL3 = this.getStoreValue('day_l3') + incL3; + + await this.setStoreValue('day_l1', newDayL1); + await this.setStoreValue('day_l2', newDayL2); + await this.setStoreValue('day_l3', newDayL3); + + // Update capabilities (total) + tasks.push(updateCapability(this, 'meter_power.l1', newL1)); + tasks.push(updateCapability(this, 'meter_power.l2', newL2)); + tasks.push(updateCapability(this, 'meter_power.l3', newL3)); + + // Update capabilities (daily) + tasks.push(updateCapability(this, 'meter_power.day.l1', newDayL1)); + tasks.push(updateCapability(this, 'meter_power.day.l2', newDayL2)); + tasks.push(updateCapability(this, 'meter_power.day.l3', newDayL3)); + + + + await Promise.allSettled(tasks); + + await this.setAvailable(); + + } catch (err) { + this._debugLog(`Poll failed: ${err.message}`); + this.setUnavailable(err.message || 'Polling error').catch(this.error); + } + } + + /** + * Settings handler + */ + onSettings(event) { + const { newSettings, changedKeys } = event; + + for (const key of changedKeys) { + + if (key === 'polling_interval') { + const interval = Math.max(newSettings.polling_interval, 2); + + if (this.onPollInterval) clearInterval(this.onPollInterval); + + this.onPollInterval = setInterval(() => { + this.onPoll().catch(this.error); + }, interval * 1000); + } + + if (key === 'cloud') { + if (newSettings.cloud == 1) this.setCloudOn(); + else this.setCloudOff(); + } + } + } +}; diff --git a/drivers/SDM630/driver.compose.json b/drivers/SDM630/driver.compose.json new file mode 100644 index 00000000..75fc0e21 --- /dev/null +++ b/drivers/SDM630/driver.compose.json @@ -0,0 +1,179 @@ +{ + "name": { + "en": "kWh Meter (3 phase)" + }, + "images": { + "large": "drivers/SDM630/assets/images/large.png", + "small": "drivers/SDM630/assets/images/small.png" + }, + "class": "socket", + "discovery": "SDM630", + "platforms": [ + "local" + ], + "capabilities": [ + "measure_power", + "meter_power", + "meter_power.l1", + "meter_power.l2", + "meter_power.l3", + "measure_power.l1", + "measure_power.l2", + "measure_power.l3", + "meter_power.day.l1", + "meter_power.day.l2", + "meter_power.day.l3", + "measure_current.l1", + "measure_current.l2", + "measure_current.l3", + "measure_voltage.l1", + "measure_voltage.l2", + "measure_voltage.l3", + "rssi" + ], + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "measure_power.l2": { + "title": { + "en": "Current usage phase 2", + "nl": "Huidig gebruik fase 2" + } + }, + "measure_power.l3": { + "title": { + "en": "Current usage phase 3", + "nl": "Huidig gebruik fase 3" + } + }, + "meter_power.day.l1": { + "decimals": 3, + "title": { + "en": "Daily usage Phase 1", + "nl": "Dagverbruik Fase 1" + } + }, + "meter_power.day.l2": { + "decimals": 3, + "title": { + "en": "Daily usage Phase 2", + "nl": "Dagverbruik Fase 2" + } + }, + "meter_power.day.l3": { + "decimals": 3, + "title": { + "en": "Daily usage Phase 3", + "nl": "Dagverbruik Fase 3" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + } + }, + "meter_power.l1": { + "decimals": 3, + "title": { + "en": "Total usage KWh Phase 1", + "nl": "Totaal verbruik KWh Fase 1" + } + }, + "meter_power.l2": { + "decimals": 3, + "title": { + "en": "Total usage KWh Phase 2", + "nl": "Totaal verbruik KWh Fase 2" + } + }, + "meter_power.l3": { + "decimals": 3, + "title": { + "en": "Total usage KWh Phase 3", + "nl": "Totaal verbruik KWh Fase 3" + } + }, + "measure_current.l1": { + "title": { + "en": "Current Amp phase 1", + "nl": "Huidig Amp fase 1" + } + }, + "measure_current.l2": { + "title": { + "en": "Current Amp phase 2", + "nl": "Huidig Amp fase 2" + } + }, + "measure_current.l3": { + "title": { + "en": "Current Amp phase 3", + "nl": "Huidig Amp fase 3" + } + }, + "measure_voltage.l1": { + "title": { + "en": "Current Voltage phase 1", + "nl": "Huidig Voltage fase 1" + } + }, + "measure_voltage.l2": { + "title": { + "en": "Current Voltage phase 2", + "nl": "Huidig Voltage fase 2" + } + }, + "measure_voltage.l3": { + "title": { + "en": "Current Voltage phase 3", + "nl": "Huidig Voltage fase 3" + } + } + }, + "pair": [ + { + "id": "start", + "navigation": { + "next": "list_devices" + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ] +} \ No newline at end of file diff --git a/drivers/SDM630/driver.js b/drivers/SDM630/driver.js new file mode 100644 index 00000000..a1a0dc29 --- /dev/null +++ b/drivers/SDM630/driver.js @@ -0,0 +1,5 @@ +'use strict'; + +const driver = require('../../includes/v1/driver.js'); + +module.exports = driver; diff --git a/drivers/SDM630/driver.settings.compose.json b/drivers/SDM630/driver.settings.compose.json new file mode 100644 index 00000000..857734d9 --- /dev/null +++ b/drivers/SDM630/driver.settings.compose.json @@ -0,0 +1,25 @@ +[ + { + "id": "polling_interval", + "type": "number", + "label": { "en": "Polling interval" }, + "value": 10, + "min": 1, + "unit": { "en": "s" } + }, + { + "id": "cloud", + "type": "number", + "label": { "en": "Cloud connection 1=on 0=off", + "nl": "Cloud verbinding 1=actief 0=uit" + }, + "value": 1 + }, + { + "id": "url", + "type": "text", + "label": { "en": "url", + "nl": "url" + } + } +] \ No newline at end of file diff --git a/drivers/SDM630/pair/start.html b/drivers/SDM630/pair/start.html new file mode 100644 index 00000000..633815c6 --- /dev/null +++ b/drivers/SDM630/pair/start.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/drivers/SDM630_v2/assets/icon.svg b/drivers/SDM630_v2/assets/icon.svg new file mode 100644 index 00000000..2dba88f5 --- /dev/null +++ b/drivers/SDM630_v2/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drivers/SDM630_v2/assets/images/large.png b/drivers/SDM630_v2/assets/images/large.png new file mode 100644 index 00000000..058db062 Binary files /dev/null and b/drivers/SDM630_v2/assets/images/large.png differ diff --git a/drivers/SDM630_v2/assets/images/small.png b/drivers/SDM630_v2/assets/images/small.png new file mode 100644 index 00000000..e3883a2d Binary files /dev/null and b/drivers/SDM630_v2/assets/images/small.png differ diff --git a/drivers/SDM630_v2/device.js b/drivers/SDM630_v2/device.js new file mode 100644 index 00000000..aff5e276 --- /dev/null +++ b/drivers/SDM630_v2/device.js @@ -0,0 +1,513 @@ +'use strict'; + +const Homey = require('homey'); +const api = require('../../includes/v2/Api'); + +// const POLL_INTERVAL = 1000 * 1; // 1 seconds + +function normalizeBatteryMode(data) { + const knownModes = [ + 'zero', + 'standby', + 'to_full', + 'zero_charge_only', + 'zero_discharge_only' + ]; + + let rawMode = data.mode; + + if (typeof rawMode === 'string') { + rawMode = rawMode.trim(); + try { rawMode = JSON.parse(rawMode); } + catch { rawMode = rawMode.replace(/^["']+|["']+$/g, ''); } + } + + if (knownModes.includes(rawMode)) return rawMode; + + if (Array.isArray(data.permissions)) { + const perms = [...data.permissions].sort().join(','); + if (perms === '') return 'standby'; + if (perms === 'charge_allowed,discharge_allowed') return 'zero'; + if (perms === 'charge_allowed') return 'zero_charge_only'; + if (perms === 'discharge_allowed') return 'zero_discharge_only'; + } + + return 'standby'; +} + + + +module.exports = class HomeWizardEnergyDevice630V2 extends Homey.Device { + + async onInit() { + + // await this.setUnavailable(`${this.getName()} ${this.homey.__('device.init')}`); + + this.token = await this.getStoreValue('token'); + //this.log('Token:', this.token); + + await this._updateCapabilities(); + await this._registerCapabilityListeners(); + + const settings = this.getSettings(); + this.log('Settings for SDM630 apiv2: ', settings.polling_interval); + + // Check if polling interval is set in settings else set default value + if (settings.polling_interval === undefined) { + settings.polling_interval = 10; // Default to 10 second if not set + await this.setSettings({ + // Update settings in Homey + polling_interval: 10, + }); + } + + // Register flow card listeners only once (prevent "already registered" warnings) + if (!this.homey.app._flowListenersRegistered_SDM630) { + this.homey.app._flowListenersRegistered_SDM630 = true; + + // Condition Card + const ConditionCardCheckBatteryMode = this.homey.flow.getConditionCard('check-battery-mode'); + ConditionCardCheckBatteryMode.registerRunListener(async (args, state) => { + // this.log('CheckBatteryModeCard'); + + return new Promise(async (resolve, reject) => { + try { + const response = await api.getMode(this.url, this.token); // NEEDS TESTING WITH SDM230 and BATTERY + + if (!response) { + this.log('Invalid response, returning false'); + return resolve(false); + } + + this.log('Retrieved mode:', response.mode); + const normalized = normalizeBatteryMode(response); + return resolve(args.mode === normalized); + + + } catch (error) { + this.log('Error retrieving mode:', error); + return resolve(false); // Or reject(error), depending on your error-handling approach + } + }); + }); + + // + // ✅ SDM630 Battery Mode Action Cards + // + + // Zero mode + this.homey.flow.getActionCard('sdm630-set-battery-to-zero-mode') + .registerRunListener(async () => { + this.log('ActionCard: Set Battery to Zero Mode'); + + try { + const response = await api.setMode(this.url, this.token, 'zero'); + + if (!response) { + this.log('Invalid response, returning false'); + return false; + } + + const normalized = normalizeBatteryMode(response); + this.log('Set mode to zero:', normalized); + return normalized; + + } catch (error) { + this.error('Error set mode to zero:', error); + return false; + } + }); + + + // Standby mode + this.homey.flow.getActionCard('sdm630-set-battery-to-standby-mode') + .registerRunListener(async () => { + this.log('ActionCard: Set Battery to Standby Mode'); + + try { + const response = await api.setMode(this.url, this.token, 'standby'); + + if (!response) { + this.log('Invalid response, returning false'); + return false; + } + + const normalized = normalizeBatteryMode(response); + this.log('Set mode to standby:', normalized); + return normalized; + + } catch (error) { + this.error('Error set mode to standby:', error); + return false; + } + }); + + + // Full charge mode + this.homey.flow.getActionCard('sdm630-set-battery-to-full-charge-mode') + .registerRunListener(async () => { + this.log('ActionCard: Set Battery to Full Charge Mode'); + + try { + const response = await api.setMode(this.url, this.token, 'to_full'); + + if (!response) { + this.log('Invalid response, returning false'); + return false; + } + + const normalized = normalizeBatteryMode(response); + this.log('Set mode to full charge:', normalized); + return normalized; + + } catch (error) { + this.error('Error set mode to full charge:', error); + return false; + } + }); + + + // Zero charge only + this.homey.flow.getActionCard('sdm630-set-battery-to-zero-charge-only-mode') + .registerRunListener(async () => { + this.log('ActionCard: Set Battery to Zero Charge Only Mode'); + + try { + const response = await api.setMode(this.url, this.token, 'zero_charge_only'); + + if (!response) { + this.log('Invalid response, returning false'); + return false; + } + + const normalized = normalizeBatteryMode(response); + this.log('Set mode to zero_charge_only:', normalized); + return normalized; + + } catch (error) { + this.error('Error set mode to zero_charge_only:', error); + return false; + } + }); + + + // Zero discharge only + this.homey.flow.getActionCard('sdm630-set-battery-to-zero-discharge-only-mode') + .registerRunListener(async () => { + this.log('ActionCard: Set Battery to Zero Discharge Only Mode'); + + try { + const response = await api.setMode(this.url, this.token, 'zero_discharge_only'); + + if (!response) { + this.log('Invalid response, returning false'); + return false; + } + + const normalized = normalizeBatteryMode(response); + this.log('Set mode to zero_discharge_only:', normalized); + return normalized; + + } catch (error) { + this.error('Error set mode to zero_discharge_only:', error); + return false; + } + }); + + } // End of _flowListenersRegistered_SDM630 guard + + this.onPollInterval = setInterval(this.onPoll.bind(this), 1000 * settings.polling_interval); + + this._triggerFlowPrevious = {}; + + /* + const ActionCardChangeBatteryMode = this.homey.flow.getActionCard('change-battery-mode') + ActionCardChangeBatteryMode.registerRunListener(async (args, state) => { + this.log('ChangeBatteryModeCard change to:', args); + + if (!this.url) { + return false; + } + + return new Promise(async (resolve, reject) => { + try { + const response = await api.setMode(this.url, this.token, args.mode); // NEEDS TESTING WITH P1 and BATTERY + + if (!response || typeof response.mode === 'undefined') { + this.log('Invalid response, returning false'); + return resolve(false); + } + + this.log('Set mode:', response.mode); + return resolve(response.mode); // Returns the mode value + } catch (error) { + this.log('Error set mode:', error); + return resolve(false); // Or reject(error), depending on your error-handling approach + } + }); + }); + */ + + } + + onDeleted() { + if (this.onPollInterval) { + clearInterval(this.onPollInterval); + this.onPollInterval = null; + } + } + + onDiscoveryAvailable(discoveryResult) { + this.url = `https://${discoveryResult.address}`; + this.log(`URL: ${this.url}`); + this.onPoll(); + } + + onDiscoveryAddressChanged(discoveryResult) { + this.url = `https://${discoveryResult.address}`; + this.log(`URL: ${this.url}`); + this.log('onDiscoveryAddressChanged'); + this.onPoll(); + } + + onDiscoveryLastSeenChanged(discoveryResult) { + this.url = `https://${discoveryResult.address}`; + this.log(`URL: ${this.url}`); + this.setAvailable(); + this.onPoll(); + } + + /** + * Helper function to update capabilities configuration. + * This function is called when the device is initialized. + */ + async _updateCapabilities() { + if (!this.hasCapability('identify')) { + try { + await this.addCapability('identify'); + this.log(`created capability identify for ${this.getName()}`); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + this.log(`Capability already exists: identify — ignoring`); + } else { + this.error(err); + } + } + } + + // Remove capabilities that are not needed + if (this.hasCapability('measure_power.power_w')) { + await this.removeCapability('measure_power.power_w').catch(this.error); + this.log(`removed capability measure_power.power_w for ${this.getName()}`); + } + } + + /** + * Helper function to register capability listeners. + * This function is called when the device is initialized. + */ + async _registerCapabilityListeners() { + this.registerCapabilityListener('identify', async (value) => { + await api.identify(this.url, this.token); + }); + } + + /** + * Helper function for 'optional' capabilities. + * This function is called when the device is initialized. + * It will create the capability if it doesn't exist. + * + * We do not remove capabilities here, as we assume the user may want to keep them. + * Besides that we assume that the P1 Meter is connected to a smart meter that does not change often. + * + * @param {string} capability The capability to set + * @param {*} value The value to set + * @returns {Promise} A promise that resolves when the capability is set + */ + async _setCapabilityValue(capability, value) { + // Test if value is undefined, if so, we don't set the capability + if (value === undefined) { + return; + } + + // Create a new capability if it doesn't exist + if (!this.hasCapability(capability)) { + try { + await this.addCapability(capability); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + this.log(`Capability already exists: ${capability} — ignoring`); + } else { + this.error(err); + } + } + } + + // Set the capability value + await this.setCapabilityValue(capability, value).catch(this.error); + } + + /** + * Helper function to trigger flows on change. + * This function is called when the device is initialized. + * + * We use this function to trigger flows when the value changes. + * We store the previous value in a variable. + * + * @param {*} flow_id Flow ID name + * @param {*} value The value to check for changes + * @returns {Promise} A promise that resolves when the flow is triggered + */ + async _triggerFlowOnChange(flow_id, value) { + + // Ignore if value is undefined + if (value === undefined) { + return; + } + + // Check if the value is undefined + // If so, we assume this is the first time we are setting the value + // We cannot trust the the 'trigger' function to be called with the correct value + if (this._triggerFlowPrevious[flow_id] === undefined) { + this._triggerFlowPrevious[flow_id] = value; + return; + } + + // Return of the value is the same as the previous value + if (this._triggerFlowPrevious[flow_id] === value) { + + // We don't need to trigger the flow + return; + } + + // It is a bit 'costly' to get the flow card every time + // But we can assume the trigger does not change often + const flow = this.homey.flow.getDeviceTriggerCard(flow_id); + if (flow === undefined) { + this.error('Flow not found'); + return; + } + + // Update value and trigger the flow + this._triggerFlowPrevious[flow_id] = value; + flow.trigger(this, { [flow_id]: value }).catch(this.error); + } + +async onPoll() { + try { + const settings = this.getSettings(); + + // 1. Restore URL if runtime is empty + if (!this.url) { + if (settings.url) { + this.url = settings.url; + } else { + await this.setUnavailable('Missing URL'); + return; + } + } + + // 2. Sync settings if discovery changed the URL + if (this.url && this.url !== settings.url) { + await this.setSettings({ url: this.url }).catch(this.error); + } + + // Refresh token if missing + if (!this.token) { + this.token = await this.getStoreValue('token'); + } + + // --- Main API calls --- + const data = await api.getMeasurement(this.url, this.token); + + const setCapabilityPromises = []; + + // Power (total + per phase) + setCapabilityPromises.push(this._setCapabilityValue('measure_power', data.power_w)); + setCapabilityPromises.push(this._setCapabilityValue('measure_power.l1', data.power_l1_w)); + setCapabilityPromises.push(this._setCapabilityValue('measure_power.l2', data.power_l2_w)); + setCapabilityPromises.push(this._setCapabilityValue('measure_power.l3', data.power_l3_w)); + + // Import / Export + setCapabilityPromises.push(this._setCapabilityValue('meter_power.import', data.energy_import_kwh)); + if (data.energy_export_kwh !== 0) { + setCapabilityPromises.push(this._setCapabilityValue('meter_power.export', data.energy_export_kwh)); + } + + // Aggregated meter_power + if (!this.hasCapability('meter_power')) { + await this.addCapability('meter_power').catch(this.error); + } + if (data.energy_import_kwh !== undefined) { + const calcValue = data.energy_import_kwh - data.energy_export_kwh; + if (this.getCapabilityValue('meter_power') !== calcValue) { + setCapabilityPromises.push(this._setCapabilityValue('meter_power', calcValue)); + } + } + + // Voltage per phase + setCapabilityPromises.push(this._setCapabilityValue('measure_voltage.l1', data.voltage_l1_v)); + setCapabilityPromises.push(this._setCapabilityValue('measure_voltage.l2', data.voltage_l2_v)); + setCapabilityPromises.push(this._setCapabilityValue('measure_voltage.l3', data.voltage_l3_v)); + + // Current (total + per phase) + setCapabilityPromises.push(this._setCapabilityValue('measure_current', data.current_a)); + setCapabilityPromises.push(this._setCapabilityValue('measure_current.l1', data.current_l1_a)); + setCapabilityPromises.push(this._setCapabilityValue('measure_current.l2', data.current_l2_a)); + setCapabilityPromises.push(this._setCapabilityValue('measure_current.l3', data.current_l3_a)); + + await Promise.allSettled(setCapabilityPromises); + + // For battery mode + + const batteryMode = await api.getMode(this.url, this.token); + + if (batteryMode !== undefined) { + const normalized = normalizeBatteryMode(batteryMode); + + if (settings.mode !== normalized) { + await this.setSettings({ mode: normalized }); + } + + await this._setCapabilityValue('measure_power.battery_group_power_w', batteryMode.power_w ?? null); + await this._setCapabilityValue('measure_power.battery_group_target_power_w', batteryMode.target_power_w ?? null); + await this._setCapabilityValue('measure_power.battery_group_max_consumption_w', batteryMode.max_consumption_w ?? null); + await this._setCapabilityValue('measure_power.battery_group_max_production_w', batteryMode.max_production_w ?? null); + + // ✅ Flow triggers MUST be inside this block + // await this._triggerFlowOnChange('battery_mode', normalized); + await this._triggerFlowOnChange('battery_mode_changed_SDM630_v2', normalized); + // await this._triggerFlowOnChange('measure_power.battery_group_power_w', batteryMode.power_w ?? null); + } + + // If everything succeeded + await this.setAvailable(); + + } catch (err) { + this.error('Polling failed:', err); + await this.setUnavailable(err).catch(this.error); + } +} + + onSettings(MySettings) { + this.log('Settings updated'); + this.log('Settings:', MySettings); + // Update interval polling + if ('polling_interval' in MySettings.oldSettings + && MySettings.oldSettings.polling_interval !== MySettings.newSettings.polling_interval + ) { + this.log('Polling_interval for P1 changed to:', MySettings.newSettings.polling_interval); + clearInterval(this.onPollInterval); + // this.onPollInterval = setInterval(this.onPoll.bind(this), MySettings.newSettings.polling_interval * 1000); + this.onPollInterval = setInterval(this.onPoll.bind(this), 1000 * this.getSettings().polling_interval); + } + if ('mode' in MySettings.oldSettings + && MySettings.oldSettings.mode !== MySettings.newSettings.mode + ) { + this.log('Mode for Plugin Battery via SDM230 advanced settings changed to:', MySettings.newSettings.mode); + api.setMode(this.url, this.token, MySettings.newSettings.mode); + } + // return true; + } + +}; diff --git a/drivers/SDM630_v2/driver.compose.json b/drivers/SDM630_v2/driver.compose.json new file mode 100644 index 00000000..e8ae0c20 --- /dev/null +++ b/drivers/SDM630_v2/driver.compose.json @@ -0,0 +1,159 @@ +{ + "name": { + "en": "kWh Meter 3P (APIv2)" + }, + "images": { + "large": "drivers/SDM630_v2/assets/images/large.png", + "small": "drivers/SDM630_v2/assets/images/small.png" + }, + "class": "socket", + "discovery": "SDM630_v2", + "platforms": [ + "local" + ], + "capabilities": [ + "measure_power", + "meter_power", + "meter_power.import", + "meter_power.export", + "measure_power.l1", + "measure_power.l2", + "measure_power.l3", + "measure_current", + "measure_current.l1", + "measure_current.l2", + "measure_current.l3", + "measure_voltage.l1", + "measure_voltage.l2", + "measure_voltage.l3" + ], + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "meter_power.import": { + "decimals": 3, + "title": { + "en": "Total usage", + "nl": "Totaal gebruik" + } + }, + "meter_power.export": { + "decimals": 3, + "title": { + "en": "Total deliver", + "nl": "Totaal teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + }, + "insights": true + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "measure_power.l2": { + "title": { + "en": "Current usage phase 2", + "nl": "Huidig gebruik fase 2" + } + }, + "measure_power.l3": { + "title": { + "en": "Current usage phase 3", + "nl": "Huidig gebruik fase 3" + } + }, + "measure_current": { + "title": { + "en": "Current Amp", + "nl": "Huidig Amp" + } + }, + "measure_current.l1": { + "title": { + "en": "Current Amp phase 1", + "nl": "Huidig Amp fase 1" + } + }, + "measure_current.l2": { + "title": { + "en": "Current Amp phase 2", + "nl": "Huidig Amp fase 2" + } + }, + "measure_current.l3": { + "title": { + "en": "Current Amp phase 3", + "nl": "Huidig Amp fase 3" + } + }, + "measure_power.battery_group_power_w": { + "title": { + "en": "Battery group Current combined Power", + "nl": "Battery groep Huidig samengesteld vermogen" + } + }, + "measure_power.battery_group_target_power_w": { + "title": { + "en": "Battery group Target Power", + "nl": "Battery groep Doel vermogen" + } + }, + "measure_power.battery_group_max_consumption_w": { + "title": { + "en": "Battery group Max allowed Consumption Power", + "nl": "Battery groep Max toegestaand gebruiksvermogen" + } + }, + "measure_power.battery_group_max_production_w": { + "title": { + "en": "Battery group Max allowed Production Power", + "nl": "Battery groep Max toegestaand leveringssvermogen" + } + } + }, + "pair": [ + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "authorize" + }, + "options": { "singular": true } + }, + { + "id": "authorize", + "navigation": + { + "prev": "list_devices" + } + } + ], + "id": "SDM630_v2", + "settings": [ + { + "id": "polling_interval", + "type": "number", + "label": { + "en": "Polling interval" + }, + "value": 10, + "min": 1, + "unit": { + "en": "s" + } + } + ] +} \ No newline at end of file diff --git a/drivers/SDM630_v2/driver.flow.compose.json b/drivers/SDM630_v2/driver.flow.compose.json new file mode 100644 index 00000000..cd14352e --- /dev/null +++ b/drivers/SDM630_v2/driver.flow.compose.json @@ -0,0 +1,72 @@ +{"triggers": [ + { + "id": "battery_mode_changed_SDM630_v2", + "title": { "en": "Battery mode changed" }, + "titleFormatted": { "en": "Battery mode changed" }, + "args": [], + "tokens": [] + } + ], + "actions": [ + { + "id": "sdm630-set-battery-to-full-charge-mode", + "title": { + "en": "Set battery to Full Charge mode", + "nl": "Zet batterij in Volledig Oplaad modus" + }, + "titleFormatted": { + "en": "Set battery to Full Charge mode", + "nl": "Zet batterij in Volledig Oplaad modus" + }, + "args": [] + }, + { + "id": "sdm630-set-battery-to-zero-mode", + "title": { + "en": "Set battery to Zero mode", + "nl": "Zet batterij in Zero modus" + }, + "titleFormatted": { + "en": "Set battery to Zero mode", + "nl": "Zet batterij in Zero modus" + }, + "args": [] + }, + { + "id": "sdm630-set-battery-to-standby-mode", + "title": { + "en": "Set battery to Standby mode", + "nl": "Zet batterij in Standby modus" + }, + "titleFormatted": { + "en": "Set battery to Standby mode", + "nl": "Zet batterij in Standby modus" + }, + "args": [] + }, + { + "id": "sdm630-set-battery-to-zero-charge-only-mode", + "title": { + "en": "Set battery to Zero Charge Only mode", + "nl": "Zet batterij in Alleen Opladen modus" + }, + "titleFormatted": { + "en": "Set battery to Zero Charge Only mode", + "nl": "Zet batterij in Alleen Opladen modus" + }, + "args": [] + }, + { + "id": "sdm630-set-battery-to-zero-discharge-only-mode", + "title": { + "en": "Set battery to Zero Discharge Only mode", + "nl": "Zet batterij in Alleen Ontladen modus" + }, + "titleFormatted": { + "en": "Set battery to Zero Discharge Only mode", + "nl": "Zet batterij in Alleen Ontladen modus" + }, + "args": [] + } + ] +} diff --git a/drivers/SDM630_v2/driver.js b/drivers/SDM630_v2/driver.js new file mode 100644 index 00000000..fa129193 --- /dev/null +++ b/drivers/SDM630_v2/driver.js @@ -0,0 +1,6 @@ +'use strict'; + +const Homey = require('homey'); +const driver = require('../../includes/v2/Driver'); + +module.exports = driver; diff --git a/drivers/SDM630_v2/pair/authorize.html b/drivers/SDM630_v2/pair/authorize.html new file mode 100644 index 00000000..acf2a741 --- /dev/null +++ b/drivers/SDM630_v2/pair/authorize.html @@ -0,0 +1,48 @@ +
+

+

+

+

+ +
+ + diff --git a/drivers/cloud_p1/assets/icon.svg b/drivers/cloud_p1/assets/icon.svg new file mode 100644 index 00000000..e1bc3269 --- /dev/null +++ b/drivers/cloud_p1/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drivers/cloud_p1/assets/images/large.png b/drivers/cloud_p1/assets/images/large.png new file mode 100644 index 00000000..6358f0b7 Binary files /dev/null and b/drivers/cloud_p1/assets/images/large.png differ diff --git a/drivers/cloud_p1/assets/images/small.png b/drivers/cloud_p1/assets/images/small.png new file mode 100644 index 00000000..721d7b06 Binary files /dev/null and b/drivers/cloud_p1/assets/images/small.png differ diff --git a/drivers/cloud_p1/device.js b/drivers/cloud_p1/device.js new file mode 100644 index 00000000..66e78965 --- /dev/null +++ b/drivers/cloud_p1/device.js @@ -0,0 +1,473 @@ +/* + * HomeWizard Cloud P1 Device Driver + * + * Based on HomeWizard Cloud API research and documentation by Sven Serlier + * Original repository: https://github.com/smarthomesven/homey-homewizard-energy-cloud + * + * Copyright (c) 2026 Jeroen Tebbens and contributors to com.homewizard + * Cloud API research (c) 2025 Sven Serlier + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +'use strict'; + +const { Device } = require('homey'); +const HomeWizardCloudAPI = require('../../lib/homewizard-cloud-api'); + +const debug = false; + +class CloudP1Device extends Device { + + /** + * onInit is called when the device is initialized. + */ + async onInit() { + this.log('CloudP1Device has been initialized'); + + // Get device data + this.deviceId = this.getData().id; + this.settings = this.getSettings(); + + // Initialize cloud API client + this.cloudAPI = null; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 10; + + // Track last update to detect stale data + this.lastUpdate = null; + this.staleDataTimeout = null; + + // Update rate monitoring to prevent spam + this.updateCount = 0; + this.updateRateWindow = 10000; // 10 seconds + this.updateRateThreshold = 8; // More than 8 updates in 10s = too fast (< 1.25s average) + this.updateRateTimer = null; + this.spamDetected = false; + this.spamLogged = false; + + // Initialize capabilities if needed + await this.initializeCapabilities(); + + // Connect to cloud + await this.connectToCloud(); + + // Register capability listeners + this.registerCapabilityListeners(); + + this.log(`Cloud P1 device initialized: ${this.getName()} (${this.deviceId})`); + } + + /** + * Initialize device capabilities + */ + async initializeCapabilities() { + // Ensure all required capabilities exist + const requiredCapabilities = [ + 'measure_power', + 'meter_power', + 'meter_power.returned', + 'meter_power.peak', + 'meter_power.offpeak', + 'meter_power.producedPeak', + 'meter_power.producedOffpeak', + 'measure_voltage.l1', + 'measure_current.l1', + 'meter_gas' + ]; + + for (const capability of requiredCapabilities) { + if (!this.hasCapability(capability)) { + try { + await this.addCapability(capability); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + this.log(`Capability already exists: ${capability} — ignoring`); + } else { + this.error(`Failed to add capability ${capability}:`, err); + } + } + } + } + + // Check if device has 3 phases based on settings + const threePhases = this.getSetting('number_of_phases') === 3; + + if (threePhases) { + const phaseCapabilities = [ + 'measure_power.l2', + 'measure_power.l3', + 'measure_voltage.l2', + 'measure_voltage.l3', + 'measure_current.l2', + 'measure_current.l3' + ]; + + for (const capability of phaseCapabilities) { + if (!this.hasCapability(capability)) { + try { + await this.addCapability(capability); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + this.log(`Capability already exists: ${capability} — ignoring`); + } else { + this.error(`Failed to add capability ${capability}:`, err); + } + } + } + } + } + } + + /** + * Connect to HomeWizard cloud + */ + async connectToCloud() { + try { + const email = this.getSetting('cloud_email'); + const password = this.getSetting('cloud_password'); + + if (!email || !password) { + throw new Error('Cloud credentials not configured'); + } + + // Create cloud API instance + this.cloudAPI = new HomeWizardCloudAPI({ + email: email, + password: password + }); + + // Set up event listeners + this.setupCloudEventListeners(); + + // Authenticate + await this.cloudAPI.authenticate(); + this.log('Successfully authenticated with HomeWizard cloud'); + + // Connect to main WebSocket + await this.cloudAPI.connectMainWebSocket(); + + // Subscribe to this device + this.cloudAPI.subscribeToDevice(this.deviceId); + + // Note: Realtime WebSocket (1-second updates) is not used + // to avoid potential cloud-side issues and excessive update rates + + // Mark device as available + await this.setAvailable(); + this.reconnectAttempts = 0; + + // Set up stale data detection + this.setupStaleDataDetection(); + + } catch (error) { + this.error('Failed to connect to cloud:', error); + await this.setUnavailable(`Cloud connection failed: ${error.message}`); + + // Schedule reconnect + this.scheduleReconnect(); + } + } + + /** + * Set up cloud API event listeners + */ + setupCloudEventListeners() { + // Handle full device updates + this.cloudAPI.on('device_update', (deviceData) => { + if (deviceData.device === this.deviceId) { + this.log('[MAIN WS] Full device update received'); + this.handleDeviceUpdate(deviceData); + } + }); + + // Handle incremental updates (JSON patches) + this.cloudAPI.on('device_patch', (patchData) => { + if (patchData.deviceId === this.deviceId) { + if (debug) this.log('[MAIN WS] JSON patch received'); + this.handleDeviceUpdate(patchData.state); + } + }); + + // Handle connection issues + this.cloudAPI.on('mainws_closed', () => { + this.log('Main WebSocket closed'); + this.setWarning('Cloud connection lost, reconnecting...').catch(this.error); + }); + + this.cloudAPI.on('mainws_connected', () => { + this.log('Main WebSocket reconnected'); + this.unsetWarning().catch(this.error); + }); + + this.cloudAPI.on('mainws_error', (error) => { + this.error('Main WebSocket error:', error); + }); + } + + /** + * Handle device update from cloud + */ + handleDeviceUpdate(deviceData) { + try { + this.lastUpdate = Date.now(); + + // Monitor update rate and potentially unsubscribe if spam detected + //this.monitorUpdateRate(); + + const state = deviceData.state; + + if (!state) { + this.error('Device update missing state data'); + return; + } + + // Clear any warnings - we're receiving data successfully + this.unsetWarning().catch(this.error); + + // Update online status + if (deviceData.online !== undefined) { + if (deviceData.online) { + this.setAvailable(); + } else { + this.setUnavailable('Device is offline'); + } + } + + // Update power measurements + if (state.active_power_w !== null && state.active_power_w !== undefined) { + this.setCapabilityValue('measure_power', state.active_power_w).catch(this.error); + } + + // Update energy meters (import) + const tariff1 = state.total_power_import_t1_kwh || 0; + const tariff2 = state.total_power_import_t2_kwh || 0; + + this.setCapabilityValue('meter_power.peak', tariff1).catch(this.error); + this.setCapabilityValue('meter_power.offpeak', tariff2).catch(this.error); + this.setCapabilityValue('meter_power', tariff1 + tariff2).catch(this.error); + + // Update energy meters (export) + const exportTariff1 = state.total_power_export_t1_kwh || 0; + const exportTariff2 = state.total_power_export_t2_kwh || 0; + + this.setCapabilityValue('meter_power.producedPeak', exportTariff1).catch(this.error); + this.setCapabilityValue('meter_power.producedOffpeak', exportTariff2).catch(this.error); + this.setCapabilityValue('meter_power.returned', exportTariff1 + exportTariff2).catch(this.error); + + // Update voltage and current (L1) + if (state.active_voltage_l1_v !== null && state.active_voltage_l1_v !== undefined) { + this.setCapabilityValue('measure_voltage.l1', state.active_voltage_l1_v).catch(this.error); + } + + if (state.active_current_l1_a !== null && state.active_current_l1_a !== undefined) { + this.setCapabilityValue('measure_current.l1', state.active_current_l1_a).catch(this.error); + } + + // Update phase-specific measurements if 3-phase + if (this.getSetting('number_of_phases') === 3) { + // Phase 2 + if (state.active_power_l2_w !== null) { + this.setCapabilityValue('measure_power.l2', state.active_power_l2_w).catch(this.error); + } + if (state.active_voltage_l2_v !== null) { + this.setCapabilityValue('measure_voltage.l2', state.active_voltage_l2_v).catch(this.error); + } + if (state.active_current_l2_a !== null) { + this.setCapabilityValue('measure_current.l2', state.active_current_l2_a).catch(this.error); + } + + // Phase 3 + if (state.active_power_l3_w !== null) { + this.setCapabilityValue('measure_power.l3', state.active_power_l3_w).catch(this.error); + } + if (state.active_voltage_l3_v !== null) { + this.setCapabilityValue('measure_voltage.l3', state.active_voltage_l3_v).catch(this.error); + } + if (state.active_current_l3_a !== null) { + this.setCapabilityValue('measure_current.l3', state.active_current_l3_a).catch(this.error); + } + } + + // Update gas meter + if (state.total_gas_m3 !== null && state.total_gas_m3 !== undefined) { + this.setCapabilityValue('meter_gas', state.total_gas_m3).catch(this.error); + } + + // Store WiFi strength for diagnostics + if (deviceData.wifi_strength !== undefined) { + this.setSettings({ wifi_strength: deviceData.wifi_strength }).catch(this.error); + } + + if (debug) this.log('Device updated successfully'); + + } catch (error) { + this.error('Failed to handle device update:', error); + } + } + + /** + * Monitor update rate and stop spam + */ + monitorUpdateRate() { + // Increment update counter + this.updateCount++; + + // Start timer on first update + if (!this.updateRateTimer) { + this.updateRateTimer = setTimeout(() => { + // Check if we exceeded threshold + if (this.updateCount > this.updateRateThreshold && !this.spamDetected) { + this.spamDetected = true; + const updatesPerSecond = (this.updateCount / (this.updateRateWindow / 1000)).toFixed(2); + this.log(`⚠️ Excessive update rate detected: ${this.updateCount} updates in ${this.updateRateWindow/1000}s (${updatesPerSecond}/s)`); + this.log('Unsubscribing from device to stop spam. Will retry in 60 seconds...'); + + // Unsubscribe from this device to stop the updates + if (this.cloudAPI) { + this.cloudAPI.unsubscribeFromDevice(this.deviceId); + } + + // Set warning + this.setWarning('Excessive updates detected. Paused for 60 seconds.').catch(this.error); + + // Resubscribe after 60 seconds + setTimeout(() => { + this.log('Resubscribing to device after spam cooldown...'); + if (this.cloudAPI) { + // Re-add to subscribed devices set + this.cloudAPI.subscribedDevices.add(this.deviceId); + // Send subscribe message + this.cloudAPI._sendSubscribeMessage(this.deviceId); + } + this.spamDetected = false; + this.spamLogged = false; + this.unsetWarning().catch(this.error); + }, 60000); // 60 seconds + } + + // Reset counter + this.updateCount = 0; + this.updateRateTimer = null; + }, this.updateRateWindow); + } + } + + /** + * Set up stale data detection + */ + setupStaleDataDetection() { + // Clear existing timeout + if (this.staleDataTimeout) { + clearTimeout(this.staleDataTimeout); + } + + // Check for stale data every 3 minutes + this.staleDataTimeout = setInterval(() => { + const timeSinceUpdate = Date.now() - (this.lastUpdate || 0); + const maxStaleTime = 180000; // 3 minutes + + if (timeSinceUpdate > maxStaleTime) { + this.log('Data appears stale, marking device as unavailable'); + this.setUnavailable('No recent data from cloud'); + + // Forceer een harde WebSocket reset + if (this.cloudAPI && this.cloudAPI.mainWs) { + this.log('Data stale → forcing WebSocket reconnect'); + try { + this.cloudAPI.mainWs.terminate(); // hard close + } catch (err) { + this.error('Failed to terminate WS:', err); + } + } + } + + }, 60000); // Check every minute + } + + /** + * Schedule reconnection attempt + */ + scheduleReconnect() { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this.error('Max reconnect attempts reached'); + this.setUnavailable('Unable to connect to cloud after multiple attempts'); + return; + } + + const delay = Math.min(5000 * Math.pow(2, this.reconnectAttempts), 60000); + this.reconnectAttempts++; + + this.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`); + + setTimeout(async () => { + await this.connectToCloud(); + }, delay); + } + + /** + * Register capability listeners + */ + registerCapabilityListeners() { + // Currently, P1 meters don't have controllable capabilities via cloud API + // This is a placeholder for future functionality + } + + /** + * onAdded is called when the user adds the device, called just after pairing. + */ + async onAdded() { + this.log('CloudP1Device has been added'); + } + + /** + * onSettings is called when the user updates the device's settings. + */ + async onSettings({ oldSettings, newSettings, changedKeys }) { + this.log('CloudP1Device settings were changed'); + + // If credentials changed, reconnect + if (changedKeys.includes('cloud_email') || changedKeys.includes('cloud_password')) { + this.log('Cloud credentials changed, reconnecting...'); + if (this.cloudAPI) { + this.cloudAPI.disconnect(); + } + await this.connectToCloud(); + } + } + + /** + * onRenamed is called when the user updates the device's name. + */ + async onRenamed(name) { + this.log('CloudP1Device was renamed to:', name); + } + + /** + * onDeleted is called when the user deleted the device. + */ + async onDeleted() { + this.log('CloudP1Device has been deleted'); + + // Clean up timers + if (this.staleDataTimeout) { + clearInterval(this.staleDataTimeout); + } + + if (this.updateRateTimer) { + clearTimeout(this.updateRateTimer); + } + + if (this.cloudAPI) { + // Unsubscribe from this device before disconnecting + this.cloudAPI.unsubscribeFromDevice(this.deviceId); + this.cloudAPI.disconnect(); + } + } + +} + +module.exports = CloudP1Device; \ No newline at end of file diff --git a/drivers/cloud_p1/driver.compose.json b/drivers/cloud_p1/driver.compose.json new file mode 100644 index 00000000..45c2da97 --- /dev/null +++ b/drivers/cloud_p1/driver.compose.json @@ -0,0 +1,129 @@ +{ + "name": { + "en": "P1 Meter (Cloud)", + "nl": "P1 Meter (Cloud)" + }, + "class": "sensor", + + "capabilities": [], + + "capabilitiesOptions": { + "meter_power.peak": { + "title": { + "en": "Power meter tariff 1", + "nl": "Energiemeter tarief 1" + } + }, + "meter_power.offpeak": { + "title": { + "en": "Power meter tariff 2", + "nl": "Energiemeter tarief 2" + } + }, + "meter_power.producedPeak": { + "title": { + "en": "Production tariff 1", + "nl": "Productie tarief 1" + } + }, + "meter_power.producedOffpeak": { + "title": { + "en": "Production tariff 2", + "nl": "Productie tarief 2" + } + }, + "meter_power.returned": { + "title": { + "en": "Returned Power", + "nl": "Teruggeleverde Energie" + } + }, + "measure_voltage.l1": { + "title": { + "en": "Voltage L1", + "nl": "Spanning L1" + } + }, + "measure_current.l1": { + "title": { + "en": "Current L1", + "nl": "Stroom L1" + } + } + }, + + "energy": { + "cumulative": true, + "cumulativeImportedCapability": "meter_power", + "cumulativeExportedCapability": "meter_power.returned" + }, + + "platforms": ["local"], + "connectivity": ["cloud"], + + "images": { + "small": "{{driverAssetsPath}}/images/small.png", + "large": "{{driverAssetsPath}}/images/large.png", + "xlarge": "{{driverAssetsPath}}/images/xlarge.png" + }, + + "pair": [ + { "id": "login" } + ], + + "repair": [ + { "id": "login" } + ], + + "settings": [ + { + "type": "group", + "label": { + "en": "Cloud Connection", + "nl": "Cloud Verbinding" + }, + "children": [ + { + "id": "cloud_email", + "type": "text", + "label": { "en": "Email", "nl": "E-mail" }, + "value": "", + "hint": { + "en": "Your HomeWizard Energy account email", + "nl": "Uw HomeWizard Energy account e-mail" + } + }, + { + "id": "cloud_password", + "type": "password", + "label": { "en": "Password", "nl": "Wachtwoord" }, + "value": "", + "hint": { + "en": "Your HomeWizard Energy account password", + "nl": "Uw HomeWizard Energy account wachtwoord" + } + }, + { + "id": "location_id", + "type": "text", + "label": { "en": "Location ID", "nl": "Locatie ID" }, + "value": "", + "hint": { + "en": "Internal location identifier", + "nl": "Interne locatie-identificatie" + } + }, + { + "id": "location_name", + "type": "text", + "label": { "en": "Location Name", "nl": "Locatie Naam" }, + "value": "", + "hint": { + "en": "Name of your home in HomeWizard Energy app", + "nl": "Naam van uw woning in HomeWizard Energy app" + } + } + ] + } + ] +} diff --git a/drivers/cloud_p1/driver.js b/drivers/cloud_p1/driver.js new file mode 100644 index 00000000..713078ce --- /dev/null +++ b/drivers/cloud_p1/driver.js @@ -0,0 +1,264 @@ +/* + * HomeWizard Cloud P1 Driver + * + * Based on HomeWizard Cloud API research and documentation by Sven Serlier + * Original repository: https://github.com/smarthomesven/homey-homewizard-energy-cloud + * + * Copyright (c) 2026 Jeroen Tebbens and contributors to com.homewizard + * Cloud API research (c) 2025 Sven Serlier + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +'use strict'; + +const { Driver } = require('homey'); +const HomeWizardCloudAPI = require('../../lib/homewizard-cloud-api'); + +class CloudP1Driver extends Driver { + + /** + * onInit is called when the driver is initialized. + */ + async onInit() { + this.log('CloudP1Driver has been initialized'); + } + + /** + * onPairListDevices is called when the user starts pairing + */ + async onPairListDevices() { + this.log('onPairListDevices called'); + + // Return empty array - devices will be discovered through pair flow + return []; + } + + /** + * onInit is called when the driver is initialized. + */ + async onInit() { + this.log('CloudP1Driver has been initialized'); + + // Store active pairing sessions at driver level + this.pairingSessions = new Map(); + } + + /** + * onPair is called when a user wants to pair a device + */ + async onPair(session) { + this.log('Pairing session started'); + + // Create a unique session ID + const sessionId = Date.now().toString(); + + // Store session data at driver level so it persists across views + const sessionData = { + cloudAPI: null, + locations: [], + credentials: { + email: null, + password: null + } + }; + + this.pairingSessions.set(sessionId, sessionData); + this.log(`Created pairing session: ${sessionId}`); + + // TEST HANDLER - to verify emit is working + session.setHandler('test', async () => { + this.log('TEST HANDLER CALLED - emit is working!'); + return { success: true, message: 'Test successful' }; + }); + + // Step 1: Get cloud credentials + session.setHandler('cloud_login', async (data) => { + try { + const { email, password } = data; + + if (!email || !password) { + throw new Error('Email and password are required'); + } + + const sd = this.pairingSessions.get(sessionId); + + // Store credentials for later use + sd.credentials.email = email; + sd.credentials.password = password; + + // Create cloud API instance + sd.cloudAPI = new HomeWizardCloudAPI({ + email: email, + password: password + }); + + // Authenticate + await sd.cloudAPI.authenticate(); + this.log('Successfully authenticated'); + + return { success: true }; + + } catch (error) { + this.error('Cloud login failed:', error); + throw new Error(`Authentication failed: ${error.message}`); + } + }); + + // Step 2: Get locations (homes) + session.setHandler('list_locations', async () => { + try { + this.log('list_locations handler called'); + + const sd = this.pairingSessions.get(sessionId); + + if (!sd || !sd.cloudAPI) { + this.error('CloudAPI not initialized'); + throw new Error('Not authenticated. Please login first.'); + } + + this.log('Fetching locations from cloud...'); + sd.locations = await sd.cloudAPI.getLocations(); + this.log(`Found ${sd.locations.length} location(s)`); + + // Format locations for display + const formattedLocations = sd.locations.map(location => ({ + id: location.id.toString(), + name: location.name, + location: location.location, + deviceCount: location.devices ? location.devices.length : 0 + })); + + this.log('Formatted locations:', JSON.stringify(formattedLocations, null, 2)); + return formattedLocations; + + } catch (error) { + this.error('Failed to get locations:', error); + this.error('Error stack:', error.stack); + throw new Error(`Failed to retrieve locations: ${error.message}`); + } + }); + + // Step 3: Get devices for selected location + session.setHandler('list_devices_for_location', async (data) => { + try { + const { locationId } = data; + + const sd = this.pairingSessions.get(sessionId); + + if (!sd || !sd.cloudAPI) { + throw new Error('Not authenticated. Please login first.'); + } + + const location = sd.locations.find(loc => loc.id.toString() === locationId); + + if (!location) { + throw new Error('Location not found'); + } + + // Filter for P1 dongles only + const p1Devices = (location.devices || []).filter(device => + device.type === 'p1dongle' + ); + + this.log(`Found ${p1Devices.length} P1 device(s) in location ${location.name}`); + + // Format devices for display + return p1Devices.map(device => ({ + name: device.name || 'P1 Meter', + data: { + id: device.device_id + }, + settings: { + cloud_email: sd.credentials.email, + cloud_password: sd.credentials.password, + location_id: locationId, + location_name: location.name, + number_of_phases: 1 + }, + store: { + device_type: device.type, + created: device.created, + modified: device.modified + } + })); + + } catch (error) { + this.error('Failed to get devices:', error); + throw new Error(`Failed to retrieve devices: ${error.message}`); + } + }); + + // Clean up on pair session disconnect + session.setHandler('disconnect', async () => { + this.log('Pairing session ended'); + + const sd = this.pairingSessions.get(sessionId); + if (sd && sd.cloudAPI) { + sd.cloudAPI.disconnect(); + } + + // Clean up session data + this.pairingSessions.delete(sessionId); + this.log(`Cleaned up pairing session: ${sessionId}`); + }); + } + + /** + * onRepair is called when a user wants to repair a device + */ + async onRepair(session, device) { + this.log('Repair session started for device:', device.getName()); + + let cloudAPI = null; + + // Step 1: Get new cloud credentials + session.setHandler('cloud_login', async (data) => { + try { + const { email, password } = data; + + if (!email || !password) { + throw new Error('Email and password are required'); + } + + // Create cloud API instance + cloudAPI = new HomeWizardCloudAPI({ + email: email, + password: password + }); + + // Authenticate + await cloudAPI.authenticate(); + this.log('Successfully authenticated during repair'); + + // Update device settings + await device.setSettings({ + cloud_email: email, + cloud_password: password + }); + + return { success: true }; + + } catch (error) { + this.error('Cloud login failed during repair:', error); + throw new Error(`Authentication failed: ${error.message}`); + } + }); + + // Clean up on repair session disconnect + session.setHandler('disconnect', async () => { + this.log('Repair session ended'); + + if (cloudAPI) { + cloudAPI.disconnect(); + cloudAPI = null; + } + }); + } + +} + +module.exports = CloudP1Driver; \ No newline at end of file diff --git a/drivers/cloud_p1/pair/list_locations.html b/drivers/cloud_p1/pair/list_locations.html new file mode 100644 index 00000000..e52c2362 --- /dev/null +++ b/drivers/cloud_p1/pair/list_locations.html @@ -0,0 +1,315 @@ + + + + + + + + +
+

Select Your Home

+

Choose which home contains the devices you want to add.

+
+ +
+
+
Loading your homes...
+ + + + +
+ +
+ + + + \ No newline at end of file diff --git a/drivers/cloud_p1/pair/login.html b/drivers/cloud_p1/pair/login.html new file mode 100644 index 00000000..0a9ddd0b --- /dev/null +++ b/drivers/cloud_p1/pair/login.html @@ -0,0 +1,257 @@ + + + + + + + +
+

HomeWizard Cloud Login

+ +
+

Enter your HomeWizard Energy account credentials.

+
+ +
+ +
+
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+ Select your home +
+ +

You have no homes! Please create a home in the HomeWizard Energy app first.

+

Failed to load homes. Please try again.

+ + +
+ + + + \ No newline at end of file diff --git a/drivers/cloud_watermeter/assets/icon.svg b/drivers/cloud_watermeter/assets/icon.svg new file mode 100644 index 00000000..e0f8ed06 --- /dev/null +++ b/drivers/cloud_watermeter/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drivers/cloud_watermeter/assets/images/large.png b/drivers/cloud_watermeter/assets/images/large.png new file mode 100644 index 00000000..66a529d6 Binary files /dev/null and b/drivers/cloud_watermeter/assets/images/large.png differ diff --git a/drivers/cloud_watermeter/assets/images/small.png b/drivers/cloud_watermeter/assets/images/small.png new file mode 100644 index 00000000..2646976e Binary files /dev/null and b/drivers/cloud_watermeter/assets/images/small.png differ diff --git a/drivers/cloud_watermeter/device.js b/drivers/cloud_watermeter/device.js new file mode 100644 index 00000000..3aec7a66 --- /dev/null +++ b/drivers/cloud_watermeter/device.js @@ -0,0 +1,361 @@ +'use strict'; + +const Homey = require('homey'); +const fetch = require('node-fetch'); + +const API_BASE_URL = 'https://api.homewizardeasyonline.com/v1'; +const TSDB_URL = 'https://tsdb-reader.homewizard.com'; +const TOKEN_REFRESH_MARGIN = 60; // seconds +const MAX_RETRY_ATTEMPTS = 5; +const INITIAL_RETRY_DELAY = 30000; // 30 seconds +const debug = false + +module.exports = class HomeWizardCloudWatermeterDevice extends Homey.Device { + + async onInit() { + this.log('Cloud Watermeter initialized:', this.getName()); + + // Get stored credentials + this.username = this.getStoreValue('username'); + this.password = this.getStoreValue('password'); + this.token = this.getStoreValue('token'); + this.tokenExpiresAt = this.getStoreValue('token_expires_at'); + this.deviceIdentifier = this.getStoreValue('identifier'); + this.homeId = this.getStoreValue('homeId'); + + // Set polling interval (every 15 minutes) + this.pollInterval = this.getSetting('poll_interval') || 900; // 15 minutes default + + // Initialize retry tracking + this.retryAttempt = 0; + + // Initialize cumulative meter if not exists + if (!this.getStoreValue('cumulative_water')) { + await this.setStoreValue('cumulative_water', 0); + } + + // Track last processed date to detect day changes + if (!this.getStoreValue('last_date')) { + await this.setStoreValue('last_date', new Date().toDateString()); + } + + // Add meter_water.daily capability if it doesn't exist + // Safe add: guard against race / 409 errors + if (!this.hasCapability('meter_water.daily')) { + try { + await this.addCapability('meter_water.daily'); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + this.log('Capability already exists: meter_water.daily — ignoring'); + } else { + throw err; + } + } + } + + // Initial data fetch + await this.fetchWaterData(); + + // Start polling + this.startPolling(); + + this.log(`Device initialized: ${this.deviceIdentifier}`); + } + + /** + * Start polling for water data + */ + startPolling() { + if (this.pollingTimer) { + this.homey.clearInterval(this.pollingTimer); + } + + this.pollingTimer = this.homey.setInterval( + async () => { + await this.fetchWaterData(); + }, + this.pollInterval * 1000 + ); + } + + /** + * Calculate exponential backoff delay + * @param {number} attempt - Current retry attempt (0-based) + * @returns {number} Delay in milliseconds + */ + calculateBackoffDelay(attempt) { + // Exponential backoff 30s, 60s, 120s, 240s, etc. + const delay = INITIAL_RETRY_DELAY * Math.pow(2, attempt); + // Add jitter (random 0-20% variation) to avoid thundering herd + const jitter = delay * 0.2 * Math.random(); + return Math.floor(delay + jitter); + } + + /** + * Sleep for specified milliseconds + * @param {number} ms - Milliseconds to sleep + */ + sleep(ms) { + return new Promise(resolve => this.homey.setTimeout(resolve, ms)); + } + + /** + * Ensure we have a valid token, refresh if needed + * @returns {Promise} Valid access token + */ + async ensureToken() { + const now = Date.now(); + + if (!this.token || now >= this.tokenExpiresAt) { + this.log('Token expired or missing, refreshing...'); + await this.authenticate(); + } + + return this.token; + } + + /** + * Authenticate and get new token + */ + async authenticate() { + const url = `${API_BASE_URL}/auth/account/token`; + const credentials = Buffer.from(`${this.username}:${this.password}`).toString('base64'); + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Basic ${credentials}`, + 'User-Agent': 'HomeWizardHomey/1.0', + }, + timeout: 10000, + }); + + if (!response.ok) { + throw new Error(`Authentication failed: ${response.status}`); + } + + const data = await response.json(); + + this.token = data.access_token; + this.tokenExpiresAt = Date.now() + ((data.expires_in || 3600) - TOKEN_REFRESH_MARGIN) * 1000; + + // Store for persistence + await this.setStoreValue('token', this.token); + await this.setStoreValue('token_expires_at', this.tokenExpiresAt); + + this.log('Token refreshed successfully'); + } catch (err) { + this.error('Authentication failed:', err.message); + await this.setUnavailable(this.homey.__('errors.auth_failed')); + throw err; + } + } + + /** + * Fetch water consumption data from TSDB with exponential backoff + */ + async fetchWaterData() { + let attempt = 0; + + while (attempt < MAX_RETRY_ATTEMPTS) { + try { + const token = await this.ensureToken(); + const now = new Date(); + const timezone = this.homey.clock.getTimezone(); + + const url = `${TSDB_URL}/devices/date/${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`; + + const payload = { + devices: [ + { + identifier: this.deviceIdentifier, + measurementType: 'water', + }, + ], + type: 'water', + values: true, + wattage: false, + gb: '15m', + tz: timezone, + fill: 'linear', + three_phases: false, + }; + + if (attempt > 0) { + this.log(`Fetching water data from TSDB (retry ${attempt}/${MAX_RETRY_ATTEMPTS})...`); + } else { + if (debug) this.log(`Fetching water data from TSDB...`); + } + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'HomeWizardHomey/1.0', + }, + body: JSON.stringify(payload), + timeout: 10000, + }); + + if (!response.ok) { + const errorText = await response.text(); + + // Check if it's a retryable error (5xx or rate limiting) + if (response.status >= 500 || response.status === 429) { + throw new Error(`TSDB request failed (retryable): ${response.status} - ${errorText}`); + } + + // Non-retryable error (4xx except 429) + this.error(`TSDB request failed (non-retryable): ${response.status} - ${errorText}`); + await this.setUnavailable(`API error: ${response.status}`); + return; + } + + const data = await response.json(); + if (debug) this.log(`TSDB data received: ${data.values?.length || 0} datapoints`); + + // Process the data + await this.processWaterData(data); + + // Mark device as available + if (!this.getAvailable()) { + await this.setAvailable(); + } + + // Reset retry counter on success + this.retryAttempt = 0; + return; + + } catch (err) { + this.error(`Error fetching water data (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS}):`, err.message); + + attempt++; + + if (attempt >= MAX_RETRY_ATTEMPTS) { + this.error('Max retry attempts reached, giving up'); + await this.setUnavailable(err.message); + return; + } + + // Calculate backoff delay + const delay = this.calculateBackoffDelay(attempt - 1); + this.log(`Retrying in ${(delay / 1000).toFixed(1)}s...`); + + // Wait before retry + await this.sleep(delay); + } + } + } + + /** + * Process and update water consumption data + * @param {Object} data - TSDB response data + */ +async processWaterData(data) { + if (!data || !data.values || data.values.length === 0) { + this.log('No water data available'); + return; + } + + const today = new Date().toDateString(); + const lastDate = this.getStoreValue('last_date'); + const previousDailyUsage = this.getStoreValue('previous_daily_usage') || 0; + + // Find the latest non-zero water value + let latestWaterValue = null; + let dailyTotal = 0; + + // Iterate through values to find the most recent reading and calculate daily total + for (let i = data.values.length - 1; i >= 0; i--) { + const datapoint = data.values[i]; + + if (datapoint.water !== null && datapoint.water !== undefined) { + // Sum up all water usage for the day (these are liters per interval) + dailyTotal += datapoint.water; + + // Get the latest reading if we haven't found one yet + if (latestWaterValue === null && datapoint.water > 0) { + latestWaterValue = datapoint.water; + if (debug) this.log(`Latest water reading: ${latestWaterValue}L at ${datapoint.time}`); + } + } + } + + // Convert daily total from liters to m³ + const dailyTotalM3 = dailyTotal / 1000; + + if (debug) this.log(`Daily water usage: ${dailyTotalM3.toFixed(3)} m³ (${dailyTotal.toFixed(1)}L)`); + + // Check if day changed - if so, add previous day's total to cumulative + if (lastDate !== today) { + if (debug) this.log(`Day changed from ${lastDate} to ${today}`); + + // Add previous day's usage to cumulative total + const cumulativeWater = this.getStoreValue('cumulative_water') || 0; + const newCumulative = cumulativeWater + previousDailyUsage; + + await this.setStoreValue('cumulative_water', newCumulative); + await this.setStoreValue('last_date', today); + + if (debug) this.log(`Added ${previousDailyUsage.toFixed(3)} m³ to cumulative. New total: ${newCumulative.toFixed(3)} m³`); + } + + // Store current daily usage for next day rollover + await this.setStoreValue('previous_daily_usage', dailyTotalM3); + + // Update daily water usage capability + if (this.hasCapability('meter_water.daily')) { + await this.setCapabilityValue('meter_water.daily', dailyTotalM3); + if (debug) this.log(`Daily water meter updated: ${dailyTotalM3.toFixed(3)} m³`); + } + + // Update cumulative water usage capability (including manual offset) + if (this.hasCapability('meter_water')) { + const cumulativeWater = this.getStoreValue('cumulative_water') || 0; + const manualOffset = parseFloat(this.getSetting('manual_offset')) || 0; + const totalWater = cumulativeWater + dailyTotalM3 + manualOffset; + + await this.setCapabilityValue('meter_water', totalWater); + if (debug) this.log(`Cumulative water meter updated: ${totalWater.toFixed(3)} m³ (cumulative: ${cumulativeWater.toFixed(3)} m³, daily: ${dailyTotalM3.toFixed(3)} m³, offset: ${manualOffset.toFixed(3)} m³)`); + } +} + + /** + * Handle settings changes + */ + async onSettings({ oldSettings, newSettings, changedKeys }) { + if (changedKeys.includes('poll_interval')) { + this.pollInterval = newSettings.poll_interval; + this.log(`Polling interval changed to ${this.pollInterval}s`); + this.startPolling(); // Restart with new interval + } + + if (changedKeys.includes('manual_offset')) { + this.log(`Manual offset changed to ${newSettings.manual_offset} m³`); + // Trigger an update to reflect the new offset + await this.fetchWaterData(); + } + } + + /** + * Clean up on device deletion + */ + async onDeleted() { + this.log('Cloud Watermeter deleted'); + + if (this.pollingTimer) { + this.homey.clearInterval(this.pollingTimer); + } + } + + /** + * Clean up on device unavailable + */ + async onUninit() { + if (this.pollingTimer) { + this.homey.clearInterval(this.pollingTimer); + } + } +}; \ No newline at end of file diff --git a/drivers/cloud_watermeter/driver.compose.json b/drivers/cloud_watermeter/driver.compose.json new file mode 100644 index 00000000..0423bf9e --- /dev/null +++ b/drivers/cloud_watermeter/driver.compose.json @@ -0,0 +1,102 @@ +{ + "id": "cloud-watermeter", + "name": { + "en": "Watermeter (cloud)", + "nl": "Watermeter (cloud)" + }, + "class": "sensor", + "platforms": [ "local"], + "capabilities": [ + "meter_water", + "meter_water.daily" + ], + "capabilitiesOptions": { + "meter_water.daily": { + "decimals": 3, + "title": { + "en": "Water usage today", + "nl": "Waterverbruik vandaag" + } + }, + "meter_water": { + "decimals": 3, + "title": { + "en": "Water usage total", + "nl": "Waterverbruik totaal" + } + } + }, + "energy": { + "cumulative": true + }, + "images": { + "small": "{{driverAssetsPath}}/images/small.png", + "large": "{{driverAssetsPath}}/images/large.png", + "xlarge": "{{driverAssetsPath}}/images/xlarge.png" + }, + "pair": [ + { + "id": "login", + "template": "login_credentials", + "options": { + "title": { + "en": "Login to HomeWizard", + "nl": "Inloggen bij HomeWizard" + }, + "usernameLabel": { + "en": "Email", + "nl": "E-mail" + }, + "usernamePlaceholder": { + "en": "your@email.com", + "nl": "jouw@email.com" + }, + "passwordLabel": { + "en": "Password", + "nl": "Wachtwoord" + }, + "passwordPlaceholder": { + "en": "Password", + "nl": "Wachtwoord" + } + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ], + "settings": [ + { + "type": "group", + "label": { + "en": "General settings", + "nl": "Algemene instellingen" + }, + "children": [ + { + "id": "poll_interval", + "type": "number", + "label": { + "en": "Polling interval (seconds)", + "nl": "Polling interval (seconden)" + }, + "value": 900, + "min": 900, + "max": 3600, + "hint": { + "en": "How often to fetch data from HomeWizard Cloud (default: 900s / 15 min)", + "nl": "Hoe vaak data ophalen van HomeWizard Cloud (standaard: 900s / 15 min)" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/drivers/cloud_watermeter/driver.js b/drivers/cloud_watermeter/driver.js new file mode 100644 index 00000000..84e7f060 --- /dev/null +++ b/drivers/cloud_watermeter/driver.js @@ -0,0 +1,290 @@ +'use strict'; + +const Homey = require('homey'); +const fetch = require('node-fetch'); + +const API_BASE_URL = 'https://api.homewizardeasyonline.com/v1'; +const HOMES_API_URL = 'https://homes.api.homewizard.com'; +const GRAPHQL_URL = 'https://api.homewizard.energy/v1/graphql'; +const TSDB_URL = 'https://tsdb-reader.homewizard.com'; +const TOKEN_REFRESH_MARGIN = 60; // seconds before expiry to refresh + +module.exports = class HomeWizardCloudWatermeterDriver extends Homey.Driver { + + async onInit() { + this.log('HomeWizard Cloud Watermeter driver initialized'); + } + + /** + * Authenticate with HomeWizard Cloud API + * @param {string} username - HomeWizard account email + * @param {string} password - HomeWizard account password + * @returns {Promise} Token data with access_token and expires_in + */ + async authenticate(username, password) { + const url = `${API_BASE_URL}/auth/account/token`; + + const credentials = Buffer.from(`${username}:${password}`).toString('base64'); + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Basic ${credentials}`, + 'User-Agent': 'HomeWizardHomey/1.0', + }, + timeout: 10000, + }); + + if (!response.ok) { + throw new Error(`Authentication failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + return { + access_token: data.access_token, + expires_at: Date.now() + ((data.expires_in || 3600) - TOKEN_REFRESH_MARGIN) * 1000, + }; + } catch (err) { + this.error('Authentication error:', err.message); + throw new Error(this.homey.__('errors.auth_failed')); + } + } + + /** + * Get list of locations (homes) for the account + * @param {string} token - Bearer token + * @returns {Promise} List of locations + */ + async getLocations(token) { + const url = `${HOMES_API_URL}/locations`; + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'HomeWizardHomey/1.0', + }, + timeout: 10000, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch locations: ${response.status}`); + } + + return await response.json(); + } catch (err) { + this.error('Error fetching locations:', err.message); + return []; + } + } + + /** + * Get devices for a specific home using GraphQL + * @param {string} token - Bearer token + * @param {number} homeId - Home ID + * @returns {Promise} GraphQL response with devices + */ + async getDevices(token, homeId) { + const payload = { + operationName: 'DeviceList', + variables: { + homeId: homeId, + }, + query: `query DeviceList($homeId: Int!) { + home(id: $homeId) { + devices { + identifier + name + wifiStrength + ... on CloudDevice { + type + model + hardwareVersion + onlineState + } + } + } + }`, + }; + + return await this.callGraphQL(token, payload); + } + + /** + * Call GraphQL endpoint + * @param {string} token - Bearer token + * @param {Object} payload - GraphQL query payload + * @returns {Promise} GraphQL response + */ + async callGraphQL(token, payload) { + try { + const response = await fetch(GRAPHQL_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'HomeWizardHomey/1.0', + }, + body: JSON.stringify(payload), + timeout: 10000, + }); + + if (!response.ok) { + throw new Error(`GraphQL request failed: ${response.status}`); + } + + return await response.json(); + } catch (err) { + this.error('GraphQL error:', err.message); + return null; + } + } + + /** + * Get time-series database data for water measurements + * @param {string} token - Bearer token + * @param {string} deviceIdentifier - Device identifier + * @param {Date} date - Date to fetch data for + * @param {string} timezone - Timezone string (e.g., 'Europe/Amsterdam') + * @returns {Promise} TSDB data + */ + async getTSDBData(token, deviceIdentifier, date, timezone = 'Europe/Amsterdam') { + const dateStr = date.toISOString().split('T')[0].replace(/-/g, '/'); + const url = `${TSDB_URL}/devices/date/${dateStr}`; + + const payload = { + devices: [ + { + identifier: deviceIdentifier, + measurementType: 'water', + }, + ], + type: 'water', + values: true, + wattage: true, + gb: '15m', + tz: timezone, + fill: 'linear', + three_phases: false, + }; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'HomeWizardHomey/1.0', + }, + body: JSON.stringify(payload), + timeout: 10000, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`TSDB request failed: ${response.status} - ${text}`); + } + + return await response.json(); + } catch (err) { + this.error('TSDB error:', err.message); + return null; + } + } + + /** + * Pairing flow - authenticate and discover devices + */ + async onPair(session) { + let username = ''; + let password = ''; + let tokenData = null; + + session.setHandler('login', async (data) => { + username = data.username; + password = data.password; + + try { + // Authenticate + tokenData = await this.authenticate(username, password); + this.log('Authentication successful'); + return true; + } catch (err) { + this.error('Login failed:', err.message); + throw new Error(this.homey.__('errors.invalid_credentials')); + } + }); + + session.setHandler('list_devices', async () => { + if (!tokenData) { + throw new Error('Not authenticated'); + } + + const devices = await this.discoverDevices(tokenData, username, password); + return devices; + }); + } + + /** + * Discover watermeter devices + */ + async discoverDevices(tokenData, username, password) { + try { + this.log('Fetching locations...'); + const locations = await this.getLocations(tokenData.access_token); + + if (!locations || locations.length === 0) { + throw new Error(this.homey.__('errors.no_locations')); + } + + const devices = []; + + for (const location of locations) { + this.log(`Fetching devices for location: ${location.id} (${location.name || 'unnamed'})`); + const devicesData = await this.getDevices(tokenData.access_token, location.id); + + if (devicesData?.data?.home?.devices) { + this.log(`Found ${devicesData.data.home.devices.length} total devices`); + + const watermeters = devicesData.data.home.devices.filter( + device => device.type === 'watermeter' || device.model?.includes('WTR') + ); + + this.log(`Filtered to ${watermeters.length} watermeter(s)`); + + for (const device of watermeters) { + devices.push({ + name: device.name || `Watermeter (${device.identifier})`, + data: { + id: device.identifier, + }, + store: { + username: username, + password: password, + token: tokenData.access_token, + token_expires_at: tokenData.expires_at, + identifier: device.identifier, + homeId: location.id, + }, + }); + } + } + } + + if (devices.length === 0) { + throw new Error(this.homey.__('errors.no_watermeters')); + } + + this.log(`Returning ${devices.length} watermeter(s) for pairing`); + return devices; + } catch (err) { + this.error('Device discovery failed:', err.message); + this.error('Stack trace:', err.stack); + throw err; + } + } +}; \ No newline at end of file diff --git a/drivers/cloud_watermeter/driver.settings.compose.json b/drivers/cloud_watermeter/driver.settings.compose.json new file mode 100644 index 00000000..0f3c1e98 --- /dev/null +++ b/drivers/cloud_watermeter/driver.settings.compose.json @@ -0,0 +1,39 @@ +[ + { + "type": "group", + "label": { + "en": "General settings", + "nl": "Algemene instellingen" + }, + "children": [ + { + "id": "poll_interval", + "type": "number", + "label": { + "en": "Polling interval (seconds)", + "nl": "Polling interval (seconden)" + }, + "value": 900, + "min": 300, + "max": 3600, + "hint": { + "en": "How often to fetch data from HomeWizard Cloud (default: 900s / 15 min)", + "nl": "Hoe vaak data ophalen van HomeWizard Cloud (standaard: 900s / 15 min)" + } + }, + { + "id": "manual_offset", + "type": "number", + "label": { + "en": "Manual offset (m³)", + "nl": "Handmatige correctie (m³)" + }, + "value": 0, + "hint": { + "en": "Add or subtract from the total meter reading. Use this to match your water company's meter reading.", + "nl": "Tel op of trek af van de totale meterstand. Gebruik dit om overeen te komen met de meterstand van je waterbedrijf." + } + } + ] + } +] \ No newline at end of file diff --git a/drivers/cloud_watermeter/pair/login.html b/drivers/cloud_watermeter/pair/login.html new file mode 100644 index 00000000..37498de3 --- /dev/null +++ b/drivers/cloud_watermeter/pair/login.html @@ -0,0 +1,145 @@ + + + + + + + + +
+ Enter your HomeWizard Energy account credentials to connect your cloud-based watermeter. +
+ +
+
+ + +
+ +
+ + +
+ + + +
+
+ + + + \ No newline at end of file diff --git a/drivers/energy/assets/icon.svg b/drivers/energy/assets/icon.svg new file mode 100644 index 00000000..e1bc3269 --- /dev/null +++ b/drivers/energy/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drivers/energy/assets/images/large.png b/drivers/energy/assets/images/large.png new file mode 100644 index 00000000..6358f0b7 Binary files /dev/null and b/drivers/energy/assets/images/large.png differ diff --git a/drivers/energy/assets/images/small.png b/drivers/energy/assets/images/small.png new file mode 100644 index 00000000..721d7b06 Binary files /dev/null and b/drivers/energy/assets/images/small.png differ diff --git a/drivers/energy/device.js b/drivers/energy/device.js new file mode 100644 index 00000000..9ccfb567 --- /dev/null +++ b/drivers/energy/device.js @@ -0,0 +1,1058 @@ +'use strict'; + +const Homey = require('homey'); +//const fetch = require('../../includes/utils/fetchQueue'); +const fetch = require('node-fetch'); +const BaseloadMonitor = require('../../includes/utils/baseloadMonitor'); +const http = require('http'); + + +// All phase‑dependent capabilities (L2/L3/T3) +const PHASE_CAPS = [ + 'measure_power.l2', 'measure_power.l3', + 'measure_voltage.l2', 'measure_voltage.l3', + 'measure_current.l2', 'measure_current.l3', + 'net_load_phase2_pct', 'net_load_phase3_pct', + 'voltage_sag_l2', 'voltage_sag_l3', + 'voltage_swell_l2', 'voltage_swell_l3', + 'meter_power.consumed.t3', 'meter_power.produced.t3' +]; + +async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { + ...options, + signal: controller.signal + }); + return res; + } catch (err) { + if (err.name === 'AbortError') { + throw new Error('TIMEOUT'); + } + throw err; + } finally { + clearTimeout(timer); + } +} + + + + +async function updateCapability(device, capability, value) { + try { + const current = device.getCapabilityValue(capability); + + // --- SAFE REMOVE --- + // Removal is allowed only when: + // 1) the new value is null + // 2) the current value in Homey is also null + + if (value == null && current == null) { + if (device.hasCapability(capability)) { + await device.removeCapability(capability); + device.log(`🗑️ Removed capability "${capability}"`); + } + return; + } + + // --- ADD IF MISSING --- + if (!device.hasCapability(capability)) { + await device.addCapability(capability); + device.log(`➕ Added capability "${capability}"`); + } + + // --- UPDATE --- + if (current !== value) { + await device.setCapabilityValue(capability, value); + } + + } catch (err) { + if (err.message === 'device_not_found') { + device.log(`⚠️ Skipping capability "${capability}" — device not found`); + return; + } + device.error(`❌ Failed updateCapability("${capability}")`, err); + } +} + + +/** + * Safe add capability helper — avoids race 409 errors + */ +async function safeAddCapability(device, capability) { + try { + if (!device.hasCapability(capability)) { + await device.addCapability(capability); + device.log(`➕ Safely added capability "${capability}"`); + } + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + device.log(`Capability already exists: ${capability} — ignoring`); + return; + } + throw err; + } +} + + + +function getWifiQuality(percent) { + if (percent >= 80) return 'Excellent / Strong'; + if (percent >= 60) return 'Moderate'; + if (percent >= 40) return 'Weak'; + if (percent >= 20) return 'Poor'; + if (percent > 0) return 'Unusable'; + return 'Unusable'; +} + +module.exports = class HomeWizardEnergyDevice extends Homey.Device { + + async onInit() { + this._lastSamples = {}; // mini-cache + this._deleted = false; + + this.agent = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 10000 + }); + + await updateCapability(this, 'connection_error', 'No errors'); + await updateCapability(this, 'alarm_connectivity', false); + + // Remove legacy capabilities once + for (const cap of ['net_load_phase1', 'net_load_phase2', 'net_load_phase3']) { + if (this.hasCapability(cap)) { + await this.removeCapability(cap).catch(this.error); + } + } + + const settings = this.getSettings(); + + this._overloadThreshold = settings.phase_overload_threshold ?? 97; + this._overloadReset = settings.phase_overload_reset ?? 85; + + if (!settings.polling_interval) { + await this.setSettings({ polling_interval: 10 }); + } + + if (settings.phase_capacity == null) { + await this.setSettings({ phase_capacity: 40 }); + } + + if (settings.number_of_phases == null) { + await this.setSettings({ number_of_phases: 1 }); + } + + if (settings.show_gas === undefined || settings.show_gas === null) { + await this.setSettings({ show_gas: true }); + } + + // Initial phase count (user setting or autodetect later) + this._phases = Number(this.getSettings().number_of_phases) || 1; + + // Clean slate: if 1 phase → remove all L2/L3/T3 + if (this._phases === 1) { + for (const cap of PHASE_CAPS) { + if (this.hasCapability(cap)) { + await this.removeCapability(cap).catch(this.error); + } + } + } + + // If 3 phases → ensure all L2/L3/T3 exist + if (this._phases === 3) { + for (const cap of PHASE_CAPS) { + if (!this.hasCapability(cap)) { + await safeAddCapability(this, cap).catch(this.error); + } + } + } + + // Autodetect counter for 1 → 3 phases promotion + this._phaseDetectCount = 0; + + // Gas capabilities are settings-driven, not payload-driven + if (!settings.show_gas) { + for (const cap of ['meter_gas', 'measure_gas', 'meter_gas.daily']) { + if (this.hasCapability(cap)) { + await this.removeCapability(cap).catch(this.error); + } + } + } + + const interval = Math.max(this.getSettings().polling_interval || 10, 2); + const offset = Math.floor(Math.random() * interval * 1000); + + if (this.onPollInterval) clearInterval(this.onPollInterval); + + // First poll offset + setTimeout(() => { + if (this._deleted) return; + this.onPoll().catch(this.error); + + // Daarna vaste interval zonder lock + this.onPollInterval = setInterval(() => { + if (!this._deleted) { + this.onPoll().catch(this.error); + } + }, interval * 1000); + + }, offset); + + + this._flowTriggerTariff = this.homey.flow.getDeviceTriggerCard('tariff_changed'); + this._flowTriggerImport = this.homey.flow.getDeviceTriggerCard('import_changed'); + this._flowTriggerExport = this.homey.flow.getDeviceTriggerCard('export_changed'); + + this.registerCapabilityListener('identify', async () => { + await this.onIdentify(); + }); + + // Baseload monitor wiring + this._baseloadNotificationsEnabled = this.getSetting('baseload_notifications') ?? true; + this._phaseOverloadNotificationsEnabled = this.getSetting('phase_overload_notifications') ?? true; + + this._phaseOverloadState = { + l1: { highCount: 0, notified: false }, + l2: { highCount: 0, notified: false }, + l3: { highCount: 0, notified: false }, + }; + + const app = this.homey.app; + if (!app.baseloadMonitor) { + app.baseloadMonitor = new BaseloadMonitor(this.homey); + } + + app.baseloadMonitor.registerP1Device(this); + app.baseloadMonitor.trySetMaster(this); + app.baseloadMonitor.setNotificationsEnabledForDevice(this, this._baseloadNotificationsEnabled); + } + + // mini-cache helper + _hasChanged(key, value) { + const prev = this._lastSamples[key]; + if (prev === value) return false; + this._lastSamples[key] = value; + return true; + } + + onDeleted() { + this._deleted = true; + + const app = this.homey.app; + if (app.baseloadMonitor) { + app.baseloadMonitor.unregisterP1Device(this); + } + + if (this.onPollInterval) { + clearInterval(this.onPollInterval); + this.onPollInterval = null; + } + } + + flowTriggerTariff(device, tokens) { + this._flowTriggerTariff.trigger(device, tokens).catch(this.error); + } + + flowTriggerImport(device, tokens) { + this._flowTriggerImport.trigger(device, tokens).catch(this.error); + } + + flowTriggerExport(device, tokens) { + this._flowTriggerExport.trigger(device, tokens).catch(this.error); + } + + _onNewPowerValue(power) { + const app = this.homey.app; + if (app.baseloadMonitor) { + app.baseloadMonitor.updatePowerFromDevice(this, power); + } + } + + async onIdentify() { + if (!this.url) return; + + try { + const res = await fetchWithTimeout(`${this.url}/identify`, { + agent: this.agent, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!res || !res.ok) { + await updateCapability(this, 'connection_error', res ? res.status : 'fetch failed'); + await updateCapability(this, 'alarm_connectivity', true); + throw new Error(res ? res.statusText : 'Unknown error during fetch'); + } + + } catch (err) { + this.error(err); + throw new Error('Network error during onIdentify'); + } + } + +onDiscoveryAvailable(discoveryResult) { + if (this._deleted) return; + + if (!discoveryResult?.address || !discoveryResult?.port || !discoveryResult?.txt?.path) { + this.log('Invalid discovery result'); + return; + } + + const newUrl = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + + // Only update if the URL actually changed + if (this.url !== newUrl) { + this.url = newUrl; + this.log(`Discovered device URL: ${this.url}`); + } + + this.setAvailable(); +} + + +onDiscoveryAddressChanged(discoveryResult) { + if (this._deleted) return; + + const newUrl = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + + // Only update if the URL actually changed + if (this.url !== newUrl) { + this.url = newUrl; + this.log(`URL updated: ${this.url}`); + this._debugLog(`Discovery address changed: ${this.url}`); + } +} + + +onDiscoveryLastSeenChanged(discoveryResult) { + if (this._deleted) return; + + const newUrl = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + + // Only update if the URL actually changed + if (this.url !== newUrl) { + this.url = newUrl; + this.log(`URL restored: ${this.url}`); + } + + this.setAvailable(); +} + + + async setCloudOn() { + if (!this.url) return; + + try { + const res = await fetchWithTimeout(`${this.url}/system`, { + agent: this.agent, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cloud_enabled: true }) + }); + + if (!res || !res.ok) { + await updateCapability(this, 'connection_error', res ? res.status : 'fetch failed'); + await updateCapability(this, 'alarm_connectivity', true); + throw new Error(res ? res.statusText : 'Unknown error during fetch'); + } + + } catch (err) { + this.error(err); + throw new Error('Network error during setCloudOn'); + } + } + + async setCloudOff() { + if (!this.url) return; + + try { + const res = await fetchWithTimeout(`${this.url}/system`, { + agent: this.agent, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cloud_enabled: false }) + }); + + if (!res || !res.ok) { + await updateCapability(this, 'connection_error', res ? res.status : 'fetch failed'); + await updateCapability(this, 'alarm_connectivity', true); + throw new Error(res ? res.statusText : 'Unknown error during fetch'); + } + + } catch (err) { + this.error(err); + throw new Error('Network error during setCloudOff'); + } + } + + /** + * Debug logger (batched writes) + */ + _debugLog(msg) { + try { + if (!this._debugBuffer) this._debugBuffer = []; + const ts = new Date().toLocaleString('nl-NL', { hour12: false, timeZone: 'Europe/Amsterdam' }); + const driverName = this.driver.id; + const deviceName = this.getName(); + const safeMsg = typeof msg === 'string' ? msg : (msg instanceof Error ? msg.message : JSON.stringify(msg)); + const line = `${ts} [${driverName}] [${deviceName}] ${safeMsg}`; + this._debugBuffer.push(line); + if (this._debugBuffer.length > 20) this._debugBuffer.shift(); + if (!this._debugFlushTimeout) { + this._debugFlushTimeout = setTimeout(() => { + this._flushDebugLogs(); + this._debugFlushTimeout = null; + }, 5000); + } + } catch (err) { + this.error('Failed to write debug logs:', err.message || err); + } +} +_flushDebugLogs() { + if (!this._debugBuffer || this._debugBuffer.length === 0) return; + try { + const logs = this.homey.settings.get('debug_logs') || []; + logs.push(...this._debugBuffer); + if (logs.length > 500) logs.splice(0, logs.length - 500); + this.homey.settings.set('debug_logs', logs); + this._debugBuffer = []; + } catch (err) { + this.error('Failed to flush debug logs:', err.message || err); + } +} + +async onPoll() { + if (this._deleted) return; + + const settings = this.getSettings(); + + // --- EARLY RETURN SAFE --- + if (!await this._prepareUrl(settings)) { + return; + } + + let data, nowLocal, homeyLang; + + // + // --- FETCH DATA --- + // + try { + const t = this._getLocalTimeAndLang(); + nowLocal = t.nowLocal; + homeyLang = t.homeyLang; + data = await this._fetchData(); + } catch (err) { + this._handlePollError(err); + return; + } + + // + // ⚡ ELECTRICITY FIRST + // + try { + const tasks = []; + + this._processCorePowerAndWifi(data, tasks); + await this._processTariffAndFlows(data, tasks); + this._processExportAndNetImport(data, tasks); + await this._processImportExportFlows(data, tasks); + this._processBelgiumMonthlyPeak(data, tasks); + this._processPhase1MetricsAndOverload(data, tasks, settings, homeyLang); + this._processPhases2And3(data, tasks, settings, homeyLang); + this._processT3ImportExport(data, tasks); + this._processUrlSync(tasks, settings); + + await Promise.allSettled(tasks); + + await updateCapability(this, 'connection_error', 'No errors'); + await updateCapability(this, 'alarm_connectivity', false); + await this.setAvailable(); + + } catch (err) { + this.error('Electricity processing failed:', err); + } + + // + // 💧 GAS/WATER + // + try { + this._processGasSourceSelection(data); + + const gasTasks = []; + + await this._processMidnightDailyReset(data, gasTasks, settings, nowLocal); + this._processExternalWater(data, gasTasks); + this._processGasLiveValue(data, gasTasks, settings); + this._processGasDelta(data, gasTasks, settings, nowLocal); + this._processDailyTotals(data, gasTasks, settings); + + await Promise.allSettled(gasTasks); + + } catch (err) { + this.error('Gas/Water processing failed:', err); + } +} + + + + async _prepareUrl(settings) { + if (!this.url) { + if (settings.url) { + this.url = settings.url; + this.log(`Restored URL from settings: ${this.url}`); + } else { + //await this.setUnavailable('Missing URL'); + this._debugLog(`Missing URL in settings for device "${this.getName()}"`); + this.log('Polling skipped: missing URL in settings'); + updateCapability(this, 'alarm_connectivity', true).catch(this.error); + return false; + } + } + return true; + } + + _getLocalTimeAndLang() { + const tz = this.homey.clock.getTimezone(); + const now = new Date(); + const iso = now.toLocaleString('sv-SE', { timeZone: tz }); + const nowLocal = new Date(iso); + const homeyLang = this.homey.i18n.getLanguage(); + return { now, nowLocal, homeyLang }; + } + + async _fetchData() { + const res = await fetchWithTimeout(`${this.url}/data`, { + agent: this.agent, + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }); + + if (!res || !res.ok) { + await updateCapability(this, 'connection_error', 'Fetch error'); + await updateCapability(this, 'alarm_connectivity', true); + throw new Error(res ? res.statusText : 'Unknown error during fetch'); + } + + let text; + let data; + + try { + text = await res.text(); + data = JSON.parse(text); + } catch (err) { + this.error('JSON parse error:', err.message, 'Body:', text?.slice(0, 200)); + throw new Error('Invalid JSON'); + } + + if (!data || typeof data !== 'object') { + throw new Error('Invalid JSON'); + } + + return data; + } + + _processGasSourceSelection(data) { + let gasValue = null; + let gasTimestamp = null; + + if (Array.isArray(data.external)) { + const gasMeters = data.external + .filter(e => e.type === 'gas_meter' && e.value != null && e.timestamp != null); + + if (gasMeters.length > 0) { + gasMeters.sort((a, b) => b.timestamp - a.timestamp); + gasValue = gasMeters[0].value; + gasTimestamp = gasMeters[0].timestamp; + } + } + + if (gasValue == null && data.total_gas_m3 != null) { + gasValue = data.total_gas_m3; + gasTimestamp = data.gas_timestamp; + } + + data._gasValue = gasValue; + data._gasTimestamp = gasTimestamp; + } + + async _processPhaseAutodetect(data, tasks) { + const hasRealL2 = typeof data.active_current_l2_a === 'number' && data.active_current_l2_a !== 0; + const hasRealL3 = typeof data.active_current_l3_a === 'number' && data.active_current_l3_a !== 0; + + if (this._phases === 1 && (hasRealL2 || hasRealL3)) { + this._phaseDetectCount++; + + if (this._phaseDetectCount >= 5) { + this._phases = 3; + await this.setSettings({ number_of_phases: 3 }).catch(this.error); + + for (const cap of PHASE_CAPS) { + if (!this.hasCapability(cap)) { + await safeAddCapability(this, cap).catch(this.error); + } + } + + this.log('Autodetect: promoted to 3 phases'); + } + } else { + this._phaseDetectCount = 0; + } + } + + async _processMidnightDailyReset(data, tasks, settings, nowLocal) { + // Format today's date as YYYY-MM-DD + const today = nowLocal.toISOString().slice(0, 10); + + // Read last reset date + const lastReset = await this.getStoreValue('last_reset_date'); + + // First run or new day → perform reset + if (lastReset !== today) { + + // Reset electricity baseline + if (data.total_power_import_kwh !== undefined) { + tasks.push( + this.setStoreValue('meter_start_day', data.total_power_import_kwh) + .catch(this.error) + ); + } + + // Reset gas baseline + if (settings.show_gas && data._gasValue !== undefined) { + tasks.push( + this.setStoreValue('gasmeter_start_day', data._gasValue) + ); + } + + // Store today's date so we don't reset again + tasks.push( + this.setStoreValue('last_reset_date', today) + ); + + return; + } + + // If baseline missing (e.g. after reinstall), initialize it + const meterStartDay = await this.getStoreValue('meter_start_day'); + if (!meterStartDay && data.total_power_import_kwh !== undefined) { + tasks.push( + this.setStoreValue('meter_start_day', data.total_power_import_kwh) + .catch(this.error) + ); + } + + if (settings.show_gas) { + const gasStartDay = await this.getStoreValue('gasmeter_start_day'); + if (!gasStartDay && data._gasValue !== undefined) { + tasks.push( + this.setStoreValue('gasmeter_start_day', data._gasValue) + ); + } + } +} + + + async _processGasDelta(data, tasks, settings, nowLocal) { + if (settings.show_gas && (nowLocal.getMinutes() % 5 === 0)) { + const prevTs = await this.getStoreValue('gasmeter_previous_reading_timestamp'); + + if (prevTs == null) { + tasks.push(this.setStoreValue('gasmeter_previous_reading_timestamp', data._gasTimestamp)); + } else if (data._gasValue != null && prevTs !== data._gasTimestamp) { + const prevReading = await this.getStoreValue('gasmeter_previous_reading'); + if (prevReading != null) { + const gasDelta = data._gasValue - prevReading; + if (gasDelta >= 0 && this._hasChanged('measure_gas_delta', gasDelta)) { + tasks.push(updateCapability(this, 'measure_gas', gasDelta)); + } + } + tasks.push(this.setStoreValue('gasmeter_previous_reading', data._gasValue)); + tasks.push(this.setStoreValue('gasmeter_previous_reading_timestamp', data._gasTimestamp)); + } + } + } + + async _processDailyTotals(data, tasks, settings) { + const meterStart = await this.getStoreValue('meter_start_day'); + if (meterStart != null && data.total_power_import_kwh != null) { + const dailyImport = data.total_power_import_kwh - meterStart; + if (this._hasChanged('meter_power.daily', dailyImport)) { + tasks.push(updateCapability(this, 'meter_power.daily', dailyImport)); + } + } + + if (settings.show_gas) { + const gasStart = await this.getStoreValue('gasmeter_start_day'); + if (data._gasValue != null && gasStart != null) { + const gasDiff = data._gasValue - gasStart; + if (this._hasChanged('meter_gas.daily', gasDiff)) { + tasks.push(updateCapability(this, 'meter_gas.daily', gasDiff)); + } + } + } + } + + _processCorePowerAndWifi(data, tasks) { + if (this._hasChanged('measure_power', data.active_power_w)) { + tasks.push(updateCapability(this, 'measure_power', data.active_power_w)); + this._onNewPowerValue(data.active_power_w); + } + + if (this._hasChanged('rssi', data.wifi_strength)) { + tasks.push(updateCapability(this, 'rssi', data.wifi_strength)); + } + + if (this._hasChanged('tariff', data.active_tariff)) { + tasks.push(updateCapability(this, 'tariff', data.active_tariff)); + } + + tasks.push(updateCapability(this, 'identify', 'identify')); + + if (this._hasChanged('meter_power.consumed.t1', data.total_power_import_t1_kwh)) { + tasks.push(updateCapability(this, 'meter_power.consumed.t1', data.total_power_import_t1_kwh)); + } + if (this._hasChanged('meter_power.consumed.t2', data.total_power_import_t2_kwh)) { + tasks.push(updateCapability(this, 'meter_power.consumed.t2', data.total_power_import_t2_kwh)); + } + if (this._hasChanged('meter_power.consumed', data.total_power_import_kwh)) { + tasks.push(updateCapability(this, 'meter_power.consumed', data.total_power_import_kwh)); + } + + const wifiQuality = getWifiQuality(data.wifi_strength); + if (this._hasChanged('wifi_quality', wifiQuality)) { + tasks.push(updateCapability(this, 'wifi_quality', wifiQuality)); + } + } + + async _processTariffAndFlows(data, tasks) { + const lastTariff = await this.getStoreValue('last_active_tariff'); + const currentTariff = data.active_tariff; + if (typeof currentTariff === 'number' && currentTariff !== lastTariff) { + this.flowTriggerTariff(this, { tariff_changed: currentTariff }); + tasks.push(this.setStoreValue('last_active_tariff', currentTariff).catch(this.error)); + } + } + + _processGasLiveValue(data, tasks, settings) { + if (settings.show_gas && data._gasValue != null && this._hasChanged('meter_gas', data._gasValue)) { + tasks.push(updateCapability(this, 'meter_gas', data._gasValue)); + } + } + + _processExportAndNetImport(data, tasks) { + if (data.total_power_export_kwh > 1 || data.total_power_export_t2_kwh > 1) { + if (this._hasChanged('meter_power.produced.t1', data.total_power_export_t1_kwh)) { + tasks.push(updateCapability(this, 'meter_power.produced.t1', data.total_power_export_t1_kwh)); + } + if (this._hasChanged('meter_power.produced.t2', data.total_power_export_t2_kwh)) { + tasks.push(updateCapability(this, 'meter_power.produced.t2', data.total_power_export_t2_kwh)); + } + } + + const netImport = data.total_power_import_kwh === undefined + ? (data.total_power_import_t1_kwh + data.total_power_import_t2_kwh) - + (data.total_power_export_t1_kwh + data.total_power_export_t2_kwh) + : data.total_power_import_kwh - data.total_power_export_kwh; + + if (this._hasChanged('meter_power', netImport)) { + tasks.push(updateCapability(this, 'meter_power', netImport)); + } + + if (data.total_power_import_kwh !== undefined && + this._hasChanged('meter_power.returned', data.total_power_export_kwh)) { + tasks.push(updateCapability(this, 'meter_power.returned', data.total_power_export_kwh)); + } + } + + async _processImportExportFlows(data, tasks) { + const lastImport = await this.getStoreValue('last_total_import_kwh'); + const currentImport = data.total_power_import_kwh; + if (typeof currentImport === 'number' && currentImport !== lastImport) { + this.flowTriggerImport(this, { import_changed: currentImport }); + tasks.push(this.setStoreValue('last_total_import_kwh', currentImport).catch(this.error)); + } + + const lastExport = await this.getStoreValue('last_total_export_kwh'); + const currentExport = data.total_power_export_kwh; + if (typeof currentExport === 'number' && currentExport !== lastExport) { + this.flowTriggerExport(this, { export_changed: currentExport }); + tasks.push(this.setStoreValue('last_total_export_kwh', currentExport).catch(this.error)); + } + } + + _processBelgiumMonthlyPeak(data, tasks) { + if (this._hasChanged('measure_power.montly_power_peak', data.montly_power_peak_w)) { + tasks.push(updateCapability(this, 'measure_power.montly_power_peak', data.montly_power_peak_w)); + } + } + + _processPhase1MetricsAndOverload(data, tasks, settings, homeyLang) { + if (data.active_voltage_l1_v !== undefined && + this._hasChanged('measure_voltage.l1', data.active_voltage_l1_v)) { + tasks.push(updateCapability(this, 'measure_voltage.l1', data.active_voltage_l1_v)); + } + if (data.active_current_l1_a !== undefined && + this._hasChanged('measure_current.l1', data.active_current_l1_a)) { + tasks.push(updateCapability(this, 'measure_current.l1', data.active_current_l1_a)); + } + // Legacy measure_current (mirror of L1) + if (data.active_current_l1_a !== undefined && + this._hasChanged('measure_current', data.active_current_l1_a)) { + tasks.push(updateCapability(this, 'measure_current', data.active_current_l1_a)); + } + + if (data.active_power_l1_w !== undefined && + this._hasChanged('measure_power.l1', data.active_power_l1_w)) { + tasks.push(updateCapability(this, 'measure_power.l1', data.active_power_l1_w)); + } + + if (data.long_power_fail_count !== undefined && + this._hasChanged('long_power_fail_count', data.long_power_fail_count)) { + tasks.push(updateCapability(this, 'long_power_fail_count', data.long_power_fail_count)); + } + + if (data.voltage_sag_l1_count !== undefined && + this._hasChanged('voltage_sag_l1', data.voltage_sag_l1_count)) { + tasks.push(updateCapability(this, 'voltage_sag_l1', data.voltage_sag_l1_count)); + } + + if (data.voltage_swell_l1_count !== undefined && + this._hasChanged('voltage_swell_l1', data.voltage_swell_l1_count)) { + tasks.push(updateCapability(this, 'voltage_swell_l1', data.voltage_swell_l1_count)); + } + + if (data.active_current_l1_a !== undefined) { + const load1 = Math.abs((data.active_current_l1_a / settings.phase_capacity) * 100); + if (this._hasChanged('net_load_phase1_pct', load1)) { + tasks.push(updateCapability(this, 'net_load_phase1_pct', load1)); + this._handlePhaseOverload('l1', load1, homeyLang); + } + } + } + + _processPhases2And3(data, tasks, settings, homeyLang) { + if (this._phases === 3 && (data.active_current_l2_a !== undefined || data.active_current_l3_a !== undefined)) { + + if (data.voltage_sag_l2_count !== undefined && + this._hasChanged('voltage_sag_l2', data.voltage_sag_l2_count)) { + tasks.push(updateCapability(this, 'voltage_sag_l2', data.voltage_sag_l2_count)); + } + if (data.voltage_sag_l3_count !== undefined && + this._hasChanged('voltage_sag_l3', data.voltage_sag_l3_count)) { + tasks.push(updateCapability(this, 'voltage_sag_l3', data.voltage_sag_l3_count)); + } + if (data.voltage_swell_l2_count !== undefined && + this._hasChanged('voltage_swell_l2', data.voltage_swell_l2_count)) { + tasks.push(updateCapability(this, 'voltage_swell_l2', data.voltage_swell_l2_count)); + } + if (data.voltage_swell_l3_count !== undefined && + this._hasChanged('voltage_swell_l3', data.voltage_swell_l3_count)) { + tasks.push(updateCapability(this, 'voltage_swell_l3', data.voltage_swell_l3_count)); + } + + if (data.active_power_l2_w !== undefined && + this._hasChanged('measure_power.l2', data.active_power_l2_w)) { + tasks.push(updateCapability(this, 'measure_power.l2', data.active_power_l2_w)); + } + if (data.active_power_l3_w !== undefined && + this._hasChanged('measure_power.l3', data.active_power_l3_w)) { + tasks.push(updateCapability(this, 'measure_power.l3', data.active_power_l3_w)); + } + + if (data.active_voltage_l2_v !== undefined && + this._hasChanged('measure_voltage.l2', data.active_voltage_l2_v)) { + tasks.push(updateCapability(this, 'measure_voltage.l2', data.active_voltage_l2_v)); + } + if (data.active_voltage_l3_v !== undefined && + this._hasChanged('measure_voltage.l3', data.active_voltage_l3_v)) { + tasks.push(updateCapability(this, 'measure_voltage.l3', data.active_voltage_l3_v)); + } + + if (data.active_current_l2_a !== undefined) { + const load2 = Math.abs((data.active_current_l2_a / settings.phase_capacity) * 100); + if (this._hasChanged('measure_current.l2', data.active_current_l2_a)) { + tasks.push(updateCapability(this, 'measure_current.l2', data.active_current_l2_a)); + } + if (this._hasChanged('net_load_phase2_pct', load2)) { + tasks.push(updateCapability(this, 'net_load_phase2_pct', load2)); + this._handlePhaseOverload('l2', load2, homeyLang); + } + } + + if (data.active_current_l3_a !== undefined) { + const load3 = Math.abs((data.active_current_l3_a / settings.phase_capacity) * 100); + if (this._hasChanged('measure_current.l3', data.active_current_l3_a)) { + tasks.push(updateCapability(this, 'measure_current.l3', data.active_current_l3_a)); + } + if (this._hasChanged('net_load_phase3_pct', load3)) { + tasks.push(updateCapability(this, 'net_load_phase3_pct', load3)); + this._handlePhaseOverload('l3', load3, homeyLang); + } + } + } + } + + _processT3ImportExport(data, tasks) { + if (this._phases === 3) { + if (this._hasChanged('meter_power.consumed.t3', data.total_power_import_t3_kwh)) { + tasks.push(updateCapability(this, 'meter_power.consumed.t3', data.total_power_import_t3_kwh)); + } + if (this._hasChanged('meter_power.produced.t3', data.total_power_export_t3_kwh)) { + tasks.push(updateCapability(this, 'meter_power.produced.t3', data.total_power_export_t3_kwh)); + } + } + } + + _processExternalWater(data, tasks) { + const externalData = data.external; + if (Array.isArray(externalData)) { + const latestWater = externalData.reduce((prev, current) => { + if (current.type === 'water_meter') { + return !prev || current.timestamp > prev.timestamp ? current : prev; + } + return prev; + }, null); + + if (latestWater && latestWater.value != null && + this._hasChanged('meter_water', latestWater.value)) { + tasks.push(updateCapability(this, 'meter_water', latestWater.value)); + } + } + } + + _processUrlSync(tasks, settings) { + if (this.url !== settings.url) { + this.log(`Energy - Updating settings url from ${settings.url} → ${this.url}`); + tasks.push(this.setSettings({ url: this.url }).catch(this.error)); + } + } + + _handlePollError(err) { + + updateCapability(this, 'connection_error', err.message || 'Polling error').catch(this.error); + + //this.setUnavailable(err.message || 'Polling error').catch(this.error); + updateCapability(this, 'alarm_connectivity', true).catch(this.error); + + // Debug logging + this._debugLog(`Poll failed: ${err.message || err}`); + this.log(`Poll failed: ${err.message || err}`); + } + + _handlePhaseOverload(phaseKey, loadPct, lang) { + if (!this._phaseOverloadNotificationsEnabled) return; + + const state = this._phaseOverloadState[phaseKey]; + if (!state) return; + + const threshold = this._overloadThreshold ?? 97; + const reset = this._overloadReset ?? 85; + + if (loadPct > threshold) { + state.highCount++; + + if (!state.notified && state.highCount >= 3) { + const phaseNum = phaseKey.replace('l', ''); + const msg = lang === 'nl' + ? `Fase ${phaseNum} overbelast (${loadPct.toFixed(0)}%)` + : `Phase ${phaseNum} overloaded (${loadPct.toFixed(0)}%)`; + + this.homey.notifications.createNotification({ excerpt: msg }).catch(this.error); + state.notified = true; + } + + } else if (loadPct < reset) { + state.highCount = 0; + state.notified = false; + } + } + + async onSettings(event) { + const { newSettings, changedKeys } = event; + this.log('Settings updated', changedKeys); + + for (const key of changedKeys) { + + if (key === 'polling_interval') { + const interval = newSettings.polling_interval; + if (typeof interval === 'number' && interval > 0) { + if (this.onPollInterval) clearInterval(this.onPollInterval); + this.onPollInterval = setInterval(this.onPoll.bind(this), interval * 1000); + } else { + this.log('Invalid polling interval:', interval); + } + } + + if (key === 'cloud') { + try { + if (newSettings.cloud == 1) await this.setCloudOn(); + else await this.setCloudOff(); + } catch (err) { + this.error('Failed to update cloud connection:', err); + } + } + + if (key === 'baseload_notifications') { + this._baseloadNotificationsEnabled = newSettings.baseload_notifications; + const app = this.homey.app; + if (app.baseloadMonitor) { + app.baseloadMonitor.setNotificationsEnabledForDevice(this, this._baseloadNotificationsEnabled); + } + this.log('Baseload notifications changed to:', this._baseloadNotificationsEnabled); + } + + if (key === 'phase_overload_notifications') { + this._phaseOverloadNotificationsEnabled = newSettings.phase_overload_notifications; + this.log('Phase overload notifications changed to:', this._phaseOverloadNotificationsEnabled); + } + + if (key === 'show_gas') { + const showGas = newSettings.show_gas; + if (!showGas) { + for (const cap of ['meter_gas', 'measure_gas', 'meter_gas.daily']) { + if (this.hasCapability(cap)) { + await this.removeCapability(cap).catch(this.error); + } + } + } + } + + if (key === 'phase_overload_threshold') { + this._overloadThreshold = newSettings.phase_overload_threshold; + this.log('Phase overload threshold changed to:', this._overloadThreshold); + } + + if (key === 'phase_overload_reset') { + this._overloadReset = newSettings.phase_overload_reset; + this.log('Phase overload reset changed to:', this._overloadReset); + } + + if (key === 'number_of_phases') { + // Manual override: keep capabilities in sync with explicit phase setting + this._phases = newSettings.number_of_phases; + + if (this._phases === 1) { + for (const cap of PHASE_CAPS) { + if (this.hasCapability(cap)) { + await this.removeCapability(cap).catch(this.error); + } + } + } + + if (this._phases === 3) { + for (const cap of PHASE_CAPS) { + if (!this.hasCapability(cap)) { + await safeAddCapability(this, cap).catch(this.error); + } + } + } + } + + } + } + +}; diff --git a/drivers/energy/driver.compose.json b/drivers/energy/driver.compose.json new file mode 100644 index 00000000..92da04b0 --- /dev/null +++ b/drivers/energy/driver.compose.json @@ -0,0 +1,268 @@ +{ + "name": { + "en": "P1 Meter" + }, + "images": { + "large": "drivers/energy/assets/images/large.png", + "small": "drivers/energy/assets/images/small.png" + }, + "class": "sensor", + "discovery": "energy", + "platforms": [ + "local" + ], + "capabilities": [ + "identify", + "measure_power", + "meter_gas", + "measure_gas", + "meter_water", + "meter_power", + "meter_power.consumed.t1", + "meter_power.produced.t1", + "meter_power.consumed.t2", + "meter_power.produced.t2", + "meter_power.consumed.t3", + "meter_power.produced.t3", + "meter_power.consumed", + "meter_power.returned", + "meter_power.daily", + "measure_power.l1", + "measure_power.l2", + "measure_power.l3", + "measure_current.l1", + "measure_current.l2", + "measure_current.l3", + "net_load_phase1", + "net_load_phase2", + "net_load_phase3", + "net_load_phase1_pct", + "net_load_phase2_pct", + "net_load_phase3_pct", + "measure_voltage.l1", + "measure_voltage.l2", + "measure_voltage.l3", + "measure_power.montly_power_peak", + "rssi", + "wifi_quality", + "tariff", + "long_power_fail_count", + "voltage_sag_l1", + "voltage_sag_l2", + "voltage_sag_l3", + "voltage_swell_l1", + "voltage_swell_l2", + "voltage_swell_l3", + "connection_error", + "measure_frequency", + "alarm_connectivity" + ], + "energy": { + "cumulative": true, + "cumulativeImportedCapability": "meter_power.consumed", + "cumulativeExportedCapability": "meter_power.returned" + }, + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + } + }, + "meter_gas": { + "decimals": 3, + "title": { + "en": "Gasmeter", + "nl": "Gasmeter" + } + }, + "meter_gas.daily": { + "decimals": 3, + "title": { + "en": "Day Usage Gas", + "nl": "Dag verbruik Gas" + } + }, + "meter_water": { + "decimals": 3, + "title": { + "en": "Watermeter", + "nl": "Watermeter" + } + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "measure_power.l2": { + "title": { + "en": "Current usage phase 2", + "nl": "Huidig gebruik fase 2" + } + }, + "measure_power.l3": { + "title": { + "en": "Current usage phase 3", + "nl": "Huidig gebruik fase 3" + } + }, + "measure_current.l1": { + "title": { + "en": "Current Amp phase 1", + "nl": "Huidig Amp fase 1" + } + }, + "measure_current.l2": { + "title": { + "en": "Current Amp phase 2", + "nl": "Huidig Amp fase 2" + } + }, + "measure_current.l3": { + "title": { + "en": "Current Amp phase 3", + "nl": "Huidig Amp fase 3" + } + }, + "net_load_phase1_pct" :{ + "title": { + "en": "Phase 1 load %", + "nl": "Fase 1 belasting %" + } + }, + "net_load_phase2_pct" :{ + "title": { + "en": "Phase 2 load %", + "nl": "Fase 2 belasting %" + } + }, + "net_load_phase3_pct" :{ + "title": { + "en": "Phase 3 load %", + "nl": "Fase 3 belasting %" + } + }, + "measure_voltage.l1": { + "title": { + "en": "Current Voltage phase 1", + "nl": "Huidig Voltage fase 1" + } + }, + "measure_voltage.l2": { + "title": { + "en": "Current Voltage phase 2", + "nl": "Huidig Voltage fase 2" + } + }, + "measure_voltage.l3": { + "title": { + "en": "Current Voltage phase 3", + "nl": "Huidig Voltage fase 3" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power.consumed.t2": { + "decimals": 3, + "title": { + "en": "Total t2 usage", + "nl": "Totaal t2 gebruik" + } + }, + "meter_power.produced.t2": { + "decimals": 3, + "title": { + "en": "Total t2 deliver", + "nl": "Totaal t2 teruglevering" + } + }, + "meter_power.consumed.t3": { + "decimals": 3, + "title": { + "en": "Total t3 usage", + "nl": "Totaal t3 gebruik" + } + }, + "meter_power.produced.t3": { + "decimals": 3, + "title": { + "en": "Total t3 deliver", + "nl": "Totaal t3 teruglevering" + } + }, + "meter_power.consumed": { + "decimals": 3, + "title": { + "en": "Sum Consumed", + "nl": "Som gebruik" + } + }, + "meter_power.returned": { + "decimals": 3, + "title": { + "en": "Sum returned", + "nl": "Som teruglevering" + } + }, + "meter_power.daily": { + "decimals": 3, + "title": { + "en": "Daily usage", + "nl": "Dag verbruik" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + } + }, + "measure_frequency": { + "decimals": 3, + "title": { + "en": "Current Frequency", + "nl": "Huidige Frequentie" + } + }, + "measure_power.montly_power_peak": { + "title": { + "en": "Monthly Power Peak", + "nl": "Maandelijks piekvermogen" + } + } + }, + "pair": [ + { + "id": "start", + "navigation": { + "next": "list_devices" + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ] +} diff --git a/drivers/energy/driver.flow.compose.json b/drivers/energy/driver.flow.compose.json new file mode 100644 index 00000000..db005275 --- /dev/null +++ b/drivers/energy/driver.flow.compose.json @@ -0,0 +1,61 @@ +{ + "triggers": [ + { + "id": "tariff_changed", + "title": { + "en": "Peak / Normal Tariff changed", + "nl": "Dal / Normaal Tarief veranderd" + }, + "args": [], + "tokens": [ + { + "name": "tariff_changed", + "type": "number", + "title": { + "en": "tariff", + "nl": "tarief" + }, + "example": 1 + } + ] + }, + { + "id": "import_changed", + "title": { + "en": "Total used changed", + "nl": "Som gebruik veranderd" + }, + "args": [], + "tokens": [ + { + "name": "import_changed", + "type": "number", + "title": { + "en": "import", + "nl": "import" + }, + "example": 1 + } + ] + }, + { + "id": "export_changed", + "title": { + "en": "Total delivered changed", + "nl": "Som teruglevering veranderd" + }, + "args": [], + "tokens": [ + { + "name": "export_changed", + "type": "number", + "title": { + "en": "export", + "nl": "export" + }, + "example": 1 + } + ] + } + ] +} \ No newline at end of file diff --git a/drivers/energy/driver.js b/drivers/energy/driver.js new file mode 100644 index 00000000..a1a0dc29 --- /dev/null +++ b/drivers/energy/driver.js @@ -0,0 +1,5 @@ +'use strict'; + +const driver = require('../../includes/v1/driver.js'); + +module.exports = driver; diff --git a/drivers/energy/driver.settings.compose.json b/drivers/energy/driver.settings.compose.json new file mode 100644 index 00000000..16166b5e --- /dev/null +++ b/drivers/energy/driver.settings.compose.json @@ -0,0 +1,93 @@ +[ + { + "id": "polling_interval", + "type": "number", + "label": { "en": "Polling interval" }, + "value": 10, + "min": 1, + "unit": { "en": "s" } + }, + { + "id": "number_of_phases", + "type": "number", + "label": { "en": "Amount of phase(s)", + "nl": "Aantal fase(s)" }, + "value": 1 + }, + { + "id": "phase_capacity", + "type": "number", + "label": { "en": "Phase capacity A", + "nl": "Fase capaciteit A" }, + "value": 40, + "unit": { "en": "A" } + }, + { + "id": "cloud", + "type": "number", + "label": { "en": "Cloud connection 1=on 0=off", + "nl": "Cloud verbinding 1=actief 0=uit" + }, + "value": 1 + }, + { + "id": "url", + "type": "text", + "label": { "en": "url", + "nl": "url" + } + }, + { + "id": "show_gas", + "type": "checkbox", + "label": { + "en": "Show gas meter", + "nl": "Gas meter weergeven" + }, + "value": true + }, + { + "id": "baseload_notifications", + "type": "checkbox", + "label": { + "en": "Enable baseload notifications", + "nl": "Sluipverbruik meldingen inschakelen" + }, + "value": true + }, + { + "id": "phase_overload_notifications", + "type": "checkbox", + "label": { + "en": "Phase overload notifications", + "nl": "Fase-overbelasting meldingen" + }, + "value": true + }, + { + "id": "phase_overload_threshold", + "type": "number", + "label": { + "en": "Phase overload warning threshold (%)", + "nl": "Fase overbelasting waarschuwing (%)" + }, + "value": 97, + "min": 50, + "max": 120, + "step": 1 +}, +{ + "id": "phase_overload_reset", + "type": "number", + "label": { + "en": "Phase overload reset threshold (%)", + "nl": "Fase overbelasting reset (%)" + }, + "value": 85, + "min": 20, + "max": 100, + "step": 1 +} + + +] \ No newline at end of file diff --git a/drivers/energy/pair/start.html b/drivers/energy/pair/start.html new file mode 100644 index 00000000..633815c6 --- /dev/null +++ b/drivers/energy/pair/start.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/drivers/energy_socket/assets/icon.svg b/drivers/energy_socket/assets/icon.svg new file mode 100644 index 00000000..98805a05 --- /dev/null +++ b/drivers/energy_socket/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drivers/energy_socket/assets/images/large.png b/drivers/energy_socket/assets/images/large.png new file mode 100644 index 00000000..e49913fc Binary files /dev/null and b/drivers/energy_socket/assets/images/large.png differ diff --git a/drivers/energy_socket/assets/images/small.png b/drivers/energy_socket/assets/images/small.png new file mode 100644 index 00000000..dad42d73 Binary files /dev/null and b/drivers/energy_socket/assets/images/small.png differ diff --git a/drivers/energy_socket/device.js b/drivers/energy_socket/device.js new file mode 100644 index 00000000..f46a61f1 --- /dev/null +++ b/drivers/energy_socket/device.js @@ -0,0 +1,456 @@ +'use strict'; + +const Homey = require('homey'); +const fetch = require('node-fetch'); +const http = require('http'); + +async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { + ...options, + signal: controller.signal + }); + return res; + } catch (err) { + if (err.name === 'AbortError') { + throw new Error('TIMEOUT'); + } + throw err; + } finally { + clearTimeout(timer); + } +} + + + +/** + * Safe capability updater + */ +async function updateCapability(device, capability, value) { + try { + const current = device.getCapabilityValue(capability); + + // --- SAFE REMOVE --- + // Removal is allowed only when: + // 1) the new value is null + // 2) the current value in Homey is also null + + if (value == null && current == null) { + if (device.hasCapability(capability)) { + await device.removeCapability(capability); + device.log(`🗑️ Removed capability "${capability}"`); + } + return; + } + + // --- ADD IF MISSING --- + if (!device.hasCapability(capability)) { + try { + await device.addCapability(capability); + device.log(`➕ Added capability "${capability}"`); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + device.log(`Capability already exists: ${capability} — ignoring`); + } else { + throw err; + } + } + } + + // --- UPDATE --- + if (current !== value) { + await device.setCapabilityValue(capability, value); + } + + } catch (err) { + if (err.message === 'device_not_found') { + device.log(`⚠️ Skipping capability "${capability}" — device not found`); + return; + } + device.error(`❌ Failed updateCapability("${capability}")`, err); + } +} + +module.exports = class HomeWizardEnergySocketDevice extends Homey.Device { + + async onInit() { + + this._lastStatePoll = 0; + this._debugLogs = []; + this.__deleted = false; + + // KeepAlive agent (blijft) + this.agent = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 10000, + }); + + await updateCapability(this, 'connection_error', 'No errors'); + await updateCapability(this, 'alarm_connectivity', false); + + const interval = Math.max(this.getSetting('offset_polling') || 10, 2); + const offset = Math.floor(Math.random() * interval * 1000); + + if (this.onPollInterval) clearInterval(this.onPollInterval); + + this.onPollInterval = setInterval(() => { + this.onPoll().catch(this.error); + }, interval * 1000); + + setTimeout(() => { + this.onPoll().catch(this.error); + }, offset); + + + if (this.getClass() === 'sensor') { + this.setClass('socket'); + } + + // Capability listeners + this.registerCapabilityListener('onoff', async (value) => { + if (this.getCapabilityValue('locked')) throw new Error('Device is locked'); + await this._putState({ power_on: value }); + }); + + this.registerCapabilityListener('identify', async () => { + await this._putIdentify(); + }); + + this.registerCapabilityListener('dim', async (value) => { + await this._putState({ brightness: Math.round(255 * value) }); + }); + + this.registerCapabilityListener('locked', async (value) => { + await this._putState({ switch_lock: value }); + }); + } + + onDeleted() { + this.__deleted = true; + + if (this.onPollInterval) { + clearInterval(this.onPollInterval); + this.onPollInterval = null; + } + if (this._debugFlushTimeout) { + clearTimeout(this._debugFlushTimeout); + this._debugFlushTimeout = null; + } + // Flush remaining logs before device deletion + if (this._debugBuffer && this._debugBuffer.length > 0) { + this._flushDebugLogs(); + } + // Destroy HTTP agent to close keep-alive sockets + if (this.agent) { + this.agent.destroy(); + this.agent = null; + } + // Clear debug buffer + if (this._debugBuffer) { + this._debugBuffer = null; + } + } + + /** + * Discovery handlers + */ + onDiscoveryAvailable(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this.setAvailable(); + } + + onDiscoveryAddressChanged(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this._debugLog(`Discovery address changed: ${this.url}`); + this.setAvailable(); + } + + onDiscoveryLastSeenChanged(discoveryResult) { + this.url = `http://${discoveryResult.address}:${discoveryResult.port}${discoveryResult.txt.path}`; + this.setAvailable(); + } + + /** + * Debug logger (batched writes to shared app settings) + */ +_debugLog(msg) { + try { + if (!this._debugBuffer) this._debugBuffer = []; + const ts = new Date().toLocaleString('nl-NL', { hour12: false, timeZone: 'Europe/Amsterdam' }); + const driverName = this.driver.id; + const deviceName = this.getName(); + const safeMsg = typeof msg === 'string' ? msg : (msg instanceof Error ? msg.message : JSON.stringify(msg)); + const line = `${ts} [${driverName}] [${deviceName}] ${safeMsg}`; + this._debugBuffer.push(line); + if (this._debugBuffer.length > 20) this._debugBuffer.shift(); + if (!this._debugFlushTimeout) { + this._debugFlushTimeout = setTimeout(() => { + this._flushDebugLogs(); + this._debugFlushTimeout = null; + }, 5000); + } + } catch (err) { + this.error('Failed to write debug logs:', err.message || err); + } +} + +_flushDebugLogs() { + if (!this._debugBuffer || this._debugBuffer.length === 0) return; + try { + const logs = this.homey.settings.get('debug_logs') || []; + logs.push(...this._debugBuffer); + if (logs.length > 500) logs.splice(0, logs.length - 500); + this.homey.settings.set('debug_logs', logs); + this._debugBuffer = []; + } catch (err) { + this.error('Failed to flush debug logs:', err.message || err); + } +} + + /** + * PUT /state (pure fetch, geen retries) + */ + async _putState(body) { + if (!this.url) return; + + try { + const res = await fetchWithTimeout(`${this.url}/state`, { + agent: this.agent, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + } catch (err) { + this._debugLog(`PUT /state failed: ${err.message}`); + throw new Error('Network error during state update'); + } + } + + /** + * PUT /identify + */ + async _putIdentify() { + if (!this.url) return; + + try { + const res = await fetchWithTimeout(`${this.url}/identify`, { + agent: this.agent, + method: 'PUT', + headers: { 'Content-Type': 'application/json' } + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + } catch (err) { + this._debugLog(`PUT /identify failed: ${err.message}`); + throw new Error('Network error during identify'); + } + } + + /** + * PUT /system cloud on/off + */ + async setCloudOn() { + if (!this.url) return; + + try { + const res = await fetchWithTimeout(`${this.url}/system`, { + agent: this.agent, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cloud_enabled: true }) + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + + } catch (err) { + this._debugLog(`Cloud ON failed: ${err.message}`); + throw new Error('Network error during setCloudOn'); + } + } + + async setCloudOff() { + if (!this.url) return; + + try { + const res = await fetchWithTimeout(`${this.url}/system`, { + agent: this.agent, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cloud_enabled: false }) + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + + } catch (err) { + this._debugLog(`Cloud OFF failed: ${err.message}`); + throw new Error('Network error during setCloudOff'); + } + } + + /** + * GET /data + GET /state + */ + async onPoll() { + if (this.__deleted) return; + + const settings = this.getSettings(); + + // URL restore when needed + if (!this.url) { + if (settings.url) { + this.url = settings.url; + } else { + this.setUnavailable('Missing URL').catch(this.error); + return; + } + } + + try { + + // ----------------------------- + // GET /data + // ----------------------------- + const res = await fetchWithTimeout(`${this.url}/data`, { + agent: this.agent, + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + + const data = await res.json(); + if (!data || typeof data !== 'object') throw new Error('Invalid JSON'); + + const offset = Number(this.getSetting('offset_socket')) || 0; + const watt = data.active_power_w + offset; + + const tasks = []; + const cap = (name, value) => { + if (value === undefined || value === null) return; + const cur = this.getCapabilityValue(name); + if (cur !== value) tasks.push(updateCapability(this, name, value)); + }; + + cap('measure_power', watt); + cap('meter_power.consumed.t1', data.total_power_import_t1_kwh); + cap('measure_power.l1', data.active_power_l1_w); + cap('rssi', data.wifi_strength); + + if (data.total_power_export_t1_kwh > 1) { + cap('meter_power.produced.t1', data.total_power_export_t1_kwh); + } + + const net = data.total_power_import_t1_kwh - data.total_power_export_t1_kwh; + cap('meter_power', net); + + cap('measure_voltage', data.active_voltage_v); + cap('measure_current', data.active_current_a); + + // ----------------------------- + // GET /state (max 1× per 30s) + // ----------------------------- + const now = Date.now(); + const mustPollState = + !this._lastStatePoll || + (now - this._lastStatePoll) > 30000; + + if (mustPollState) { + this._lastStatePoll = now; + + try { + const resState = await fetchWithTimeout(`${this.url}/state`, { + agent: this.agent, + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }); + + if (!resState.ok) throw new Error(`HTTP ${resState.status}: ${resState.statusText}`); + + const state = await resState.json(); + if (!state || typeof state !== 'object') throw new Error('Invalid JSON'); + + cap('onoff', state.power_on); + cap('dim', state.brightness / 255); + cap('locked', state.switch_lock); + + } catch (err) { + this._debugLog(`State poll failed: ${err.message}`); + cap('connection_error', err.message || 'State polling error'); + await updateCapability(this, 'alarm_connectivity', true); + } + } + + if (!this.__deleted && this.url !== settings.url) { + this.setSettings({ url: this.url }).catch(this.error); + } + + cap('connection_error', 'No errors'); + await updateCapability(this, 'alarm_connectivity', false); + + if (tasks.length > 0) await Promise.allSettled(tasks); + this.setAvailable().catch(this.error); + + } catch (err) { + if (!this.__deleted) { + this._debugLog(`Poll failed: ${err.message}`); + updateCapability(this, 'connection_error', err.message || 'Polling error') + .catch(this.error); + this.setUnavailable(err.message || 'Polling error') + .catch(this.error); + await updateCapability(this, 'alarm_connectivity', true); + } + } +} + + + /** + * Settings handler + */ + async onSettings(oldSettings, newSettings, changedKeys = []) { + + for (const key of changedKeys) { + + if (key === 'offset_socket') { + const cap = 'measure_power'; + const oldVal = Number(oldSettings[key]) || 0; + const newVal = Number(newSettings[key]) || 0; + const delta = newVal - oldVal; + + const current = this.getCapabilityValue(cap) || 0; + await this.setCapabilityValue(cap, current + delta).catch(this.error); + } + + if (key === 'offset_polling') { + if (this.onPollInterval) { + clearInterval(this.onPollInterval); + this.onPollInterval = null; + } + + const interval = Number(newSettings.offset_polling); + if (interval >= 2) { // Enforce minimum 2 second interval + this.onPollInterval = setInterval(this.onPoll.bind(this), interval * 1000); + } + } + + if (key === 'cloud') { + try { + if (newSettings.cloud == 1) await this.setCloudOn(); + else await this.setCloudOff(); + } catch (err) { + this.error('Failed to update cloud setting:', err); + } + } + } + } +}; diff --git a/drivers/energy_socket/driver.compose.json b/drivers/energy_socket/driver.compose.json new file mode 100644 index 00000000..3b48256b --- /dev/null +++ b/drivers/energy_socket/driver.compose.json @@ -0,0 +1,136 @@ +{ + "name": { + "en": "Energy Socket" + }, + "images": { + "large": "drivers/energy_socket/assets/images/large.png", + "small": "drivers/energy_socket/assets/images/small.png" + }, + "class": "socket", + "discovery": "energy_socket", + "platforms": [ + "local" + ], + "capabilities": [ + "onoff", + "dim", + "identify", + "locked", + "measure_power", + "meter_power", + "meter_power.consumed.t1", + "meter_power.produced.t1", + "measure_power.l1", + "rssi", + "connection_error", + "alarm_connectivity" + ], + "energy": { + "meterPowerImportedCapability": "meter_power.consumed.t1", + "meterPowerExportedCapability": "meter_power.produced.t1" + }, + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + } + }, + "measure_voltage.l1": { + "title": { + "en": "Current Voltage phase 1", + "nl": "Huidig Voltage fase 1" + }, + "insights": true + } + }, + "settings": [ + { + "type": "group", + "label": { + "en": "Energy Socket settings", + "nl": "Energy Socket instellingen" + }, + "children": [ + { + "id": "offset_socket", + "type": "number", + "label": { + "en": "Offset Watt usage", + "nl": "Compensatie watt gebruik" + }, + "value": 0, + "unit": { + "en": "watt", + "nl": "watt" + } + }, + { + "id": "offset_polling", + "type": "number", + "label": { + "en": "Polling in seconds", + "nl": "Interval in seconden" + }, + "value": 10, + "min": 1 + }, + { + "id": "cloud", + "type": "number", + "label": { "en": "Cloud connection 1=on 0=off", + "nl": "Cloud verbinding 1=actief 0=uit" + }, + "value": 1 + } + ] + } + ], + "pair": [ + { + "id": "start", + "navigation": { + "next": "list_devices" + } + }, + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "add_devices" + } + }, + { + "id": "add_devices", + "template": "add_devices" + } + ] +} diff --git a/drivers/energy_socket/driver.js b/drivers/energy_socket/driver.js new file mode 100644 index 00000000..a1a0dc29 --- /dev/null +++ b/drivers/energy_socket/driver.js @@ -0,0 +1,5 @@ +'use strict'; + +const driver = require('../../includes/v1/driver.js'); + +module.exports = driver; diff --git a/drivers/energy_socket/pair/start.html b/drivers/energy_socket/pair/start.html new file mode 100644 index 00000000..daae7b43 --- /dev/null +++ b/drivers/energy_socket/pair/start.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/drivers/energy_v2/assets/battery.svg b/drivers/energy_v2/assets/battery.svg new file mode 100644 index 00000000..202e8ba5 --- /dev/null +++ b/drivers/energy_v2/assets/battery.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/drivers/energy_v2/assets/icon.svg b/drivers/energy_v2/assets/icon.svg new file mode 100644 index 00000000..e1bc3269 --- /dev/null +++ b/drivers/energy_v2/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drivers/energy_v2/assets/images/large.png b/drivers/energy_v2/assets/images/large.png new file mode 100644 index 00000000..6358f0b7 Binary files /dev/null and b/drivers/energy_v2/assets/images/large.png differ diff --git a/drivers/energy_v2/assets/images/small.png b/drivers/energy_v2/assets/images/small.png new file mode 100644 index 00000000..721d7b06 Binary files /dev/null and b/drivers/energy_v2/assets/images/small.png differ diff --git a/drivers/energy_v2/assets/rssi.svg b/drivers/energy_v2/assets/rssi.svg new file mode 100644 index 00000000..e98392f6 --- /dev/null +++ b/drivers/energy_v2/assets/rssi.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/drivers/energy_v2/device.js b/drivers/energy_v2/device.js new file mode 100644 index 00000000..e120df44 --- /dev/null +++ b/drivers/energy_v2/device.js @@ -0,0 +1,2123 @@ +/* + * HomeWizard Energy (P1) Driver - APIv2 + * Copyright (C) 2025 Jeroen Tebbens + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +'use strict'; + +const Homey = require('homey'); +const fetch = require('node-fetch'); +const https = require('https'); +const api = require('../../includes/v2/Api'); +const WebSocketManager = require('../../includes/v2/Ws'); +const wsDebug = require('../../includes/v2/wsDebug'); +const debug = false; + +process.on('uncaughtException', (err) => { + console.error('💥 Uncaught Exception:', err); +}); + +process.on('unhandledRejection', (reason, promise) => { + console.error('💥 Unhandled Rejection at:', promise, 'reason:', reason); +}); + +// Create an agent that skips TLS verification +const agent = new https.Agent({ + rejectUnauthorized: false +}); + + + +/** + * Helper function to add, remove or update a capability + * @async + * @param {Homey.Device} device The device instance + * @param {string} capability The capability identifier + * @param {any} value The value to set + * @returns {Promise} + */ +async function updateCapability(device, capability, value) { + try { + const current = device.getCapabilityValue(capability); + + // --- SPECIAL CASE: battery_group_charge_mode --- + // This capability is managed exclusively by _updateBatteryGroup(). + if (capability === 'battery_group_charge_mode') { + // Only update value, never add/remove + if (value != null && current !== value) { + await device.setCapabilityValue(capability, value); + } + return; + } + + // --- SAFE REMOVE --- + if (value == null && current == null) { + if (device.hasCapability(capability)) { + await device.removeCapability(capability); + device.log(`🗑️ Removed capability "${capability}"`); + } + return; + } + + // --- ADD IF MISSING --- + if (!device.hasCapability(capability)) { + try { + await device.addCapability(capability); + device.log(`➕ Added capability "${capability}"`); + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + device.log(`Capability already exists: ${capability} — ignoring`); + } else { + throw err; + } + } + } + + // --- UPDATE --- + if (current !== value) { + await device.setCapabilityValue(capability, value); + } + + } catch (err) { + if (err.message === 'device_not_found') { + device.log(`⚠️ Skipping capability "${capability}" — device not found`); + return; + } + device.error(`❌ Failed updateCapability("${capability}")`, err); + } +} + + +/** + * Safe add capability helper — avoids race 409 errors + */ +async function safeAddCapability(device, capability) { + try { + if (!device.hasCapability(capability)) { + await device.addCapability(capability); + device.log(`➕ Safely added capability "${capability}"`); + } + } catch (err) { + if (err && (err.code === 409 || err.statusCode === 409 || (err.message && err.message.includes('capability_already_exists')))) { + device.log(`Capability already exists: ${capability} — ignoring`); + return; + } + throw err; + } +} + + + +async function setStoreValueSafe(device, key, value) { + try { + return await device.setStoreValue(key, value); + } catch (err) { + if (err.message === 'device_not_found') { + device.log(`⚠️ Skipping setStoreValue("${key}") — device not found`); + return null; + } + device.error(`❌ Failed setStoreValue("${key}")`, err); + return null; + } +} + +async function getStoreValueSafe(device, key) { + try { + return await device.getStoreValue(key); + } catch (err) { + if (err.message === 'device_not_found') { + device.log(`⚠️ Skipping getStoreValue("${key}") — device not found`); + return null; + } + device.error(`❌ Failed getStoreValue("${key}")`, err); + return null; + } +} + + +async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { + ...options, + signal: controller.signal + }); + return res; + } catch (err) { + if (err.name === 'AbortError') { + throw new Error('TIMEOUT'); + } + throw err; + } finally { + clearTimeout(timer); + } +} + + + +/** + * Helper function to determine WiFi quality + * @param {number} strength The WiFi signal strength + * @returns {string} The quality level ('poor', 'fair', 'good') + */ +function getWifiQuality(strength) { + if (strength >= -30) return 'Excellent'; // Strongest signal + if (strength >= -60) return 'Strong'; // Strong + if (strength >= -70) return 'Moderate'; // Good to Fair + if (strength >= -80) return 'Weak'; // Fair to Weak + if (strength >= -90) return 'Poor'; // Weak to Unusable + return 'Unusable'; // Very poor signal +} + +async function applyMeasurementCapabilities(device, m) { + try { + const mappings = { + // Generic + 'measure_power': m.power_w, + 'measure_voltage': m.voltage_v, + 'measure_current': m.current_a, + 'meter_power.consumed': m.energy_import_kwh, + 'meter_power.returned': m.energy_export_kwh, + 'tariff': m.tariff, + 'measure_frequency': m.frequency_hz, + + // Per phase + 'measure_power.l1': m.power_l1_w, + 'measure_power.l2': m.power_l2_w, + 'measure_power.l3': m.power_l3_w, + 'measure_voltage.l1': m.voltage_l1_v, + 'measure_voltage.l2': m.voltage_l2_v, + 'measure_voltage.l3': m.voltage_l3_v, + 'measure_current.l1': m.current_l1_a, + 'measure_current.l2': m.current_l2_a, + 'measure_current.l3': m.current_l3_a, + + // Tariff totals + 'meter_power.consumed.t1': m.energy_import_t1_kwh, + 'meter_power.produced.t1': m.energy_export_t1_kwh, + 'meter_power.consumed.t2': m.energy_import_t2_kwh, + 'meter_power.produced.t2': m.energy_export_t2_kwh, + 'meter_power.consumed.t3': m.energy_import_t3_kwh, + 'meter_power.produced.t3': m.energy_export_t3_kwh, + 'meter_power.consumed.t4': m.energy_import_t4_kwh, + 'meter_power.produced.t4': m.energy_export_t4_kwh, + + // Net quality + 'long_power_fail_count': m.long_power_fail_count, + 'voltage_sag_l1': m.voltage_sag_l1_count, + 'voltage_sag_l2': m.voltage_sag_l2_count, + 'voltage_sag_l3': m.voltage_sag_l3_count, + 'voltage_swell_l1': m.voltage_swell_l1_count, + 'voltage_swell_l2': m.voltage_swell_l2_count, + 'voltage_swell_l3': m.voltage_swell_l3_count, + + // Belgium + 'measure_power.montly_power_peak': m.monthly_power_peak_w, + 'measure_power.average_power_15m_w': m.average_power_15m_w, + }; + + // Collect all capability updates as promises + const tasks = []; + + for (const [capability, valueRaw] of Object.entries(mappings)) { + let value = valueRaw; + + // Normalize tariff (critical for triggers) + if (capability === 'tariff' && value != null) { + value = Number(value); + } + + const cur = device.getCapabilityValue(capability); + if (cur !== value) { + tasks.push(updateCapability(device, capability, value ?? null)); + } + } + + + // Run all updates in parallel + await Promise.allSettled(tasks); + + } catch (error) { + device.error('Failed to apply measurement capabilities:', error); + throw error; + } +} + + + + +/** + * Normalize battery mode from raw payload + * @param {Object} data - battery payload { mode, permissions } + * @returns {string} normalized mode string + */ +function normalizeBatteryMode(data) { + // If already normalized (string), return as-is + if (typeof data === 'string') { + return data.trim(); + } + + // Extract mode + let rawMode = typeof data.mode === 'string' + ? data.mode.trim().replace(/^["']+|["']+$/g, '') + : null; + + const mode = rawMode ? rawMode.toLowerCase() : null; + + // Extract permissions (sorted for deterministic comparison) + const perms = Array.isArray(data.permissions) + ? [...data.permissions].map(p => p.toLowerCase()).sort().join(',') + : null; + + // Direct modes + if (mode === 'standby') return 'standby'; + if (mode === 'to_full') return 'to_full'; + + // Vendor sometimes sends these directly + if (mode === 'zero_charge_only') return 'zero_charge_only'; + if (mode === 'zero_discharge_only') return 'zero_discharge_only'; + + // Normalize "zero" family + if (mode === 'zero') { + switch (perms) { + case 'charge_allowed,discharge_allowed': + return 'zero'; + case 'charge_allowed': + return 'zero_charge_only'; + case 'discharge_allowed': + return 'zero_discharge_only'; + case '': + case null: + return 'zero'; + default: + console.log(`⚠️ Unknown permissions for mode=zero: ${perms}`); + return 'zero'; + } + } + + // Unknown combination + console.log(`⚠️ Unknown battery mode: ${JSON.stringify(data)}`); + return 'standby'; +} + + + + + + + + + + + +module.exports = class HomeWizardEnergyDeviceV2 extends Homey.Device { + +_hashExternal(external) { + if (!Array.isArray(external) || external.length === 0) return 'none'; + + // Only hash if there's actually data to process + let hash = ''; + for (let i = 0; i < external.length; i++) { + const e = external[i]; + const type = e?.type ?? 'unknown'; + const value = e?.value ?? 'null'; + const ts = e?.timestamp ?? 'null'; + hash += (hash ? '|' : '') + `${type}:${value}:${ts}`; + } + return hash; +} + + + async onInit() { + wsDebug.init(this.homey); + this.onPollInterval = null; + this.gridReturnStart = null; + this.batteryErrorTriggered = false; + this._lastFullUpdate = 0; + this._lastDiscoveryIP = null; + + this._cache = { + external_last_payload: null, + external_last_result: null, + meter_start_day: null, + gasmeter_start_day: null, + last_gas_delta_minute: null, + gasmeter_previous_reading: null, + gasmeter_previous_reading_timestamp: null, + last_battery_state: null, + }; + + this._cacheDirty = false; + + // Load store values once + for (const key of Object.keys(this._cache)) { + this._cache[key] = await getStoreValueSafe(this, key); + } + + + + // await this.setUnavailable(`${this.getName()} ${this.homey.__('device.init')}`); + + await updateCapability(this, 'connection_error', 'No errors').catch(this.error); + + this.token = await getStoreValueSafe(this, 'token'); + //console.log('P1 Token:', this.token); + + await this._updateCapabilities(); + await this._registerCapabilityListeners(); + await this._ensureBatteryCapabilities(); + + + const settings = this.getSettings(); + this.log('Settings for P1 apiv2: ', settings.polling_interval); + + // Check if polling interval is set in settings else set default value + if (settings.polling_interval === undefined) { + settings.polling_interval = 10; // Default to 10 second if not set + await this.setSettings({ + // Update settings in Homey + polling_interval: 10, + }); + } + + if (settings.cloud === undefined) { + settings.cloud = 1; // Default true + await this.setSettings({ + // Update settings in Homey + cloud: 1, + }); + } + + + + // Store flow listener references for cleanup in onDeleted() + this._flowListenerReferences = []; + + // Register flow card listeners only once (prevent "already registered" warnings) + if (!this.homey.app._flowListenersRegistered) { + this.homey.app._flowListenersRegistered = true; + + // Condition Card + const ConditionCardCheckBatteryMode = this.homey.flow.getConditionCard('check-battery-mode'); + + const conditionListener = ConditionCardCheckBatteryMode.registerRunListener(async ({ device, mode }) => { + if (!device) return false; // ✅ Prevents crashes + + device.log('ConditionCard: Check Battery Mode'); + + try { + // ✅ Prefer WebSocket cache + const { wsManager, url, token } = device; + + if (wsManager?.isConnected()) { + // const lastBatteryState = await device.getStoreValue('last_battery_state'); + const lastBatteryState = device._cacheGet('last_battery_state'); + + if (lastBatteryState) { + const normalized = normalizeBatteryMode(lastBatteryState); + return mode === normalized; + } + } + + // Fallback: HTTP + const response = await api.getMode(url, token); + if (!response || typeof response !== 'object') { + device.log('⚠️ Invalid battery mode response:', response); + return false; + } + + // Update cache so condition cards see the correct mode + device._cacheSet('last_battery_state', { + mode: response.mode, + permissions: response.permissions, + battery_count: response.battery_count ?? 1 + }); + + // Normalize + const normalized = normalizeBatteryMode(response); + + // Update capability + await updateCapability(device, 'battery_group_charge_mode', normalized); + + // Trigger flow on change + if (normalized !== device._cacheGet('last_battery_mode')) { + device.flowTriggerBatteryMode(device); + device._cacheSet('last_battery_mode', normalized); + } + + return mode === normalized; + + + + } catch (error) { + device?.error('Error retrieving mode:', error); + return false; + } +}); + + + + + + this.homey.flow + .getActionCard('set-battery-to-zero-mode') + .registerRunListener(async ({ device }) => { + device.log('ActionCard: Set Battery to Zero Mode'); + + try { + const { wsManager, url, token } = device; + + // --- Prefer WebSocket --- + if (wsManager?.isConnected()) { + wsManager.setBatteryMode('zero'); + device.log('Set mode to zero via WebSocket'); + return 'zero'; + } + + // --- HTTP fallback: set mode --- + const response = await api.setMode(url, token, 'zero'); + if (!response) { + device.log('Invalid response from setMode()'); + return false; + } + + // --- Fetch real battery state after setting mode --- + const modeResponse = await api.getMode(url, token); + if (!modeResponse || typeof modeResponse !== 'object') { + device.log('⚠️ Invalid battery mode response after setMode:', modeResponse); + return false; + } + + // --- Update cache --- + device._cacheSet('last_battery_state', { + mode: modeResponse.mode, + permissions: modeResponse.permissions, + battery_count: modeResponse.battery_count ?? 1 + }); + + // --- Normalize --- + const normalized = normalizeBatteryMode(modeResponse); + + // --- Update capability --- + await updateCapability(device, 'battery_group_charge_mode', normalized); + + // --- Trigger flow on change --- + if (normalized !== device._cacheGet('last_battery_mode')) { + device.flowTriggerBatteryMode(device); + device._cacheSet('last_battery_mode', normalized); + } + + device.log('Set mode to zero via HTTP'); + return 'zero'; + + } catch (error) { + device.error('Error set mode to zero:', error); + return false; + } + }); + + + + +this.homey.flow + .getActionCard('set-battery-to-standby-mode') + .registerRunListener(async ({ device }) => { + device.log('ActionCard: Set Battery to Standby Mode'); + + try { + const { wsManager, url, token } = device; + + // --- Prefer WebSocket --- + if (wsManager?.isConnected()) { + wsManager.setBatteryMode('standby'); + device.log('Set mode to standby via WebSocket'); + return 'standby'; + } + + // --- HTTP fallback: set mode --- + const response = await api.setMode(url, token, 'standby'); + if (!response) return false; + + // --- Fetch real battery state --- + const modeResponse = await api.getMode(url, token); + if (!modeResponse || typeof modeResponse !== 'object') return false; + + // --- Update cache --- + device._cacheSet('last_battery_state', { + mode: modeResponse.mode, + permissions: modeResponse.permissions, + battery_count: modeResponse.battery_count ?? 1 + }); + + // --- Normalize --- + const normalized = normalizeBatteryMode(modeResponse); + + // --- Update capability --- + await updateCapability(device, 'battery_group_charge_mode', normalized); + + // --- Trigger flow on change --- + if (normalized !== device._cacheGet('last_battery_mode')) { + device.flowTriggerBatteryMode(device); + device._cacheSet('last_battery_mode', normalized); + } + + device.log('Set mode to standby via HTTP'); + return 'standby'; + + } catch (error) { + device.error('Error set mode to standby:', error); + return false; + } + }); + + + + +this.homey.flow + .getActionCard('set-battery-to-full-charge-mode') + .registerRunListener(async ({ device }) => { + device.log('ActionCard: Set Battery to Full Charge Mode'); + + try { + const { wsManager, url, token } = device; + + // --- Prefer WebSocket --- + if (wsManager?.isConnected()) { + wsManager.setBatteryMode('to_full'); + device.log('Set mode to full charge via WebSocket'); + return 'to_full'; + } + + // --- HTTP fallback --- + const response = await api.setMode(url, token, 'to_full'); + if (!response) return false; + + // --- Fetch real battery state --- + const modeResponse = await api.getMode(url, token); + if (!modeResponse || typeof modeResponse !== 'object') return false; + + // --- Update cache --- + device._cacheSet('last_battery_state', { + mode: modeResponse.mode, + permissions: modeResponse.permissions, + battery_count: modeResponse.battery_count ?? 1 + }); + + // --- Normalize --- + const normalized = normalizeBatteryMode(modeResponse); + + // --- Update capability --- + await updateCapability(device, 'battery_group_charge_mode', normalized); + + // --- Trigger flow on change --- + if (normalized !== device._cacheGet('last_battery_mode')) { + device.flowTriggerBatteryMode(device); + device._cacheSet('last_battery_mode', normalized); + } + + device.log('Set mode to full charge via HTTP'); + return 'to_full'; + + } catch (error) { + device.error('Error set mode to full charge:', error); + return false; + } + }); + + + + + this.homey.flow + .getActionCard('set-battery-to-zero-charge-only-mode') + .registerRunListener(async ({ device }) => { + device.log('ActionCard: Set Battery to Zero Charge Only Mode'); + + try { + const { wsManager, url, token } = device; + + // --- Prefer WebSocket --- + if (wsManager?.isConnected()) { + wsManager.setBatteryMode('zero_charge_only'); + device.log('Set mode to zero_charge_only via WebSocket'); + return 'zero_charge_only'; + } + + // --- HTTP fallback --- + const response = await api.setMode(url, token, 'zero_charge_only'); + if (!response) return false; + + // --- Fetch real battery state --- + const modeResponse = await api.getMode(url, token); + if (!modeResponse || typeof modeResponse !== 'object') return false; + + // --- Update cache --- + device._cacheSet('last_battery_state', { + mode: modeResponse.mode, + permissions: modeResponse.permissions, + battery_count: modeResponse.battery_count ?? 1 + }); + + // --- Normalize --- + const normalized = normalizeBatteryMode(modeResponse); + + // --- Update capability --- + await updateCapability(device, 'battery_group_charge_mode', normalized); + + // --- Trigger flow on change --- + if (normalized !== device._cacheGet('last_battery_mode')) { + device.flowTriggerBatteryMode(device); + device._cacheSet('last_battery_mode', normalized); + } + + device.log('Set mode to zero_charge_only via HTTP'); + return 'zero_charge_only'; + + } catch (error) { + device.error('Error set mode to zero_charge_only:', error); + return false; + } + }); + + + + +this.homey.flow + .getActionCard('set-battery-to-zero-discharge-only-mode') + .registerRunListener(async ({ device }) => { + device.log('ActionCard: Set Battery to Zero Discharge Only Mode'); + + try { + const { wsManager, url, token } = device; + + // --- Prefer WebSocket --- + if (wsManager?.isConnected()) { + wsManager.setBatteryMode('zero_discharge_only'); + device.log('Set mode to zero_discharge_only via WebSocket'); + return 'zero_discharge_only'; + } + + // --- HTTP fallback --- + const response = await api.setMode(url, token, 'zero_discharge_only'); + if (!response) return false; + + // --- Fetch real battery state --- + const modeResponse = await api.getMode(url, token); + if (!modeResponse || typeof modeResponse !== 'object') return false; + + // --- Update cache --- + device._cacheSet('last_battery_state', { + mode: modeResponse.mode, + permissions: modeResponse.permissions, + battery_count: modeResponse.battery_count ?? 1 + }); + + // --- Normalize --- + const normalized = normalizeBatteryMode(modeResponse); + + // --- Update capability --- + await updateCapability(device, 'battery_group_charge_mode', normalized); + + // --- Trigger flow on change --- + if (normalized !== device._cacheGet('last_battery_mode')) { + device.flowTriggerBatteryMode(device); + device._cacheSet('last_battery_mode', normalized); + } + + device.log('Set mode to zero_discharge_only via HTTP'); + return 'zero_discharge_only'; + + } catch (error) { + device.error('Error set mode to zero_discharge_only:', error); + return false; + } + }); + + + + + + + + } // End of _flowListenersRegistered guard + + // this.flowTriggerBatteryMode + + this._flowTriggerBatteryMode = this.homey.flow.getDeviceTriggerCard('battery_mode_changed'); + this._flowTriggerTariff = this.homey.flow.getDeviceTriggerCard('tariff_changed_v2'); + this._flowTriggerImport = this.homey.flow.getDeviceTriggerCard('import_changed_v2'); + this._flowTriggerExport = this.homey.flow.getDeviceTriggerCard('export_changed_v2'); + + + + this._triggerFlowPrevious = {}; + + // Bind handler functions ONCE to avoid creating new function objects on every reconnect (memory leak) + this._boundHandleMeasurement = this._handleMeasurement.bind(this); + this._boundHandleSystem = this._handleSystem.bind(this); + this._boundHandleBatteries = this._handleBatteries.bind(this); + this._boundLog = this.log.bind(this); + this._boundError = this.error.bind(this); + this._boundSetAvailable = this.setAvailable.bind(this); + this._boundGetSetting = this.getSetting.bind(this); + + // this.onPollInterval = setInterval(this.onPoll.bind(this), 1000 * settings.polling_interval); + + this.pollingEnabled = !!settings.use_polling; + + if (this.pollingEnabled) { + this.log('⚙️ Polling enabled via settings'); + this.startPolling(); + } else { + this.wsManager = new WebSocketManager({ + device: this, + url: this.url, + token: this.token, + log: this._boundLog, + error: this._boundError, + setAvailable: this._boundSetAvailable, + getSetting: this._boundGetSetting, + handleMeasurement: this._boundHandleMeasurement, + handleSystem: this._boundHandleSystem, + handleBatteries: this._boundHandleBatteries + }); + + this.wsManager.start(); + } + + if (debug) this._debugInterval = setInterval(() => { + this.log( + 'CPU diag:', + 'ws=', this.wsManager?.isConnected(), + 'poll=', this.pollingEnabled, + 'batteryGroup=', this._phaseOverloadNotificationsEnabled, + 'external=', !!this._cache.external_last_payload, + 'lastWS=', Date.now() - (this.wsManager?.lastMeasurementAt || 0) + ); + }, 60000); // Reduced frequency: every 60s instead of 10s + + + // 🕒 Driver-side watchdog + this._wsWatchdog = setInterval(() => { + const staleMs = Date.now() - (this.wsManager?.lastMeasurementAt || 0); + if (!this.getSettings().use_polling && staleMs > 190000) { // just over 3min + this.log(`🕒 P1 watchdog: stale >3min (${staleMs}ms), restarting WS`); + this.wsManager?.restartWebSocket(); + } + }, 60000); // check every minute + + // Overload notification true/false + this._phaseOverloadNotificationsEnabled = this.getSetting('phase_overload_notifications') ?? true; + + this._phaseOverloadState = { + l1: { highCount: 0, notified: false }, + l2: { highCount: 0, notified: false }, + l3: { highCount: 0, notified: false } + }; + + this._cacheFlushInterval = setInterval(async () => { + if (!this._cacheDirty) return; + this._cacheDirty = false; + + // Batch all store operations in parallel instead of sequential awaits + const storePromises = Object.entries(this._cache).map( + ([key, value]) => setStoreValueSafe(this, key, value) + ); + await Promise.all(storePromises).catch(this.error); + }, 30000); + + this._batteryGroupInterval = setInterval(() => { + this._updateBatteryGroup().catch(this.error); + }, 10000); // elke 10 seconden + + this._dailyInterval = setInterval(() => { + this._updateDaily().catch(this.error); + }, 60000); // elke minuut + + + } + + _cacheGet(key) { + return this._cache[key]; + } + + _cacheSet(key, value) { + this._cache[key] = (value === undefined ? null : value); + this._cacheDirty = true; + } + + + + flowTriggerBatteryMode(device, tokens) { + this._flowTriggerBatteryMode.trigger(device, tokens).catch(this.error); + } + + + flowTriggerTariff(device, value) { + // this.log(`⚡ Triggering tariff change with value:`, value); + this._flowTriggerTariff.trigger(device, { tariff: value }).catch(this.error); + } + + flowTriggerImport(device, value) { + // this.log(`📥 Triggering import change with value:`, value); + this._flowTriggerImport.trigger(device, { import: value }).catch(this.error); + } + + flowTriggerExport(device, value) { + // this.log(`📤 Triggering export change with value:`, value); + this._flowTriggerExport.trigger(device, { export: value }).catch(this.error); + } + +_getRealtimePluginBatteryData() { + const driver = this.homey.drivers.getDriver('plugin_battery'); + if (!driver) return []; + + const devices = driver.getDevices(); + const result = []; + + for (const dev of devices) { + const id = dev.getData()?.id; + if (!id) continue; + + // Explicit realtime values + const soc = (typeof dev._lastSoC === 'number') + ? dev._lastSoC // 0% is valid + : null; + + const power = (typeof dev._lastPower === 'number') + ? dev._lastPower + : null; + + const capacity = (typeof dev._lastCapacity === 'number' && dev._lastCapacity > 0) + ? dev._lastCapacity + : null; + + const cycles = (typeof dev._lastCycles === 'number') + ? dev._lastCycles + : null; + + result.push({ + id, + soc, + power, + capacity, + cycles, + }); + } + + return result; +} + + +_mergeBatterySources(realtime, group) { + const merged = []; + + for (const rt of realtime) { + const g = group[rt.id] || {}; + + // Explicit realtime vs fallback selection + const capacity = (typeof rt.capacity === 'number' && rt.capacity > 0) + ? rt.capacity + : (typeof g.capacity_kwh === 'number' && g.capacity_kwh > 0) + ? g.capacity_kwh + : 2.8; // default + + const soc = (typeof rt.soc === 'number') + ? rt.soc // realtime 0% is valid + : (typeof g.soc_pct === 'number') + ? g.soc_pct + : 0; + + const power = (typeof rt.power === 'number') + ? rt.power + : (typeof g.power_w === 'number') + ? g.power_w + : 0; + + const cycles = (typeof rt.cycles === 'number') + ? rt.cycles + : (typeof g.cycles === 'number') + ? g.cycles + : 0; + + merged.push({ + id: rt.id, + capacity_kwh: capacity, + soc_pct: soc, + power_w: power, + cycles: cycles, + }); + } + + return merged; +} + + + + +async _updateBatteryGroup() { + const dataObj = this.getData(); + if (!dataObj?.id) return; + + // 1. Realtime pluginBattery data + const realtime = this._getRealtimePluginBatteryData(); + + // 2. Fallback batteryGroup data (cached) + const cachedGroup = this._cacheGet('pluginBatteryGroup_cache'); + const group = cachedGroup || (this.homey.settings.get('pluginBatteryGroup') || {}); + + // Refresh cache every 60s + if (!this._lastBatteryGroupCacheUpdate || Date.now() - this._lastBatteryGroupCacheUpdate > 60000) { + this._cacheSet('pluginBatteryGroup_cache', group); + this._lastBatteryGroupCacheUpdate = Date.now(); + } + + // 3. Merge both sources + const batteries = this._mergeBatterySources(realtime, group); + + const realtimeCount = realtime.length; + const fallbackCount = Object.keys(group).length; + + // 4. Vendor battery_count gate (soft) + const vendorCount = this._cacheGet('last_battery_state')?.battery_count; + + // --- Only remove capabilities if ALL sources agree there is no battery --- + if (vendorCount === 0 && fallbackCount === 0) { + if (debug) this.log('🔋 No battery detected — removing battery capabilities'); + + const caps = [ + 'measure_power.battery_group_power_w', + 'measure_power.battery_group_target_power_w', + 'measure_power.battery_group_max_consumption_w', + 'measure_power.battery_group_max_production_w', + 'battery_group_total_capacity_kwh', + 'battery_group_average_soc', + 'battery_group_state', + 'battery_group_charge_mode' + ]; + + for (const cap of caps) { + if (this.hasCapability(cap)) { + this.removeCapability(cap).catch(this.error); + } + } + + return; + } + + // --- If we have ANY batteries, continue --- + if (batteries.length === 0) return; + + // 5. Weighted SoC calculation + let totalCapacity = 0; + let weightedSoC = 0; + let totalPower = 0; + + for (const b of batteries) { + const cap = (typeof b.capacity_kwh === 'number' && b.capacity_kwh > 0) + ? b.capacity_kwh + : 1; + + const soc = (typeof b.soc_pct === 'number') + ? b.soc_pct + : 0; + + const power = (typeof b.power_w === 'number') + ? b.power_w + : 0; + + totalCapacity += cap; + weightedSoC += cap * soc; + totalPower += power; + } + + const averageSoC = totalCapacity > 0 + ? Math.round(weightedSoC / totalCapacity) + : 0; + + const chargeState = + totalPower > 20 ? 'charging' : + totalPower < -20 ? 'discharging' : + 'idle'; + + // 6. Update capabilities + await Promise.allSettled([ + updateCapability(this, 'battery_group_total_capacity_kwh', totalCapacity), + updateCapability(this, 'battery_group_average_soc', averageSoC), + updateCapability(this, 'battery_group_state', chargeState), + ]); + + // 7. Vendor-native charge mode update + const lastVendorState = this._cacheGet('last_battery_state'); + + if (lastVendorState && typeof lastVendorState === 'object') { + const normalized = normalizeBatteryMode(lastVendorState); + + // Alleen updaten als de waarde echt veranderd is + const prev = this.getCapabilityValue('battery_group_charge_mode'); + if (prev !== normalized) { + await updateCapability(this, 'battery_group_charge_mode', normalized); + + // Cache bijwerken + this._cacheSet('last_battery_mode', normalized); + + // Flow triggeren + this.flowTriggerBatteryMode(this, { mode: normalized }); + + if (debug) this.log(`🔋 Updated battery_group_charge_mode → ${normalized}`); + } + } +} + + + + + + +_processBatteryGroupChargeMode(data, tasks) { + const group = data.battery_group; + if (!group || !group.charge_mode) return; + + const mode = this.normalizeBatteryMode(group.charge_mode); + + if (this._hasChanged('battery_group_charge_mode', mode)) { + tasks.push(updateCapability(this, 'battery_group_charge_mode', mode)); + } +} + + + +async _updateDaily() { + if (!this._validateMeasurementContext()) return; + + const showGas = this.getSetting('show_gas') === true; + const m = this._cacheGet('last_measurement'); + if (!m) return; + + const nowLocal = this._getLocalTimeSafe(); + const hour = nowLocal.getHours(); + const minute = nowLocal.getMinutes(); + + this._dailyMidnightReset(m, showGas, hour, minute); + await this._dailyElectricity(m); + await this._dailyGas(m, showGas); + await this._dailyGasDelta(showGas, minute); +} + +_getLocalTimeSafe() { + const tz = 'Europe/Brussels'; + const iso = new Date().toLocaleString('sv-SE', { timeZone: tz }); + return new Date(iso); +} + +_dailyMidnightReset(m, showGas, hour, minute) { + if (hour === 0 && minute === 0) { + if (typeof m.energy_import_kwh === 'number') { + this._cacheSet('meter_start_day', m.energy_import_kwh); + } + + const lastExternal = this._cacheGet('external_last_result'); + const gas = lastExternal?.gas; + + if (showGas && typeof gas?.value === 'number') { + this._cacheSet('gasmeter_start_day', gas.value); + } + } +} + +async _dailyElectricity(m) { + const meterStart = this._cacheGet('meter_start_day'); + if (meterStart != null && typeof m.energy_import_kwh === 'number') { + const dailyImport = m.energy_import_kwh - meterStart; + const cur = this.getCapabilityValue('meter_power.daily'); + if (cur !== dailyImport) { + await updateCapability(this, 'meter_power.daily', dailyImport).catch(this.error); + } + } +} + +async _dailyGas(m, showGas) { + if (!showGas) return; + + const lastExternal = this._cacheGet('external_last_result'); + const gas = lastExternal?.gas; + const gasStart = this._cacheGet('gasmeter_start_day'); + + if (gas?.value != null && gasStart != null) { + const gasDiff = gas.value - gasStart; + const cur = this.getCapabilityValue('meter_gas.daily'); + if (cur !== gasDiff) { + await updateCapability(this, 'meter_gas.daily', gasDiff).catch(this.error); + } + } +} + +async _dailyGasDelta(showGas, minute) { + if (!showGas || minute % 5 !== 0) return; + + const lastMinute = this._cacheGet('last_gas_delta_minute'); + if (lastMinute === minute) return; + + this._cacheSet('last_gas_delta_minute', minute); + + const lastExternal = this._cacheGet('external_last_result'); + const gas = lastExternal?.gas; + if (!gas || typeof gas.value !== 'number') return; + + const prevTimestamp = this._cacheGet('gasmeter_previous_reading_timestamp'); + + if (prevTimestamp == null) { + this._cacheSet('gasmeter_previous_reading_timestamp', gas.timestamp); + return; + } + + if (gas.timestamp === prevTimestamp) return; + + const prevReading = this._cacheGet('gasmeter_previous_reading'); + + if (typeof prevReading === 'number') { + const delta = gas.value - prevReading; + if (delta >= 0) { + const cur = this.getCapabilityValue('measure_gas'); + if (cur !== delta) { + await updateCapability(this, 'measure_gas', delta).catch(this.error); + } + } + } + + this._cacheSet('gasmeter_previous_reading', gas.value); + this._cacheSet('gasmeter_previous_reading_timestamp', gas.timestamp); +} + + +async _handleExternalMeters(external) { + const tasks = []; + + // Single pass through external meters - extract latest for each type + const latest = {}; + let gasExists = false; + let waterExists = false; + + for (const meter of (external ?? [])) { + if (meter.type === 'gas_meter') { + gasExists = true; + if (meter.value != null && meter.timestamp != null) { + const current = latest['gas_meter']; + if (!current || meter.timestamp > current.timestamp) { + latest['gas_meter'] = meter; + } + } + } else if (meter.type === 'water_meter') { + waterExists = true; + if (meter.value != null && meter.timestamp != null) { + const current = latest['water_meter']; + if (!current || meter.timestamp > current.timestamp) { + latest['water_meter'] = meter; + } + } + } + } + + const gas = latest['gas_meter']; + const water = latest['water_meter']; + + // GAS CAPABILITY MANAGEMENT (structural) + if (gasExists && !this.hasCapability('meter_gas')) { + tasks.push(safeAddCapability(this, 'meter_gas').catch(this.error)); + } + if (!gasExists && this.hasCapability('meter_gas')) { + tasks.push(this.removeCapability('meter_gas').catch(this.error)); + this.log('Removed meter_gas — no gas meter found.'); + } + + // GAS VALUE UPDATE (data) + if (gasExists && gas && this.getCapabilityValue('meter_gas') !== gas.value) { + tasks.push(this.setCapabilityValue('meter_gas', gas.value).catch(this.error)); + } + + // WATER CAPABILITY MANAGEMENT (structural) + if (waterExists && !this.hasCapability('meter_water')) { + tasks.push(safeAddCapability(this, 'meter_water').catch(this.error)); + } + if (!waterExists && this.hasCapability('meter_water')) { + tasks.push(this.removeCapability('meter_water').catch(this.error)); + this.log('Removed meter_water — no water meter found.'); + } + + // WATER VALUE UPDATE (data) + if (waterExists && water && this.getCapabilityValue('meter_water') !== water.value) { + tasks.push(this.setCapabilityValue('meter_water', water.value).catch(this.error)); + } + + await Promise.all(tasks); + + return { gas, water }; +} + +async _handleMeasurement(m) { + if (!this._validateMeasurementContext()) return; + + const now = Date.now(); + const settings = this.getSettings(); + const showGas = settings.show_gas === true; + const homeyLang = this.homey.i18n.getLanguage(); + + this._measurementCache(m, now); + const tasks = []; + + this._measurementPower(m, tasks); + this._measurementPhases(m, tasks, settings, homeyLang); + this._measurementFullRefresh(m, tasks, now); + this._measurementFlows(m, now); + this._measurementNetPower(m, tasks); + + const { gas, water } = await this._measurementExternalMeters(m, tasks); + await this._measurementGasWater(gas, water, tasks, showGas); + + if (tasks.length > 0) { + await Promise.allSettled(tasks); + } +} + +_validateMeasurementContext() { + const dataObj = this.getData(); + if (!dataObj || !dataObj.id) { + this.log('⚠️ Ignoring measurement: device no longer exists'); + return false; + } + return true; +} + +_measurementCache(m, now) { + this._cacheSet('last_measurement', m); + this.lastMeasurementAt = now; +} + +_measurementPower(m, tasks) { + const cap = (name, value) => { + const cur = this.getCapabilityValue(name); + if (cur !== value) { + tasks.push(updateCapability(this, name, value).catch(this.error)); + } + }; + + const currentPower = this.getCapabilityValue('measure_power'); + if (currentPower !== m.power_w) { + cap('measure_power', m.power_w); + cap('measure_power.l1', m.power_l1_w); + cap('measure_power.l2', m.power_l2_w); + cap('measure_power.l3', m.power_l3_w); + } +} + +_measurementPhases(m, tasks, settings, homeyLang) { + const cap = (name, value) => { + const cur = this.getCapabilityValue(name); + if (cur !== value) { + tasks.push(updateCapability(this, name, value).catch(this.error)); + } + }; + + if (m.current_l1_a !== undefined) { + const load1 = Math.abs((m.current_l1_a / settings.grid_phase_amps) * 100); + cap('net_load_phase1_pct', load1); + this._handlePhaseOverload('l1', load1, homeyLang); + } + + if (m.current_l2_a !== undefined) { + const load2 = Math.abs((m.current_l2_a / settings.grid_phase_amps) * 100); + cap('net_load_phase2_pct', load2); + this._handlePhaseOverload('l2', load2, homeyLang); + } + + if (m.current_l3_a !== undefined) { + const load3 = Math.abs((m.current_l3_a / settings.grid_phase_amps) * 100); + cap('net_load_phase3_pct', load3); + this._handlePhaseOverload('l3', load3, homeyLang); + } +} + +_measurementFullRefresh(m, tasks, now) { + if (!this._lastFullUpdate || now - this._lastFullUpdate > 10000) { + tasks.push(applyMeasurementCapabilities(this, m).catch(this.error)); + this._lastFullUpdate = now; + } +} + +_measurementFlows(m, now) { + if (!this._lastFlowTrigger || now - this._lastFlowTrigger > 5000) { + + if (typeof m.energy_import_kwh === 'number' && + this._triggerFlowPrevious.import !== m.energy_import_kwh) { + this._triggerFlowPrevious.import = m.energy_import_kwh; + this.flowTriggerImport(this, m.energy_import_kwh); + } + + if (typeof m.energy_export_kwh === 'number' && + this._triggerFlowPrevious.export !== m.energy_export_kwh) { + this._triggerFlowPrevious.export = m.energy_export_kwh; + this.flowTriggerExport(this, m.energy_export_kwh); + } + + if (typeof m.tariff !== 'undefined') { + const newTariff = Number(m.tariff); + const prevTariff = this._triggerFlowPrevious.tariff; + + if (prevTariff !== newTariff) { + this._triggerFlowPrevious.tariff = newTariff; + this.flowTriggerTariff(this, newTariff); + } + } + + + this._lastFlowTrigger = now; + } +} + +_measurementNetPower(m, tasks) { + if (m.energy_import_kwh !== undefined && m.energy_export_kwh !== undefined) { + const net = m.energy_import_kwh - m.energy_export_kwh; + const cur = this.getCapabilityValue('meter_power'); + if (cur !== net) { + tasks.push(updateCapability(this, 'meter_power', net).catch(this.error)); + } + } +} + +async _measurementExternalMeters(m, tasks) { + let gas = null; + let water = null; + + const prevHash = this._cacheGet('external_last_hash') ?? null; + const newHash = this._hashExternal(m.external); + + if (prevHash === newHash) { + // Geen verandering → gebruik cache + const lastResult = this._cacheGet('external_last_result'); + gas = lastResult?.gas ?? null; + water = lastResult?.water ?? null; + } else { + // Verandering → opnieuw verwerken + const result = await this._handleExternalMeters(m.external); + gas = result.gas; + water = result.water; + + this._cacheSet('external_last_payload', m.external); + this._cacheSet('external_last_result', result); + this._cacheSet('external_last_hash', newHash); + } + + return { gas, water }; +} + + +async _measurementGasWater(gas, water, tasks, showGas) { + if (!showGas) { + if (this.hasCapability('meter_gas')) tasks.push(this.removeCapability('meter_gas').catch(this.error)); + if (this.hasCapability('measure_gas')) tasks.push(this.removeCapability('measure_gas').catch(this.error)); + if (this.hasCapability('meter_gas.daily')) tasks.push(this.removeCapability('meter_gas.daily').catch(this.error)); + return; + } + + // (No extra logic — everything happens in _handleExternalMeters) +} + + + + +_handleSystem(data) { + // this.log('⚙️ System data received:', data); + if (!this.getData() || !this.getData().id) { + this.log('⚠️ Ignoring system event: device no longer exists'); + return; + } + + // Update wifi rssi and wifi text + if (typeof data.wifi_rssi_db === 'number') { + if (this.hasCapability('rssi')) { + updateCapability(this, 'rssi', data.wifi_rssi_db).catch(this.error); + const wifiQuality = getWifiQuality(data.wifi_rssi_db); + updateCapability(this, 'wifi_quality', wifiQuality).catch(this.error); + } + + } + + +} + +async _ensureBatteryCapabilities() { + const caps = [ + 'measure_power.battery_group_power_w', + 'measure_power.battery_group_target_power_w', + 'measure_power.battery_group_max_consumption_w', + 'measure_power.battery_group_max_production_w', + 'battery_group_total_capacity_kwh', + 'battery_group_average_soc', + 'battery_group_state', + 'battery_group_charge_mode' + ]; + + for (const cap of caps) { + try { + await safeAddCapability(this, cap); + } catch (err) { + this.error(`❌ Failed to ensure capability "${cap}":`, err); + } + } +} + + +async _handleBatteries(data) { + try { + if (debug) this.log('⚡ Battery event data:', data); + + // --- Device existence guards --- + const dataObj = this.getData(); + if (!dataObj?.id) return; + + let deviceInstance; + try { + const driver = this.homey.drivers.getDriver('energy_v2'); + deviceInstance = driver?.getDevice(dataObj); + } catch (err) { + if (err.message?.includes('Could not get device')) return; + throw err; + } + if (!deviceInstance) return; + + // --- Normalize payload --- + const battery = Array.isArray(data) ? data[0] : data; + const payload = typeof battery === 'string' + ? { ...data, mode: battery, permissions: [] } + : battery; + + if (debug) this.log('⚡ Battery event payload:', payload); + + // --- IMPORTANT --- + // NEVER remove capabilities here. + // vendorCount is handled in _updateBatteryGroup(). + // WS payloads are unreliable (missing fields, mode-only, reconnects, etc.) + + + // --- Normalize mode --- + const normalizedMode = normalizeBatteryMode(payload); + const lastBatteryMode = this._cacheGet('last_battery_mode'); + + // --- Update capability battery_group_charge_mode --- + try { + await updateCapability(this, 'battery_group_charge_mode', normalizedMode); + } catch (err) { + this.error('❌ Failed to update battery_group_charge_mode:', err); + } + + // --- Trigger flow only on real mode change --- + if (normalizedMode !== lastBatteryMode) { + this.flowTriggerBatteryMode(this); + this._cacheSet('last_battery_mode', normalizedMode); + + try { + await this.setSettings({ mode: normalizedMode }); + } catch (err) { + this.error('❌ Failed to update setting "mode":', err); + } + } + + // --- Update battery power capabilities --- + await this._setCapabilityValue('measure_power.battery_group_power_w', payload.power_w ?? 0); + await this._setCapabilityValue('measure_power.battery_group_target_power_w', payload.target_power_w ?? 0); + await this._setCapabilityValue('measure_power.battery_group_max_consumption_w', payload.max_consumption_w ?? 0); + await this._setCapabilityValue('measure_power.battery_group_max_production_w', payload.max_production_w ?? 0); + + // --- Store raw WS battery state for condition cards --- + const prev = this._cacheGet('last_battery_state') || {}; + + // Only update fields that exist in the payload. + // Never overwrite vendorCount with undefined or incomplete WS frames. + this._cacheSet('last_battery_state', { + mode: payload.mode ?? prev.mode, + permissions: Array.isArray(payload.permissions) + ? payload.permissions + : prev.permissions, + battery_count: (typeof payload.battery_count === 'number') + ? payload.battery_count + : prev.battery_count + }); + + // --- Battery error detection --- + const group = this.homey.settings.get('pluginBatteryGroup') || {}; + const batteries = Object.values(group); + + const isGridReturn = (payload.power_w ?? 0) < -400; + const batteriesPresent = batteries.length > 0; + const shouldBeCharging = (payload.target_power_w ?? 0) > 0; + const isNotStandby = normalizedMode !== 'standby'; + + const now = Date.now(); + + if (isGridReturn && batteriesPresent && shouldBeCharging && isNotStandby) { + if (!this.gridReturnStart) this.gridReturnStart = now; + + const duration = now - this.gridReturnStart; + + if (duration > 30000 && !this.batteryErrorTriggered) { + this.batteryErrorTriggered = true; + + this.log('❌ Battery error: batteries should be charging and grid is receiving power'); + + this.homey.flow + .getDeviceTriggerCard('battery_error_detected') + .trigger(this, {}, { + power: payload.power_w, + target: payload.target_power_w, + mode: normalizedMode, + batteryCount: batteries.length + }) + .catch(this.error); + } + + } else { + this.gridReturnStart = null; + this.batteryErrorTriggered = false; + } + + } catch (err) { + this.error('❌ _handleBatteries failed:', err); + } +} + + + + + + + + startPolling() { + if (this.wsActive || this.onPollInterval) return; + + const interval = this.getSettings().polling_interval || 10; + this.log(`⏱️ Polling gestart met interval: ${interval}s`); + + this.onPollInterval = setInterval(this.onPoll.bind(this), 1000 * interval); + } + + + + onDeleted() { + if (this.onPollInterval) { + clearInterval(this.onPollInterval); + this.onPollInterval = null; + } + if (this._wsReconnectTimeout) { + clearTimeout(this._wsReconnectTimeout); + this._wsReconnectTimeout = null; + } + if (this._wsWatchdog) { + clearInterval(this._wsWatchdog); + this._wsWatchdog = null; + } + if (this._cacheFlushInterval) { + clearInterval(this._cacheFlushInterval); + this._cacheFlushInterval = null; + } + if (this._batteryGroupInterval) { + clearInterval(this._batteryGroupInterval); + this._batteryGroupInterval = null; + } + if (this._dailyInterval) { + clearInterval(this._dailyInterval); + this._dailyInterval = null; + } + if (this._debugInterval) { + clearInterval(this._debugInterval); + this._debugInterval = null; + } + if (this.wsManager) { + this.wsManager.stop(); + this.wsManager = null; + } + + // Unregister flow card listeners to prevent memory leak + if (this._flowListenerReferences) { + for (const listener of this._flowListenerReferences) { + try { + listener.unregister?.(); + } catch (_) {} + } + this._flowListenerReferences = null; + } + } + +async onDiscoveryAvailable(discoveryResult) { + const newIP = discoveryResult.address; + + // Eerste keer discovery → IP opslaan + if (!this._lastDiscoveryIP) { + this._lastDiscoveryIP = newIP; + this.url = `https://${newIP}`; + this.log(`🌐 Discovery: initial IP set to ${newIP}`); + await this.setSettings({ url: this.url }).catch(this.error); + } + + // IP is NIET veranderd → niets doen + if (this._lastDiscoveryIP === newIP) { + this.log(`🌐 Discovery: IP unchanged (${newIP}) — ignoring`); + return; + } + + // IP is WEL veranderd → update + restart + this._lastDiscoveryIP = newIP; + this.url = `https://${newIP}`; + this.log(`🌐 Discovery: IP changed → ${newIP}`); + await this.setSettings({ url: this.url }).catch(this.error); + + // Debounce reconnect + if (this._wsReconnectTimeout) clearTimeout(this._wsReconnectTimeout); + this._wsReconnectTimeout = setTimeout(async () => { + + if (this.pollingEnabled) { + this.log('🔁 Discovery: polling active — skipping WS reconnect'); + return; + } + + // Preflight reachability check + try { + const res = await fetchWithTimeout(`${this.url}/api/system`, { + headers: { Authorization: `Bearer ${this.token}` }, + agent: new https.Agent({ rejectUnauthorized: false }) + }, 3000); + + if (!res || typeof res.cloud_enabled === 'undefined') { + this.error(`❌ Discovery: device at ${this.url} unreachable — skipping WS`); + return; + } + + this.log('🔁 Discovery: IP changed & reachable — restarting WebSocket'); + await this.setAvailable(); + this.wsManager?.restartWebSocket(); + + } catch (err) { + this.error(`❌ Discovery preflight failed — ${err.message}`); + } + + }, 500); +} + + + + + +async onDiscoveryAddressChanged(discoveryResult) { + const newIP = discoveryResult.address; + + // Only respond if the IP actually changed + if (this._lastDiscoveryIP === newIP) { + this.log(`🌐 AddressChanged: IP unchanged (${newIP}) — ignoring`); + return; + } + + // IP is veranderd → opslaan + settings bijwerken + this._lastDiscoveryIP = newIP; + this.url = `https://${newIP}`; + this.log(`🌐 Address changed — new URL: ${this.url}`); + await this.setSettings({ url: this.url }).catch(this.error); + + // Debounce reconnect + if (this._wsReconnectTimeout) clearTimeout(this._wsReconnectTimeout); + this._wsReconnectTimeout = setTimeout(() => { + if (!this.getSettings().use_polling) { + this.log('🔁 Address change: restarting WebSocket'); + this.wsManager?.restartWebSocket(); + } else { + this.log('🔁 Address change: polling active — skipping WS reconnect'); + } + }, 500); +} + + +async onDiscoveryLastSeenChanged(discoveryResult) { + const newIP = discoveryResult.address; + + // Update IP only if changed + if (this._lastDiscoveryIP !== newIP) { + this._lastDiscoveryIP = newIP; + this.url = `https://${newIP}`; + this.log(`📡 Device seen again — IP updated: ${newIP}`); + await this.setSettings({ url: this.url }).catch(this.error); + } else { + this.log(`📡 Device seen again — IP unchanged (${newIP})`); + } + + await this.setAvailable(); + + // Debounce reconnect + if (this._wsReconnectTimeout) clearTimeout(this._wsReconnectTimeout); + this._wsReconnectTimeout = setTimeout(() => { + + if (this.pollingEnabled) { + this.log('🔁 LastSeen: polling active — skipping WS reconnect'); + return; + } + + // Only restart WS if it is NOT connected + if (!this.wsManager?.isConnected()) { + this.log('🔁 LastSeen: WS not connected → restarting WebSocket'); + this.wsManager?.restartWebSocket(); + } else { + this.log('📡 LastSeen: WS already connected — ignoring'); + } + + }, 500); +} + + + + + + /** + * Helper function to update capabilities configuration. + * This function is called when the device is initialized. + */ + async _updateCapabilities() { + if (!this.hasCapability('identify')) { + await safeAddCapability(this, 'identify').catch(this.error); + console.log(`created capability identify for ${this.getName()}`); + } + + if (!this.hasCapability('measure_power')) { + await safeAddCapability(this, 'measure_power').catch(this.error); + console.log(`created capability measure_power for ${this.getName()}`); + } + + + + // Remove capabilities that are not needed + if (this.hasCapability('measure_power.power_w')) { + await this.removeCapability('measure_power.power_w').catch(this.error); + console.log(`removed capability measure_power.power_w for ${this.getName()}`); + } + + if (this.hasCapability('meter_power.returned.t1')) { + await this.removeCapability('meter_power.returned.t1').catch(this.error); + console.log(`removed capability meter_power.returned.t1 for ${this.getName()}`); + } + + if (this.hasCapability('meter_power.returned.t2')) { + await this.removeCapability('meter_power.returned.t2').catch(this.error); + console.log(`removed capability meter_power.returned.t2 for ${this.getName()}`); + } + + } + + /** + * Helper function to register capability listeners. + * This function is called when the device is initialized. + */ +async _registerCapabilityListeners() { + + // Existing listener + this.registerCapabilityListener('identify', async () => { + await api.identify(this.url, this.token); + }); + + // Battery mode picker listener + this.registerCapabilityListener('battery_group_charge_mode', async (value) => { + this.log(`UI changed battery_group_charge_mode → ${value}`); + + try { + const { wsManager, url, token } = this; + + // 1. Prefer WebSocket + if (wsManager?.isConnected()) { + wsManager.setBatteryMode(value); + this.log(`Set battery mode via WS → ${value}`); + } else { + // 2. HTTP fallback + const response = await api.setMode(url, token, value); + if (!response) { + this.log(`⚠️ Invalid response from setMode(${value})`); + return false; + } + } + + // 3. Fetch real vendor state + const modeResponse = await api.getMode(url, token); + + if (!modeResponse) { + this.log('⚠️ Invalid battery mode response after UI change:', modeResponse); + return false; + } + + // 4. Normalize (string OR object) + const normalized = normalizeBatteryMode(modeResponse); + + // 5. Update cache in object-safe form + this._cacheSet('last_battery_state', { + mode: typeof modeResponse === 'object' ? modeResponse.mode : normalized, + permissions: typeof modeResponse === 'object' ? modeResponse.permissions : [], + battery_count: typeof modeResponse === 'object' + ? (modeResponse.battery_count ?? 1) + : 1 + }); + + // 6. Update capability to the *real* vendor state + await updateCapability(this, 'battery_group_charge_mode', normalized); + + // 7. Trigger flow if changed + const prev = this._cacheGet('last_battery_mode'); + if (normalized !== prev) { + this.flowTriggerBatteryMode(this, { mode: normalized }); + this._cacheSet('last_battery_mode', normalized); + } + + return normalized; + + } catch (err) { + this.error('❌ Failed to set battery_group_charge_mode via UI:', err); + return false; + } + }); +} + + + /** + * Helper function for 'optional' capabilities. + * This function is called when the device is initialized. + * It will create the capability if it doesn't exist. + * + * We do not remove capabilities here, as we assume the user may want to keep them. + * Besides that we assume that the P1 Meter is connected to a smart meter that does not change often. + * + * @param {string} capability The capability to set + * @param {*} value The value to set + * @returns {Promise} A promise that resolves when the capability is set + */ +async _setCapabilityValue(capability, value) { + if (value === undefined) return; + + // Only update if the capability exists + if (!this.hasCapability(capability)) return; + + await this.setCapabilityValue(capability, value).catch(this.error); +} + + + /** + * Helper function to trigger flows on change. + * This function is called when the device is initialized. + * + * We use this function to trigger flows when the value changes. + * We store the previous value in a variable. + * + * @param {*} flow_id Flow ID name + * @param {*} value The value to check for changes + * @returns {Promise} A promise that resolves when the flow is triggered + */ + async _triggerFlowOnChange(flow_id, value) { + if (!Number.isFinite(value)) { + this.log(`⚠️ Skipping flow "${flow_id}" — invalid or missing value:`, value); + return; + } + + this._triggerFlowPrevious = this._triggerFlowPrevious || {}; + + if (this._triggerFlowPrevious[flow_id] === undefined) { + this._triggerFlowPrevious[flow_id] = value; + // await setStoreValueSafe(this, `last_${flow_id}`, value); + this._cacheSet(`last_${flow_id}`, value); + + return; + } + + if (this._triggerFlowPrevious[flow_id] === value) { + return; + } + + const card = this.homey.flow.getDeviceTriggerCard(flow_id); + if (!card) { + this.error(`❌ Flow card "${flow_id}" not found`); + return; + } + + this._triggerFlowPrevious[flow_id] = value; + + this.log(`🚀 Triggering flow "${flow_id}" with value:`, value); + this.log(`📦 Token payload:`, { [flow_id]: value }); + + await card.trigger(this, {}, { [flow_id]: value }).catch(this.error); + // await setStoreValueSafe(this, `last_${flow_id}`, value); + this._cacheSet(`last_${flow_id}`, value); + } + + + + // onPoll method if websocket is to heavy for Homey unit + async onPoll() { + + const settings = this.getSettings(); + + // 1. Restore URL if runtime is empty + if (!this.url) { + if (settings.url) { + this.url = settings.url; + } else { + await this.setUnavailable('Missing URL'); + return; + } + } + + // 2. Sync settings if discovery changed the URL + if (this.url && this.url !== settings.url) { + await this.setSettings({ url: this.url }).catch(this.error); + } + + try { + + const [measurement, system, batteries] = await Promise.all([ + api.getMeasurement(this.url, this.token), + api.getSystem(this.url, this.token), + api.getMode(this.url, this.token), + ]); + + // Reuse websocket based measurement capabilities code + if (measurement) { + await this._handleMeasurement(measurement); + + // Reuse websocket based external measurement capabilities code (gas and water) + if (measurement.external) { + await this._handleExternalMeters(measurement.external); + } + } + + // Reuse websocket based system capabilities code + if (system) { + await this._handleSystem(system); + } + + // console.log(batteries); + // Reuse websocket based battery capabilities code + if (batteries) { + await this._handleBatteries(batteries); + } + + await this.setAvailable(); + + } catch (err) { + this.log(`Polling error: ${err.message}`); + this.setUnavailable(err.message || 'Polling error').catch(this.error); + + } + } + + _handlePhaseOverload(phaseKey, loadPct, lang) { + const state = this._phaseOverloadState[phaseKey]; + + // Debounce: 3 opeenvolgende samples boven 97% + if (loadPct > 97) { + state.highCount++; + + if (!state.notified && state.highCount >= 3 && this._phaseOverloadNotificationsEnabled) { + const phaseNum = phaseKey.replace('l', ''); // l1 → 1 + const msg = lang === 'nl' + ? `Fase ${phaseNum} overbelast (${loadPct.toFixed(0)}%)` + : `Phase ${phaseNum} overloaded (${loadPct.toFixed(0)}%)`; + + this.homey.notifications.createNotification({ excerpt: msg }).catch(this.error); + state.notified = true; + } + } else { + // Hysterese: reset pas onder 85% + if (loadPct < 85) { + state.highCount = 0; + state.notified = false; + } + } +} + + async onSettings(MySettings) { + this.log('Settings updated'); + this.log('Settings:', MySettings); + // Update interval polling + if ('polling_interval' in MySettings.oldSettings + && MySettings.oldSettings.polling_interval !== MySettings.newSettings.polling_interval + ) { + this.log('Polling_interval for P1 changed to:', MySettings.newSettings.polling_interval); + clearInterval(this.onPollInterval); + // this.onPollInterval = setInterval(this.onPoll.bind(this), MySettings.newSettings.polling_interval * 1000); + this.onPollInterval = setInterval(this.onPoll.bind(this), 1000 * this.getSettings().polling_interval); + } + if ('mode' in MySettings.oldSettings + && MySettings.oldSettings.mode !== MySettings.newSettings.mode + ) { + this.log('Mode for Plugin Battery via P1 advanced settings changed to:', MySettings.newSettings.mode); + try { + await api.setMode(this.url, this.token, MySettings.newSettings.mode); + } catch (err) { + this.log('Failed to set mode:', err.message); + } + } + + if ('cloud' in MySettings.oldSettings + && MySettings.oldSettings.cloud !== MySettings.newSettings.cloud + ) { + this.log('Cloud connection in advanced settings changed to:', MySettings.newSettings.cloud); + + try { + if (MySettings.newSettings.cloud == 1) { + await api.setCloudOn(this.url, this.token); + } else if (MySettings.newSettings.cloud == 0) { + await api.setCloudOff(this.url, this.token); + } + } catch (err) { + this.log('Failed to update cloud setting:', err.message); + } + } + + if (MySettings.changedKeys.includes('use_polling')) { + this.log(`⚙️ use_polling gewijzigd naar: ${MySettings.newSettings.use_polling}`); + + // ⭐ FIX: update runtime flag + this.pollingEnabled = MySettings.newSettings.use_polling; + + if (MySettings.newSettings.use_polling) { + this.wsManager?.stop(); // cleanly stop WebSocket + this.startPolling(); + } else { + if (this.onPollInterval) { + clearInterval(this.onPollInterval); + this.onPollInterval = null; + } + + if (!this.wsManager) { + this.wsManager = new WebSocketManager({ + url: this.url, + token: this.token, + log: this._boundLog, + error: this._boundError, + setAvailable: this._boundSetAvailable, + getSetting: this._boundGetSetting, + handleMeasurement: this._boundHandleMeasurement, + handleSystem: this._boundHandleSystem, + handleBatteries: this._boundHandleBatteries + }); + } + + this.wsManager.start(); + } + + } + + if ('phase_overload_notifications' in MySettings.newSettings) { + this._phaseOverloadNotificationsEnabled = MySettings.newSettings.phase_overload_notifications; + this.log('Phase overload notifications changed to:', this._phaseOverloadNotificationsEnabled); + } + + return true; + } + +}; diff --git a/drivers/energy_v2/driver.compose.json b/drivers/energy_v2/driver.compose.json new file mode 100644 index 00000000..db9b4cbd --- /dev/null +++ b/drivers/energy_v2/driver.compose.json @@ -0,0 +1,378 @@ +{ + "name": { + "en": "P1 Meter (apiv2)" + }, + "images": { + "large": "drivers/energy_v2/assets/images/large.png", + "small": "drivers/energy_v2/assets/images/small.png" + }, + "class": "sensor", + "discovery": "energy_v2", + "platforms": [ + "local" + ], + "capabilities": [ + "identify", + "measure_power", + "meter_gas", + "meter_gas.daily", + "meter_water", + "meter_power", + "meter_power.consumed.t1", + "meter_power.produced.t1", + "meter_power.consumed.t2", + "meter_power.produced.t2", + "meter_power.consumed.t3", + "meter_power.produced.t3", + "meter_power.consumed.t4", + "meter_power.produced.t4", + "meter_power.consumed", + "meter_power.returned", + "meter_power.daily", + "measure_power.l1", + "measure_power.l2", + "measure_power.l3", + "measure_current", + "measure_current.l1", + "measure_current.l2", + "measure_current.l3", + "net_load_phase1_pct", + "net_load_phase2_pct", + "net_load_phase3_pct", + "measure_voltage.l1", + "measure_voltage.l2", + "measure_voltage.l3", + "measure_power.montly_power_peak", + "measure_power.average_power_15m_w", + "tariff", + "long_power_fail_count", + "voltage_sag_l1", + "voltage_sag_l2", + "voltage_sag_l3", + "voltage_swell_l1", + "voltage_swell_l2", + "voltage_swell_l3", + "rssi", + "wifi_quality", + "measure_power.battery_group_power_w", + "measure_power.battery_group_target_power_w", + "measure_power.battery_group_max_consumption_w", + "measure_power.battery_group_max_production_w", + "connection_error", + "battery_group_total_capacity_kwh", + "battery_group_average_soc", + "battery_group_state", + "battery_group_charge_mode" + ], + "energy": { + "cumulative": true, + "cumulativeImportedCapability": "meter_power.consumed", + "cumulativeExportedCapability": "meter_power.returned" + }, + "capabilitiesOptions": { + "measure_power": { + "title": { + "en": "Current usage", + "nl": "Huidig vermogen" + } + }, + "meter_gas": { + "decimals": 3, + "title": { + "en": "Gasmeter", + "nl": "Gasmeter" + } + }, + "meter_gas.daily": { + "decimals": 3, + "title": { + "en": "Day Usage Gas", + "nl": "Dag verbruik Gas" + } + }, + "meter_water": { + "decimals": 3, + "title": { + "en": "Watermeter", + "nl": "Watermeter" + } + }, + "measure_power.l1": { + "title": { + "en": "Current usage phase 1", + "nl": "Huidig gebruik fase 1" + } + }, + "measure_power.l2": { + "title": { + "en": "Current usage phase 2", + "nl": "Huidig gebruik fase 2" + } + }, + "measure_power.l3": { + "title": { + "en": "Current usage phase 3", + "nl": "Huidig gebruik fase 3" + } + }, + "measure_current": { + "title": { + "en": "Current Amp", + "nl": "Huidig Amp" + } + }, + "measure_current.l1": { + "title": { + "en": "Current Amp phase 1", + "nl": "Huidig Amp fase 1" + } + }, + "measure_current.l2": { + "title": { + "en": "Current Amp phase 2", + "nl": "Huidig Amp fase 2" + } + }, + "measure_current.l3": { + "title": { + "en": "Current Amp phase 3", + "nl": "Huidig Amp fase 3" + } + }, + "net_load_phase1_pct" :{ + "title": { + "en": "Phase 1 load %", + "nl": "Fase 1 belasting %" + } + }, + "net_load_phase2_pct" :{ + "title": { + "en": "Phase 2 load %", + "nl": "Fase 2 belasting %" + } + }, + "net_load_phase3_pct" :{ + "title": { + "en": "Phase 3 load %", + "nl": "Fase 3 belasting %" + } + }, + "measure_voltage": { + "title": { + "en": "Current Voltage", + "nl": "Huidig Voltage" + } + }, + "measure_voltage.l1": { + "title": { + "en": "Current Voltage phase 1", + "nl": "Huidig Voltage fase 1" + } + }, + "measure_voltage.l2": { + "title": { + "en": "Current Voltage phase 2", + "nl": "Huidig Voltage fase 2" + } + }, + "measure_voltage.l3": { + "title": { + "en": "Current Voltage phase 3", + "nl": "Huidig Voltage fase 3" + } + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Total t1 usage", + "nl": "Totaal t1 gebruik" + } + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Total t1 deliver", + "nl": "Totaal t1 teruglevering" + } + }, + "meter_power.consumed.t2": { + "decimals": 3, + "title": { + "en": "Total t2 usage", + "nl": "Totaal t2 gebruik" + } + }, + "meter_power.produced.t2": { + "decimals": 3, + "title": { + "en": "Total t2 deliver", + "nl": "Totaal t2 teruglevering" + } + }, + "meter_power.consumed.t3": { + "decimals": 3, + "title": { + "en": "Total t3 usage", + "nl": "Totaal t3 gebruik" + } + }, + "meter_power.produced.t3": { + "decimals": 3, + "title": { + "en": "Total t3 deliver", + "nl": "Totaal t3 teruglevering" + } + }, + "meter_power.consumed.t4": { + "decimals": 3, + "title": { + "en": "Total t4 usage", + "nl": "Totaal t4 gebruik" + } + }, + "meter_power.produced.t4": { + "decimals": 3, + "title": { + "en": "Total t4 deliver", + "nl": "Totaal t4 teruglevering" + } + }, + "meter_power.consumed": { + "decimals": 3, + "title": { + "en": "Sum Consumed", + "nl": "Som gebruik" + } + }, + "meter_power.returned": { + "decimals": 3, + "title": { + "en": "Sum returned", + "nl": "Som teruglevering" + } + }, + "meter_power.daily": { + "decimals": 3, + "title": { + "en": "Daily usage", + "nl": "Dag verbruik" + } + }, + "meter_power": { + "decimals": 3, + "title": { + "en": "Total usage KWh", + "nl": "Totaal verbruik KWh" + } + }, + "measure_power.montly_power_peak": { + "title": { + "en": "Monthly Power Peak", + "nl": "Maandelijks piekvermogen" + } + }, + "measure_power.average_power_15m_w": { + "title": { + "en": "Active average power over 15 minutes", + "nl": "Actief gemiddeld vermogen over 15 minuten" + } + }, + "measure_power.battery_group_power_w": { + "title": { + "en": "Battery group Current combined Power", + "nl": "Battery groep Huidig samengesteld vermogen" + } + }, + "measure_power.battery_group_target_power_w": { + "title": { + "en": "Battery group Target Power", + "nl": "Battery groep Doel vermogen" + } + }, + "measure_power.battery_group_max_consumption_w": { + "title": { + "en": "Battery group Max allowed Consumption Power", + "nl": "Battery groep Max toegestaand gebruiksvermogen" + } + }, + "measure_power.battery_group_max_production_w": { + "title": { + "en": "Battery group Max allowed Production Power", + "nl": "Battery groep Max toegestaand leveringssvermogen" + } + }, + "battery_group_total_capacity_kwh": { + "type": "number", + "getable": true, + "setable": false, + "uiComponent": "sensor", + "icon": "assets/battery.svg", + "units": { + "en": "kWh" + }, + "title": { + "en": "Battery Group Total Capacity" + }, + "insights": false + }, + "battery_group_average_soc": { + "type": "number", + "getable": true, + "setable": false, + "uiComponent": "sensor", + "icon": "assets/battery.svg", + "units": { + "en": "%" + }, + "title": { + "en": "Battery Group Average SoC" + }, + "insights": true + }, + "battery_group_state": { + "type": "string", + "getable": true, + "setable": false, + "uiComponent": "sensor", + "icon": "assets/battery.svg", + "title": { + "en": "Battery Group Charge state", + "nl": "Battery Groep Laadt status" + }, + "insights": true + }, + "rssi": { + "type": "number", + "title": { + "en": "WiFi Signal", + "nl": "WiFi signaal" + }, + "getable": true, + "setable": false, + "uiComponent": "sensor", + "insights": true, + "icon": "assets/rssi.svg", + "units": { + "en": "dBm", + "nl": "dBm" + } + } + }, + "pair": [ + { + "id": "list_devices", + "template": "list_devices", + "navigation": { + "next": "authorize" + }, + "options": { "singular": true } + }, + { + "id": "authorize", + "navigation": + { + "prev": "list_devices" + } + } + ] +} diff --git a/drivers/energy_v2/driver.flow.compose.json b/drivers/energy_v2/driver.flow.compose.json new file mode 100644 index 00000000..9ced8284 --- /dev/null +++ b/drivers/energy_v2/driver.flow.compose.json @@ -0,0 +1,141 @@ +{ + "actions": [ + { + "id": "set-battery-to-full-charge-mode", + "title": { + "en": "Set battery to Full Charge mode", + "nl": "Zet batterij in Volledig Oplaad modus" + }, + "titleFormatted": { + "en": "Set battery to Full Charge mode", + "nl": "Zet batterij in Volledig Oplaad modus" + }, + "args": [] + }, + { + "id": "set-battery-to-zero-mode", + "title": { + "en": "Set battery to Zero mode", + "nl": "Zet batterij in Zero modus" + }, + "titleFormatted": { + "en": "Set battery to Zero mode", + "nl": "Zet batterij in Nul op de meter modus" + }, + "args": [] + }, + { + "id": "set-battery-to-standby-mode", + "title": { + "en": "Set battery to Standby mode", + "nl": "Zet batterij in Standby modus" + }, + "titleFormatted": { + "en": "Set battery to Standby mode", + "nl": "Zet batterij in Standby modus" + }, + "args": [] + }, + { + "id": "set-battery-to-zero-charge-only-mode", + "title": { + "en": "Set battery to Zero Charge Only mode", + "nl": "Zet batterij in Nul op de meter alleen opladen modus" + }, + "titleFormatted": { + "en": "Set battery to Zero Charge Only mode", + "nl": "Zet batterij in Nul op de meter alleen opladen modus" + }, + "args": [] + }, + { + "id": "set-battery-to-zero-discharge-only-mode", + "title": { + "en": "Set battery to Zero Discharge Only mode", + "nl": "Zet batterij in Nul op de meter, alleen ontladen modus" + }, + "titleFormatted": { + "en": "Set battery to Zero Discharge Only mode", + "nl": "Zet batterij in Nul op de meter, alleen ontladen modus" + }, + "args": [] + } + ], + "triggers": [ + { + "id": "battery_mode_changed", + "title": { "en": "Battery mode changed" }, + "titleFormatted": { "en": "Battery mode changed" }, + "args": [], + "tokens": [] + }, + { + "id": "battery_error_detected", + "title": { + "en": "Battery error detected" + }, + "args": [] + }, + { + "id": "battery_group_state_changed", + "title": { "en": "Battery group state changed" } + }, + { + "id": "tariff_changed_v2", + "title": { + "en": "Peak / Normal Tariff changed", + "nl": "Dal / Normaal Tarief veranderd" + }, + "args": [], + "tokens": [ + { + "name": "tariff", + "type": "number", + "title": { + "en": "tariff", + "nl": "tarief" + }, + "example": 1 + } + ] + }, + { + "id": "import_changed_v2", + "title": { + "en": "Total used changed", + "nl": "Som gebruik veranderd" + }, + "args": [], + "tokens": [ + { + "name": "import", + "type": "number", + "title": { + "en": "import", + "nl": "import" + }, + "example": 1 + } + ] + }, + { + "id": "export_changed_v2", + "title": { + "en": "Total delivered changed", + "nl": "Som teruglevering veranderd" + }, + "args": [], + "tokens": [ + { + "name": "export", + "type": "number", + "title": { + "en": "export", + "nl": "export" + }, + "example": 1 + } + ] + } + ] +} diff --git a/drivers/energy_v2/driver.js b/drivers/energy_v2/driver.js new file mode 100644 index 00000000..fa129193 --- /dev/null +++ b/drivers/energy_v2/driver.js @@ -0,0 +1,6 @@ +'use strict'; + +const Homey = require('homey'); +const driver = require('../../includes/v2/Driver'); + +module.exports = driver; diff --git a/drivers/energy_v2/driver.settings.compose.json b/drivers/energy_v2/driver.settings.compose.json new file mode 100644 index 00000000..940759b4 --- /dev/null +++ b/drivers/energy_v2/driver.settings.compose.json @@ -0,0 +1,107 @@ +[ + { + "id": "mode", + "type": "dropdown", + "value": "plugin-battery", + "label": { + "en": "Plugin Battery mode", + "nl": "Plugin‑batterijmodus" + }, + "values": [ + { + "id": "zero", + "label": { + "en": "Zero mode", + "nl": "Nul op de meter" + } + }, + { + "id": "to_full", + "label": { + "en": "Full charge", + "nl": "Volledig opladen" + } + }, + { + "id": "standby", + "label": { + "en": "Standby", + "nl": "Stand‑by" + } + }, + { + "id": "zero_charge_only", + "label": { + "en": "Zero mode, Charge allowed", + "nl": "Nul op de meter, laden toegestaan" + } + }, + { + "id": "zero_discharge_only", + "label": { + "en": "Zero mode, Discharge allowed", + "nl": "Nul op de meter, ontladen toegestaan" + } + } + ] + }, + { + "id": "polling_interval", + "type": "number", + "label": { "en": "Polling interval" }, + "value": 10, + "min": 1, + "unit": { "en": "s" } + }, + { + "id": "grid_phase_amps", + "type": "number", + "label": { "en": "Grid phase Amps", + "nl": "Net fase aansluiting" }, + "value": 40, + "unit" : { "en": "A", + "nl": "A" } + }, + { + "id": "cloud", + "type": "number", + "label": { "en": "Cloud connection 1=on 0=off", + "nl": "Cloud verbinding 1=actief 0=uit" + }, + "value": 1 + }, + { + "id": "url", + "type": "text", + "label": { "en": "url", + "nl": "url" + } + }, + { + "id": "use_polling", + "type": "checkbox", + "label": { + "en": "Use polling instead of WebSocket", + "nl": "Gebruik polling in plaats van WebSocket" + }, + "value": true + }, + { + "id": "show_gas", + "type": "checkbox", + "label": { + "en": "Show gas meter", + "nl": "Gas meter weergeven" + }, + "value": true + }, + { + "id": "phase_overload_notifications", + "type": "checkbox", + "label": { + "en": "Phase overload notifications", + "nl": "Fase-overbelasting meldingen" + }, + "value": true + } +] diff --git a/drivers/energy_v2/pair/authorize.html b/drivers/energy_v2/pair/authorize.html new file mode 100644 index 00000000..6b62d7d0 --- /dev/null +++ b/drivers/energy_v2/pair/authorize.html @@ -0,0 +1,48 @@ +
+

+

+

+

+ +
+ + diff --git a/drivers/energylink/assets/icon.svg b/drivers/energylink/assets/icon.svg new file mode 100644 index 00000000..3ac01a32 --- /dev/null +++ b/drivers/energylink/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drivers/energylink/device.js b/drivers/energylink/device.js new file mode 100644 index 00000000..a42ac32f --- /dev/null +++ b/drivers/energylink/device.js @@ -0,0 +1,296 @@ +'use strict'; + +const Homey = require('homey'); +const homewizard = require('../../includes/legacy/homewizard.js'); + +const debug = false; + +class HomeWizardEnergylink extends Homey.Device { + + async onInit() { + + this.startPolling(); + + // Flow triggers + this._flowTriggerPowerUsed = this.homey.flow.getDeviceTriggerCard('power_used_changed'); + this._flowTriggerPowerNetto = this.homey.flow.getDeviceTriggerCard('power_netto_changed'); + this._flowTriggerPowerS1 = this.homey.flow.getDeviceTriggerCard('power_s1_changed'); + this._flowTriggerMeterPowerS1 = this.homey.flow.getDeviceTriggerCard('meter_power_s1_changed'); + this._flowTriggerPowerS2 = this.homey.flow.getDeviceTriggerCard('power_s2_changed'); + this._flowTriggerMeterPowerS2 = this.homey.flow.getDeviceTriggerCard('meter_power_s2_changed'); + this._flowTriggerMeterPowerUsed = this.homey.flow.getDeviceTriggerCard('meter_power_used_changed'); + this._flowTriggerMeterPowerAggregated = this.homey.flow.getDeviceTriggerCard('meter_power_aggregated_changed'); + this._flowTriggerMeterReturnT1 = this.homey.flow.getDeviceTriggerCard('meter_return_t1_changed'); + this._flowTriggerMeterReturnT2 = this.homey.flow.getDeviceTriggerCard('meter_return_t2_changed'); + } + + startPolling() { + + // Clear previous intervals + if (this.refreshIntervalId) clearInterval(this.refreshIntervalId); + if (this.refreshIntervalIdReadings) clearInterval(this.refreshIntervalIdReadings); + + // Status polling every 20 seconds + this.refreshIntervalId = setInterval(() => { + if (debug) this.log('-- EnergyLink Status Polling --'); + + if (this.getSetting('homewizard_id')) { + this.getStatus(); + } + }, 20 * 1000); + + // Readings polling every 60 seconds + this.refreshIntervalIdReadings = setInterval(() => { + if (debug) this.log('-- EnergyLink Readings Polling --'); + + if (this.getSetting('homewizard_id')) { + this.getReadings(); + } + }, 60 * 1000); + } + + // ----------------------------- + // STATUS POLLING + // ----------------------------- + async getStatus() { + + const homewizard_id = this.getSetting('homewizard_id'); + if (!homewizard_id) return; + + try { + const callback = await homewizard.getDeviceData(homewizard_id, 'energylinks'); + + // Safe guard: must be array with at least 1 entry + if (!Array.isArray(callback) || callback.length === 0) { + this.setUnavailable('No EnergyLink data available'); + return; + } + + const entry = callback[0]; + if (!entry) return; + + this.setAvailable().catch(this.error); + + const promises = []; + + // ----------------------------- + // BASIC VALUES + // ----------------------------- + const value_s1 = entry.t1; + const value_s2 = entry.t2; + + const energy_current_cons = entry.used?.po ?? 0; + const energy_daytotal_cons = entry.used?.dayTotal ?? 0; + const energy_daytotal_aggr = entry.aggregate?.dayTotal ?? 0; + const energy_current_netto = entry.aggregate?.po ?? 0; + + // ----------------------------- + // GAS (optional) + // ----------------------------- + try { + const gas_daytotal_cons = entry.gas?.dayTotal; + if (gas_daytotal_cons != null) { + promises.push(this.setCapabilityValue('meter_gas.today', gas_daytotal_cons).catch(this.error)); + } + } catch (_) { + this.log('No gas information available'); + } + + // ----------------------------- + // ELECTRICITY (common) + // ----------------------------- + promises.push(this.setCapabilityValue('measure_power.used', energy_current_cons).catch(this.error)); + promises.push(this.setCapabilityValue('measure_power', energy_current_netto).catch(this.error)); + promises.push(this.setCapabilityValue('measure_power.netto', energy_current_netto).catch(this.error)); + promises.push(this.setCapabilityValue('meter_power.used', energy_daytotal_cons).catch(this.error)); + promises.push(this.setCapabilityValue('meter_power.aggr', energy_daytotal_aggr).catch(this.error)); + + // ----------------------------- + // SOLAR / WATER / OTHER / CAR + // ----------------------------- + let solar_current_prod = 0; + let solar_daytotal_prod = 0; + + let water_current_cons = 0; + let water_daytotal_cons = 0; + + // S1 solar + if (value_s1 === 'solar') { + const po = entry.s1?.po ?? 0; + const dt = entry.s1?.dayTotal ?? 0; + + solar_current_prod += po; + solar_daytotal_prod += dt; + + if (this.hasCapability('meter_power.s1other')) { + promises.push(this.removeCapability('meter_power.s1other').catch(this.error)); + promises.push(this.removeCapability('measure_power.s1other').catch(this.error)); + } + } + + // S2 solar + if (value_s2 === 'solar') { + const po = entry.s2?.po ?? 0; + const dt = entry.s2?.dayTotal ?? 0; + + if (!this.hasCapability('measure_power.s2')) { + await this.addCapability('measure_power.s2').catch(this.error); + await this.addCapability('meter_power.s2').catch(this.error); + } + + promises.push(this.setCapabilityValue('measure_power.s2', po).catch(this.error)); + promises.push(this.setCapabilityValue('meter_power.s2', dt).catch(this.error)); + + solar_current_prod += po; + solar_daytotal_prod += dt; + + if (this.hasCapability('meter_power.s2other')) { + promises.push(this.removeCapability('meter_power.s2other').catch(this.error)); + promises.push(this.removeCapability('measure_power.s2other').catch(this.error)); + } + } + + // Apply solar totals + if (value_s1 === 'solar' || value_s2 === 'solar') { + promises.push(this.setCapabilityValue('measure_power.s1', solar_current_prod).catch(this.error)); + promises.push(this.setCapabilityValue('meter_power.s1', solar_daytotal_prod).catch(this.error)); + } + + // S1 water + if (value_s1 === 'water') { + water_current_cons = entry.s1?.po ?? 0; + water_daytotal_cons = (entry.s1?.dayTotal ?? 0) / 1000; + + promises.push(this.setCapabilityValue('meter_water', water_daytotal_cons).catch(this.error)); + promises.push(this.setCapabilityValue('measure_water', water_current_cons).catch(this.error)); + } + + // S2 water + if (value_s2 === 'water') { + water_current_cons = entry.s2?.po ?? 0; + water_daytotal_cons = (entry.s2?.dayTotal ?? 0) / 1000; + + promises.push(this.setCapabilityOptions('meter_water', { decimals: 3 }).catch(this.error)); + promises.push(this.setCapabilityValue('meter_water', water_daytotal_cons).catch(this.error)); + promises.push(this.setCapabilityValue('measure_water', water_current_cons).catch(this.error)); + } + + // S1 other/car + if (value_s1 === 'other' || value_s1 === 'car') { + const po = entry.s1?.po ?? 0; + const dt = entry.s1?.dayTotal ?? 0; + + promises.push(this.setCapabilityValue('meter_power.s1other', dt).catch(this.error)); + promises.push(this.setCapabilityValue('measure_power.s1other', po).catch(this.error)); + } + + // S2 other/car + if (value_s2 === 'other' || value_s2 === 'car') { + const po = entry.s2?.po ?? 0; + const dt = entry.s2?.dayTotal ?? 0; + + promises.push(this.setCapabilityValue('meter_power.s2other', dt).catch(this.error)); + promises.push(this.setCapabilityValue('measure_power.s2other', po).catch(this.error)); + } + + // ----------------------------- + // FLOW TRIGGERS (safe) + // ----------------------------- + if (energy_current_cons != null && + energy_current_cons !== this.getStoreValue('last_measure_power_used')) { + + promises.push(this._flowTriggerPowerUsed.trigger(this, { power_used: energy_current_cons })); + this.setStoreValue('last_measure_power_used', energy_current_cons); + } + + if (energy_current_netto != null && + energy_current_netto !== this.getStoreValue('last_measure_power_netto')) { + + promises.push(this._flowTriggerPowerNetto.trigger(this, { netto_power_used: energy_current_netto })); + this.setStoreValue('last_measure_power_netto', energy_current_netto); + } + + // Execute all updates + await Promise.allSettled(promises); + + this.setAvailable().catch(this.error); + + } catch (err) { + this.log('ERROR EnergyLink getStatus', err); + this.setUnavailable(err); + } + } + + // ----------------------------- + // READINGS POLLING + // ----------------------------- + async getReadings() { + + const homewizard_id = this.getSetting('homewizard_id'); + if (!homewizard_id) return; + + try { + const callback = await homewizard.getDeviceData(homewizard_id, 'energylink_el'); + + // Must have at least 3 entries + if (!Array.isArray(callback) || callback.length < 3) { + return; + } + + this.setAvailable().catch(this.error); + + const gas = callback[2]?.consumed ?? 0; + const cons_t1 = callback[0]?.consumed ?? 0; + const prod_t1 = callback[0]?.produced ?? 0; + const cons_t2 = callback[1]?.consumed ?? 0; + let prod_t2 = callback[1]?.produced ?? 0; + + if (prod_t2 < 0) prod_t2 = -prod_t2; + + const aggregated = (cons_t1 + cons_t2) - (prod_t1 + prod_t2); + + // Ensure capabilities exist + if (!this.hasCapability('meter_power')) { + await this.addCapability('meter_power').catch(this.error); + } + if (!this.hasCapability('meter_gas')) { + await this.addCapability('meter_gas').catch(this.error); + } + + // Update values + this.setCapabilityValue('meter_gas.reading', gas).catch(this.error); + this.setCapabilityValue('meter_gas', gas).catch(this.error); + this.setCapabilityValue('meter_power', aggregated).catch(this.error); + this.setCapabilityValue('meter_power.consumed.t1', cons_t1).catch(this.error); + this.setCapabilityValue('meter_power.produced.t1', prod_t1).catch(this.error); + this.setCapabilityValue('meter_power.consumed.t2', cons_t2).catch(this.error); + this.setCapabilityValue('meter_power.produced.t2', prod_t2).catch(this.error); + + // Flow triggers + if (prod_t1 != null && prod_t1 !== this.getStoreValue('last_meter_return_t1')) { + this._flowTriggerMeterReturnT1.trigger(this, { meter_power_produced_t1: prod_t1 }); + this.setStoreValue('last_meter_return_t1', prod_t1); + } + + if (prod_t2 != null && prod_t2 !== this.getStoreValue('last_meter_return_t2')) { + this._flowTriggerMeterReturnT2.trigger(this, { meter_power_produced_t2: prod_t2 }); + this.setStoreValue('last_meter_return_t2', prod_t2); + } + + } catch (err) { + this.log('ERROR EnergyLink getReadings', err); + this.setUnavailable(err); + } + } + + onDeleted() { + const deviceId = this.getData().id; + homewizard.removeDevice(deviceId); + + clearInterval(this.refreshIntervalId); + clearInterval(this.refreshIntervalIdReadings); + this.log('-- EnergyLink Polling Stopped --'); + } +} + +module.exports = HomeWizardEnergylink; diff --git a/drivers/energylink/driver.compose.json b/drivers/energylink/driver.compose.json new file mode 100644 index 00000000..341dd810 --- /dev/null +++ b/drivers/energylink/driver.compose.json @@ -0,0 +1,221 @@ +{ + "name": { + "en": "Energylink", + "nl": "Energylink" + }, + "images": { + "large": "drivers/energylink/assets/images/large.jpg", + "small": "drivers/energylink/assets/images/small.jpg" + }, + "platforms": [ + "local" + ], + "class": "sensor", + "platforms": [ "local"], + "capabilities": [ + "measure_power", + "meter_power.used", + "meter_power.aggr", + "meter_power.s1", + "meter_power.s2", + "meter_power", + "measure_power.used", + "measure_power.netto", + "measure_power.s1", + "measure_power.s2", + "measure_power.s1other", + "meter_power.s1other", + "measure_power.s2other", + "meter_power.s2other", + "meter_gas.today", + "meter_gas.reading", + "meter_water", + "measure_water", + "meter_power.consumed.t1", + "meter_power.produced.t1", + "meter_power.consumed.t2", + "meter_power.produced.t2" + ], + "energy": { + "cumulative": true, + "cumulativeImportedCapability": "meter_power.used", + "cumulativeExportedCapability": "meter_power.s1" + }, + "capabilitiesOptions": { + "meter_power.used": { + "decimals": 3, + "title": { + "en": "Day usage", + "nl": "Dag gebruik" + }, + "insights": true + }, + "meter_power.aggr": { + "decimals": 3, + "title": { + "en": "Overall usage", + "nl": "Netto gebruik" + }, + "insights": true + }, + "meter_power.s1": { + "decimals": 3, + "title": { + "en": "Day production S1", + "nl": "Dag opbrengst S1" + }, + "insights": true + }, + "meter_power.s2": { + "decimals": 3, + "title": { + "en": "Day production S2", + "nl": "Dag opbrengst S2" + }, + "insights": true + }, + "measure_power.s1other": { + "title": { + "en": "Power current S1 other", + "nl": "Huidig vermogen S1 other" + }, + "insights": true + }, + "meter_power.s1other": { + "decimals": 3, + "title": { + "en": "Day usage S1 other", + "nl": "Dag gebruik S1 other" + }, + "insights": true + }, + "measure_power.used": { + "title": { + "en": "Power current", + "nl": "Huidig vermogen" + }, + "insights": true + }, + "measure_power.s2other": { + "title": { + "en": "Power current S2 other", + "nl": "Huidig vermogen S2 other" + }, + "insights": true + }, + "meter_power.s2other": { + "decimals": 3, + "title": { + "en": "Day usage S2 other", + "nl": "Dag gebruik S2 other" + }, + "insights": true + }, + "measure_power.netto": { + "title": { + "en": "Netto Power current", + "nl": "Netto Huidig vermogen" + } + }, + "measure_power.s1": { + "title": { + "en": "Solar current S1", + "nl": "Huidige opbrengst S1" + }, + "insights": true + }, + "measure_power.s2": { + "title": { + "en": "Solar current S2", + "nl": "Huidige opbrengst S2" + }, + "insights": true + }, + "meter_gas.today": { + "decimals": 3, + "title": { + "en": "Gas", + "nl": "Gas" + }, + "insights": true + }, + "meter_gas.reading": { + "decimals": 3, + "title": { + "en": "Meter reading gas", + "nl": "Meterstand Gas" + }, + "insights": true + }, + "meter_water": { + "decimals": 3, + "title": { + "en": "Water Total", + "nl": "Water Totaal" + }, + "insights": true + }, + "measure_water": { + "title": { + "en": "Water l./m", + "nl": "Water l./m" + }, + "insights": true + }, + "meter_power.consumed.t1": { + "decimals": 3, + "title": { + "en": "Meter reading low", + "nl": "Stand laag tarief" + }, + "insights": true + }, + "meter_power.produced.t1": { + "decimals": 3, + "title": { + "en": "Meter reading produced low", + "nl": "Stand terug levering laag" + }, + "insights": true + }, + "meter_power.consumed.t2": { + "decimals": 3, + "title": { + "en": "Meter reading normal", + "nl": "Stand verbruik normaal" + }, + "insights": true + }, + "meter_power.produced.t2": { + "decimals": 3, + "title": { + "en": "Meter reading produced normal", + "nl": "Stand terug levering normaal" + }, + "insights": true + }, + "meter_power": { + "title": { + "en": "Aggregated meter", + "nl": "Geaggregeerde meterstand" + }, + "insights": true + } + }, + "pair": [ + { + "id": "start" + }, + { + "id": "list_my_devices", + "template": "list_devices", + "navigation": { + "next": "add_my_devices" + } + }, + { + "id": "add_my_devices", + "template": "add_devices" + } + ] +} \ No newline at end of file diff --git a/drivers/energylink/driver.flow.compose.json b/drivers/energylink/driver.flow.compose.json new file mode 100644 index 00000000..09fa61cf --- /dev/null +++ b/drivers/energylink/driver.flow.compose.json @@ -0,0 +1,194 @@ +{ + "triggers": [ + { + "id": "power_used_changed", + "title": { + "en": "Power used changed", + "nl": "Huidig vermogen veranderd" + }, + "args": [], + "tokens": [ + { + "name": "power_used", + "type": "number", + "title": { + "en": "Watt", + "nl": "Watt" + }, + "example": 15 + } + ] + }, + { + "id": "power_s1_changed", + "title": { + "en": "Power production changed", + "nl": "Huidige productie veranderd" + }, + "args": [], + "tokens": [ + { + "name": "power_s1", + "type": "number", + "title": { + "en": "Watt", + "nl": "Watt" + }, + "example": 15 + } + ] + }, + { + "id": "power_s2_changed", + "title": { + "en": "Power usage S2 changed", + "nl": "Huidige gebruik S2 veranderd" + }, + "args": [], + "tokens": [ + { + "name": "power_s2", + "type": "number", + "title": { + "en": "Watt", + "nl": "Watt" + }, + "example": 15 + } + ] + }, + { + "id": "power_netto_changed", + "title": { + "en": "Daily netto usage changed", + "nl": "Dag netto verbruik veranderd" + }, + "args": [], + "tokens": [ + { + "name": "netto_power_used", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + }, + { + "id": "meter_power_used_changed", + "title": { + "en": "Daily usage changed", + "nl": "Dag verbruik veranderd" + }, + "args": [], + "tokens": [ + { + "name": "power_daytotal_used", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + }, + { + "id": "meter_power_aggregated_changed", + "title": { + "en": "Overall usage changed", + "nl": "Netto verbruik veranderd" + }, + "args": [], + "tokens": [ + { + "name": "power_daytotal_aggr", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + }, + { + "id": "meter_power_s1_changed", + "title": { + "en": "Daily production changed", + "nl": "Dag productie veranderd" + }, + "args": [], + "tokens": [ + { + "name": "power_daytotal_s1", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + }, + { + "id": "meter_power_s2_changed", + "title": { + "en": "Daily usage S2 changed", + "nl": "Dag gebruik S2 veranderd" + }, + "args": [], + "tokens": [ + { + "name": "power_daytotal_s2", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + }, + { + "id": "meter_return_t1_changed", + "title": { + "en": "Meter return t1 changed", + "nl": "Meter teruglevering t1 veranderd" + }, + "args": [], + "tokens": [ + { + "name": "meter_power_produced_t1", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + }, + { + "id": "meter_return_t2_changed", + "title": { + "en": "Meter return t2 changed", + "nl": "Meter teruglevering t2 veranderd" + }, + "args": [], + "tokens": [ + { + "name": "meter_power_produced_t2", + "type": "number", + "title": { + "en": "kWh", + "nl": "kWh" + }, + "example": 1 + } + ] + } + ] +} \ No newline at end of file diff --git a/drivers/energylink/driver.js b/drivers/energylink/driver.js index e5f2a0e8..3de1bb9c 100755 --- a/drivers/energylink/driver.js +++ b/drivers/energylink/driver.js @@ -1,200 +1,76 @@ -var devices = []; -var homewizard = require('./../../includes/homewizard.js'); -var refreshIntervalId = 0; - -// SETTINGS -module.exports.settings = function( device_data, newSettingsObj, oldSettingsObj, changedKeysArr, callback ) { - Homey.log ('Changed settings: ' + JSON.stringify(device_data) + ' / ' + JSON.stringify(newSettingsObj) + ' / old = ' + JSON.stringify(oldSettingsObj)); - try { - changedKeysArr.forEach(function (key) { - devices[device_data.id].settings[key] = newSettingsObj[key]; - }); - callback(null, true); - } catch (error) { - callback(error); - } -}; - -module.exports.pair = function( socket ) { - socket.on('get_homewizards', function () { - homewizard.getDevices(function(homewizard_devices) { - - Homey.log(homewizard_devices); - var hw_devices = {}; - Object.keys(homewizard_devices).forEach(function(key) { - hw_devices[key] = homewizard_devices[key]; - }); - - socket.emit('hw_devices', hw_devices); - }); - }); - - socket.on('manual_add', function (device, callback) { - if (device.settings.homewizard_id.indexOf('HW_') === -1 && device.settings.homewizard_id.indexOf('HW') === 0) { - //true - Homey.log('Energylink added ' + device.data.id); - devices[device.data.id] = { - id: device.data.id, - name: device.name, - settings: device.settings, - }; - callback( null, devices ); - socket.emit("success", device); - startPolling(); - } else { - socket.emit("error", "No valid HomeWizard found, re-pair if problem persists"); - } - }); - - socket.on('disconnect', function(){ - console.log("User aborted pairing, or pairing is finished"); +'use strict'; + +const Homey = require('homey'); +const homewizard = require('../../includes/legacy/homewizard.js'); + +const devices = {}; + +class HomeWizardEnergyLink extends Homey.Driver { + + onInit() { + // Driver initialized + } + + async onPair(socket) { + + // Show initial view + await socket.showView('start'); + + // View change logging + socket.setHandler('showView', (viewId) => { + this.log(`View: ${viewId}`); }); -} -module.exports.init = function(devices_data, callback) { - devices_data.forEach(function initdevice(device) { - Homey.log('add device: ' + JSON.stringify(device)); - devices[device.id] = device; - module.exports.getSettings(device, function(err, settings){ - devices[device.id].settings = settings; + // Request list of HomeWizard controllers + socket.setHandler('get_homewizards', () => { + + const hwControllers = this.homey.drivers.getDriver('homewizard').getDevices(); + + homewizard.getDevices((hwDevices) => { + const result = {}; + + Object.keys(hwDevices).forEach((key) => { + + const energylinks = hwDevices[key].polldata?.energylinks || {}; + + result[key] = { + id: key, + name: hwDevices[key].name, + settings: hwDevices[key].settings, + energylinks: energylinks + }; }); + + socket.emit('hw_devices', result); + }); + }); + + // Manual add + socket.setHandler('manual_add', (device) => { + + const id = device.settings.homewizard_id; + + if (id.indexOf('HW_') === -1 && id.indexOf('HW') === 0) { + + this.log(`EnergyLink added ${device.data.id}`); + + devices[device.data.id] = { + id: device.data.id, + name: 'EnergyLink', + settings: device.settings, + }; + + socket.emit('success', device); + return devices; + } + + socket.emit('error', 'No valid HomeWizard found, re-pair if problem persists'); }); - if (Object.keys(devices).length > 0) { - startPolling(); - } - Homey.log('Energylink driver init done'); - - callback (null, true); -}; - -module.exports.deleted = function( device_data ) { - clearInterval(refreshIntervalId); - console.log("--Stopped Polling Energy Link--"); - devices = []; - Homey.log('deleted: ' + JSON.stringify(device_data)); -}; - -module.exports.capabilities = { - "measure_power.used": { - get: function (device_data, callback) { - var device = devices[device_data.id]; - - if (device === undefined) { - callback(null, 0); - } else { - callback(null, device.last_measure_power_used); - } - } - }, - "measure_power.s1": { - get: function (device_data, callback) { - var device = devices[device_data.id]; - - if (device === undefined) { - callback(null, 0); - } else { - callback(null, device.last_measure_power_s1); - } - } - }, - "meter_power.used": { - get: function (device_data, callback) { - var device = devices[device_data.id]; - - if (device === undefined) { - callback(null, 0); - } else { - callback(null, device.last_meter_power_used); - } - } - }, - "meter_power.s1": { - get: function (device_data, callback) { - var device = devices[device_data.id]; - - if (device === undefined) { - callback(null, 0); - } else { - callback(null, device.last_meter_power_s1); - } - } - }, - meter_gas: { - get: function (device_data, callback) { - var device = devices[device_data.id]; - - if (device === undefined) { - callback(null, 0); - } else { - callback(null, device.last_meter_gas); - } - } - } -}; - -// Start polling -function startPolling() { - refreshIntervalId = setInterval(function () { - console.log("--Start Energylink Polling-- "); - Object.keys(devices).forEach(function (device_id) { - getStatus(device_id); + + socket.setHandler('disconnect', () => { + this.log('Pairing aborted or finished'); }); - }, 1000 * 10); + } } -function getStatus(device_id) { - if(devices[device_id].settings.homewizard_id !== undefined ) { - var homewizard_id = devices[device_id].settings.homewizard_id; - homewizard.getDeviceData(homewizard_id, 'energylinks', function(callback) { - if (Object.keys(callback).length > 0) { - try { - module.exports.setAvailable({id: device_id}); - var energy_current_cons = ( callback[0].used.po ); // WATTS Energy used JSON $energylink[0]['used']['po'] - var energy_current_prod = ( callback[0].s1.po ); // WATTS Energy produced via S1 $energylink[0]['s1']['po'] - var energy_daytotal_cons = ( callback[0].used.dayTotal ); // KWH Energy used JSON $energylink[0]['used']['po'] - var energy_daytotal_prod = ( callback[0].s1.dayTotal ); // KWH Energy produced via S1 $energylink[0]['s1']['po'] - var gas_daytotal_cons = ( callback[0].gas.dayTotal ); // m3 Energy produced via S1 $energylink[0]['gas']['dayTotal'] - - - // Consumed elec current - module.exports.realtime( { id: device_id }, "measure_power.used", energy_current_cons ); - // Consumed elec total day - module.exports.realtime( { id: device_id }, "meter_power.used", energy_daytotal_cons ); - // Produced elec current - module.exports.realtime( { id: device_id }, "measure_power.s1", energy_current_prod ); - // Produced elec total day - module.exports.realtime( { id: device_id }, "meter_power.s1", energy_daytotal_prod ); - // Consumed gas - module.exports.realtime( { id: device_id }, "meter_gas", gas_daytotal_cons ); - - // Trigger flows - if (energy_current_cons != devices[device_id].last_measure_power_used) { - console.log("Current Power - "+ energy_current_cons); - Homey.manager('flow').triggerDevice('power_used_changed', { power_used: energy_current_cons }, null, { id: device_id } ); - } - if (energy_current_prod != devices[device_id].last_measure_power_s1) { - console.log("Current S1 - "+ energy_current_prod); - Homey.manager('flow').triggerDevice('power_s1_changed', { power_s1: energy_current_prod }, null, { id: device_id } ); - } - if (energy_daytotal_cons != devices[device_id].last_meter_power_used) { - console.log("Used Daytotal- "+ energy_daytotal_cons); - Homey.manager('flow').triggerDevice('meter_power_used_changed', { power_daytotal_used: energy_daytotal_cons }, null, { id: device_id }); - } - if (energy_daytotal_prod != devices[device_id].last_meter_power_s1) { - console.log("S1 Daytotal- "+ energy_daytotal_prod); - Homey.manager('flow').triggerDevice('meter_power_s1_changed', { power_daytotal_s1: energy_daytotal_prod }, null, { id: device_id }); - } - } - catch(err) { - // Error with Energylink no data in Energylink - console.log ("No Energylink found"); - module.exports.setUnavailable({id: device_id}, "No Energylink found" ); - } - } - }); - } else { - Homey.log('Removed Energylink '+ device_id +' (wrong settings)'); - module.exports.setUnavailable({id: device_id}, "No Energylink found" ); - clearInterval(refreshIntervalId); - } -} +module.exports = HomeWizardEnergyLink; diff --git a/drivers/energylink/pair/start.html b/drivers/energylink/pair/start.html index 9308b3ee..8132131a 100755 --- a/drivers/energylink/pair/start.html +++ b/drivers/energylink/pair/start.html @@ -35,7 +35,7 @@ settings: { 'homewizard_id': device.settings.homewizard_id, }, - name: device.name + name: "EnergyLink" }, function( err, result ){ if( err ) return console.error(err); Homey.done(); @@ -43,7 +43,7 @@ }); Homey.on('error', function(message){ - $('#error').html('Error: '+message); + $('#error').html(Homey.__("settings.error")+message); $('#save').prop('disabled', false); }); } @@ -56,15 +56,6 @@ width: 150px; display:inline-block !important; } - input { - border: 1px solid #ccc; - padding: 3px; - } - button { - padding: 10px; - background-color: #ddd; - border: 1px solid #ccc; - } #error { color: red; } @@ -73,12 +64,12 @@

- +

- + diff --git a/drivers/heatlink/assets/icon.svg b/drivers/heatlink/assets/icon.svg new file mode 100644 index 00000000..14d493af --- /dev/null +++ b/drivers/heatlink/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drivers/heatlink/device.js b/drivers/heatlink/device.js new file mode 100644 index 00000000..6d53cf57 --- /dev/null +++ b/drivers/heatlink/device.js @@ -0,0 +1,263 @@ +'use strict'; + +const Homey = require('homey'); +const homewizard = require('../../includes/legacy/homewizard.js'); + +const debug = false; + +function callnewAsync(device_id, uri_part, { + timeout = 5000, + retries = 2, + retryDelay = 3000 +} = {}) { + + return new Promise((resolve, reject) => { + let attempts = 0; + + const attempt = () => { + attempts++; + + let finished = false; + const timeoutId = setTimeout(() => { + if (finished) return; + finished = true; + + if (attempts <= retries) { + return setTimeout(attempt, retryDelay); + } + + return reject(new Error(`Timeout after ${timeout}ms`)); + }, timeout); + + homewizard.callnew(device_id, uri_part, (err, result) => { + if (finished) return; + finished = true; + clearTimeout(timeoutId); + + if (err) { + if (attempts <= retries) { + return setTimeout(attempt, retryDelay); + } + return reject(err); + } + + return resolve(result); + }); + }; + + attempt(); + }); +} + +class HomeWizardHeatlink extends Homey.Device { + + + async retrySetTarget(homewizard_id, temperature, { + maxAttempts = 5, + delay = 3000 + } = {}) { + + const path = `/hl/0/settarget/${temperature}`; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + + try { + this.log(`Attempt ${attempt}/${maxAttempts}: ${path}`); + await callnewAsync(homewizard_id, path); + return true; // success + } + + catch (err) { + const msg = err?.message || String(err); + + // Circuit breaker open → wait and retry + if (msg.includes('circuit_open')) { + this.log(`Circuit open, retrying in ${delay}ms...`); + await new Promise(r => setTimeout(r, delay)); + continue; + } + + // Real error → stop immediately + this.log(`Non-retryable error: ${msg}`); + throw err; + } + } + + throw new Error('Failed after retries (circuit still open)'); + } + + + + async onInit() { + + this.log(`Heatlink init: ${this.getName()}`); + + this.startPolling(); + + this.registerCapabilityListener('target_temperature', async (temperature) => { + if (!temperature) return false; + + if (temperature < 5) temperature = 5; + else if (temperature > 35) temperature = 35; + + temperature = Math.round(temperature.toFixed(1) * 2) / 2; + + const homewizard_id = this.getSetting('homewizard_id'); + + try { + const ok = await this.retrySetTarget(homewizard_id, temperature); + + if (ok) { + this.log('settarget target_temperature -> true'); + await this.setStoreValue('setTemperature', temperature); + return true; + } + + } catch (err) { + this.log('ERR settarget target_temperature -> false'); + this.error(`Heatlink ${this.getName()} settarget failed: ${err.message}`); + + await this.setStoreValue('setTemperature', 0); + this.getStatus().catch(this.error); + return false; + } + }); + + + + } + + startPolling() { + + if (this.refreshIntervalId) { + clearInterval(this.refreshIntervalId); + } + + this.refreshIntervalId = setInterval(() => { + if (debug) this.log('--Heatlink Poll--'); + this.getStatus(); + }, 20000); // 20 sec + } + + async getStatus() { + + const homewizard_id = this.getSetting('homewizard_id'); + if (!homewizard_id) { + this.log('HW ID not found'); + return; + } + + try { + // ❗ getDeviceData is async (in-memory) + const callback = await homewizard.getDeviceData(homewizard_id, 'heatlinks'); + + if (!callback || Object.keys(callback).length === 0) { + if (debug) this.log('No heatlink data yet'); + return; + } + + this.setAvailable().catch(this.error); + + const promises = []; + + const rte = (callback[0].rte.toFixed(1) * 2) / 2; + const rsp = (callback[0].rsp.toFixed(1) * 2) / 2; + const tte = (callback[0].tte.toFixed(1) * 2) / 2; + const wte = (callback[0].wte.toFixed(1) * 2) / 2; + + if (this.getStoreValue('temperature') != rte) { + promises.push(this.setCapabilityValue('measure_temperature', rte).catch(this.error)); + this.setStoreValue('temperature', rte).catch(this.error); + } + + if (this.getStoreValue('thermTemperature') != rsp) { + if (this.getStoreValue('setTemperature') === 0) { + promises.push(this.setCapabilityValue('target_temperature', rsp).catch(this.error)); + } + this.setStoreValue('thermTemperature', rsp).catch(this.error); + } + + const override = await this.getStoreValue('setTemperature'); + + // If override is active but Heatlink reports a different tte → clear override + if (override > 0 && tte !== override) { + this.log('Heatlink rejected override, clearing override flag'); + await this.setStoreValue('setTemperature', 0).catch(this.error); + + // Immediately sync Homey to real Heatlink value + promises.push(this.setCapabilityValue('target_temperature', rsp).catch(this.error)); + } + + + if (override > 0) { + // Override active → Homey must follow the override + if (tte === override) { + // Heatlink has accepted the override + promises.push(this.setCapabilityValue('target_temperature', tte).catch(this.error)); + } else { + // Heatlink has NOT accepted the override yet + // Do NOT overwrite Homey with stale tte + // Let polling try again later + } + } else { + // No override → follow thermostat setpoint (rsp) + promises.push(this.setCapabilityValue('target_temperature', rsp).catch(this.error)); + } + + + if (!this.hasCapability('measure_temperature.boiler')) { + promises.push(this.addCapability('measure_temperature.boiler').catch(this.error)); + } else { + promises.push(this.setCapabilityValue('measure_temperature.boiler', wte).catch(this.error)); + } + + if (!this.hasCapability('measure_temperature.heatlink')) { + promises.push(this.addCapability('measure_temperature.heatlink').catch(this.error)); + } else { + promises.push(this.setCapabilityValue('measure_temperature.heatlink', tte).catch(this.error)); + } + + if (!this.hasCapability('central_heating_flame')) { + promises.push(this.addCapability('central_heating_flame').catch(this.error)); + } else { + promises.push(this.setCapabilityValue('central_heating_flame', callback[0].heating === 'on').catch(this.error)); + } + + if (!this.hasCapability('central_heating_pump')) { + promises.push(this.addCapability('central_heating_pump').catch(this.error)); + } else { + promises.push(this.setCapabilityValue('central_heating_pump', callback[0].pump === 'on').catch(this.error)); + } + + if (!this.hasCapability('warm_water')) { + promises.push(this.addCapability('warm_water').catch(this.error)); + } else { + promises.push(this.setCapabilityValue('warm_water', callback[0].dhw === 'on').catch(this.error)); + } + + if (!this.hasCapability('measure_pressure')) { + promises.push(this.addCapability('measure_pressure').catch(this.error)); + } else { + promises.push(this.setCapabilityValue('measure_pressure', callback[0].wp).catch(this.error)); + } + + await Promise.allSettled(promises); + + } catch (error) { + this.log('Heatlink data error', error); + this.setUnavailable(error).catch(this.error); + } + } + + onDeleted() { + const deviceId = this.getData().id; + homewizard.removeDevice(deviceId); + + if (this.refreshIntervalId) { + clearInterval(this.refreshIntervalId); + } + this.log(`Heatlink deleted: ${this.getName()}`); + } +} + +module.exports = HomeWizardHeatlink; diff --git a/drivers/heatlink/driver.compose.json b/drivers/heatlink/driver.compose.json new file mode 100644 index 00000000..dc2c461a --- /dev/null +++ b/drivers/heatlink/driver.compose.json @@ -0,0 +1,66 @@ +{ + "name": { + "en": "Heatlink", + "nl": "Heatlink" + }, + "images": { + "large": "drivers/heatlink/assets/images/large.jpg", + "small": "drivers/heatlink/assets/images/small.jpg" + }, + "platforms": [ + "local" + ], + "class": "thermostat", + "platforms": [ "local"], + "capabilities": [ + "measure_temperature", + "target_temperature", + "measure_temperature.heatlink", + "measure_temperature.boiler", + "central_heating_pump", + "central_heating_flame", + "warm_water", + "measure_pressure" + ], + "capabilitiesOptions": { + "measure_temperature.heatlink": { + "title": { + "en": "Heatlink target temperature", + "nl": "Heatlink doel temperatuur" + } + }, + "measure_temperature.boiler": { + "title": { + "en": "Boiler temperature", + "nl": "Ketel temperatuur" + } + }, + "measure_pressure": { + "decimals": 1, + "title": { + "en": "Water pressure", + "nl": "Waterdruk" + }, + "units": { + "en": "Bar", + "nl": "Bar" + } + } + }, + "pair": [ + { + "id": "start" + }, + { + "id": "list_my_devices", + "template": "list_devices", + "navigation": { + "next": "add_my_devices" + } + }, + { + "id": "add_my_devices", + "template": "add_devices" + } + ] +} \ No newline at end of file diff --git a/drivers/heatlink/driver.js b/drivers/heatlink/driver.js index 3ae92c70..7578b387 100755 --- a/drivers/heatlink/driver.js +++ b/drivers/heatlink/driver.js @@ -1,192 +1,131 @@ -var devices = []; -var homewizard = require('./../../includes/homewizard.js'); -var refreshIntervalId = 0; - -// SETTINGS -module.exports.settings = function( device_data, newSettingsObj, oldSettingsObj, changedKeysArr, callback ) { - Homey.log ('Changed settings: ' + JSON.stringify(device_data) + ' / ' + JSON.stringify(newSettingsObj) + ' / old = ' + JSON.stringify(oldSettingsObj)); - try { - changedKeysArr.forEach(function (key) { - devices[device_data.id].settings[key] = newSettingsObj[key]; - }); - callback(null, true); - } catch (error) { - callback(error); - } -}; - -module.exports.pair = function( socket ) { - socket.on('get_homewizards', function () { - homewizard.getDevices(function(homewizard_devices) { - - Homey.log(homewizard_devices); - var hw_devices = {}; - Object.keys(homewizard_devices).forEach(function(key) { - hw_devices[key] = homewizard_devices[key]; - }); - - socket.emit('hw_devices', hw_devices); - }); - }); - - socket.on('manual_add', function (device, callback) { - if (device.settings.homewizard_id.indexOf('HW_') === -1 && device.settings.homewizard_id.indexOf('HW') === 0) { - //true - Homey.log('HeatLink added ' + device.data.id); - devices[device.data.id] = { - id: device.data.id, - name: device.name, - settings: device.settings, - }; - callback( null, devices ); - socket.emit("success", device); - startPolling(); - } else { - socket.emit("error", "No valid HomeWizard found, re-pair if problem persists"); +'use strict'; + +const Homey = require('homey'); + +// const { ManagerDrivers } = require('homey'); +// const driver = ManagerDrivers.getDriver('homewizard'); +const devices = {}; +const homewizard = require('../../includes/legacy/homewizard.js'); + +let homewizard_devices; + +function callnewAsync(device_id, uri_part, { + timeout = 5000, + retries = 2, + retryDelay = 3000 +} = {}) { + + return new Promise((resolve, reject) => { + + let attempts = 0; + + const attempt = () => { + attempts++; + + let timeoutId; + let finished = false; + + // Timeout mechanisme + timeoutId = setTimeout(() => { + if (finished) return; + finished = true; + + if (attempts <= retries) { + return setTimeout(attempt, retryDelay); } - }); - - socket.on('disconnect', function(){ - console.log("User aborted pairing, or pairing is finished"); - }); + + return reject(new Error(`Timeout after ${timeout}ms`)); + }, timeout); + + // De echte call + homewizard.callnew(device_id, uri_part, (err, result) => { + if (finished) return; + finished = true; + clearTimeout(timeoutId); + + if (err) { + if (attempts <= retries) { + return setTimeout(attempt, retryDelay); + } + return reject(err); + } + + return resolve(result); + }); + }; + + attempt(); + }); } -module.exports.init = function(devices_data, callback) { - devices_data.forEach(function initdevice(device) { - Homey.log('add device: ' + JSON.stringify(device)); - devices[device.id] = device; - module.exports.getSettings(device, function(err, settings){ - devices[device.id].settings = settings; - }); - - }); - if (Object.keys(devices).length > 0) { - startPolling(); - } - Homey.log('Heatlink driver init done'); - - callback (null, true); -}; - -module.exports.deleted = function( device_data ) { - clearInterval(refreshIntervalId); - Homey.log("--Stopped Polling--"); - devices = []; - Homey.log('deleted: ' + JSON.stringify(device_data)); -}; - - -module.exports.capabilities = { - - measure_temperature: { - get: function (device, callback) { - if (device instanceof Error) return callback(device); - console.log("measure_temperature"); - getStatus(device.id); - newvalue = devices[device.id].temperature; - // Callback ambient temperature - callback(null, newvalue); - } - }, - target_temperature: { - - get: function (device, callback) { - if (device instanceof Error) return callback(device); - console.log("target_temperature:get"); - // Retrieve updated data - getStatus(device.id); - if (devices[device.id].setTemperature !== 0) { - newvalue = devices[device.id].setTemperature; - } else { - newvalue = devices[device.id].thermTemperature; - } - callback(null, newvalue); - }, - - set: function (device, temperature, callback) { - if (device instanceof Error) return callback(device); - // Catch faulty trigger and max/min temp - if (!temperature) { - callback(true, temperature); +class HomeWizardHeatlink extends Homey.Driver { + + onInit() { + //this.log('HomeWizard Heatlink has been inited'); + + this.homey.flow.getActionCard('heatlink_off') + // .register() + .registerRunListener(async (args) => { + if (!args.device) return false; + + try { + await callnewAsync(args.device.getData().id, '/hl/0/settarget/0'); + this.log('flowCardAction heatlink_off -> returned true'); + return true; + } catch (err) { + this.log('ERR flowCardAction heatlink_off -> returned false: ', err.message); return false; } - else if (temperature < 5) { - temperature = 5; - } - else if (temperature > 35) { - temperature = 35; - } - temperature = Math.round(temperature.toFixed(1) * 2) / 2; - var url = '/hl/0/settarget/'+temperature; - console.log(url); - var homewizard_id = devices[device.id].settings.homewizard_id; - homewizard.call(homewizard_id, '/hl/0/settarget/'+temperature, function(err, response) { - console.log(err); - if (callback) callback(err, temperature); - }); - } - }, -}; - -function getStatus(device_id) { - if(devices[device_id].settings.homewizard_id !== undefined ) { - var homewizard_id = devices[device_id].settings.homewizard_id; - homewizard.getDeviceData(homewizard_id, 'heatlinks', function(callback) { - if (Object.keys(callback).length > 0) { - try { - var rte = (callback[0].rte.toFixed(1) * 2) / 2; - var rsp = (callback[0].rsp.toFixed(1) * 2) / 2; - var tte = (callback[0].tte.toFixed(1) * 2) / 2; - - //Check current temperature - if (devices[device_id].temperature != rte) { - console.log("New RTE - "+ rte); - module.exports.realtime( { id: device_id }, "measure_temperature", rte ); - devices[device_id].temperature = rte; - } else { - console.log("RTE: no change"); - } - - //Check thermostat temperature - if (devices[device_id].thermTemperature != rsp) { - console.log("New RSP - "+ rsp); - if (devices[device_id].setTemperature === 0) { - module.exports.realtime( { id: device_id }, "target_temperature", rsp ); - } - devices[device_id].thermTemperature = rsp; - } else { - console.log("RSP: no change"); - } - - //Check heatlink set temperature - if (devices[device_id].setTemperature != tte) { - console.log("New TTE - "+ tte); - if (tte > 0) { - module.exports.realtime( { id: device_id }, "target_temperature", tte ); - } else { - module.exports.realtime( { id: device_id }, "target_temperature", devices[device_id].thermTemperature ); - } - devices[device_id].setTemperature = tte; - } else { - console.log("TTE: no change"); - } - } catch(err) { - console.log ("Heatlink data corrupt"); - } - } + }); + } + + async onPair(socket) { + // socket.on('get_homewizards', function () { + await socket.setHandler('get_homewizards', () => { + + // homewizard_devices = driver.getDevices(); + homewizard_devices = this.homey.drivers.getDriver('homewizard').getDevices(); + + homewizard.getDevices((homewizard_devices) => { + const hw_devices = {}; + + Object.keys(homewizard_devices).forEach((key) => { + hw_devices[key] = homewizard_devices[key]; }); - } else { - Homey.log('Removed Heatlink '+ device_id +' (old settings)'); - module.exports.setUnavailable({id: device_id}, "No Heatlink found" ); - clearInterval(refreshIntervalId); - } - } - - function startPolling() { - refreshIntervalId = setInterval(function () { - Homey.log("--Start Heatlink Polling-- "); - Object.keys(devices).forEach(function (device_id) { - getStatus(device_id); + + this.log(hw_devices); + socket.emit('hw_devices', hw_devices); + }); - }, 1000 * 10); - } \ No newline at end of file + }); + + await socket.setHandler('manual_add', (device) => { + + if (device.settings.homewizard_id.indexOf('HW_') === -1 && device.settings.homewizard_id.indexOf('HW') === 0) { + // true + this.log(`HeatLink added ${device.data.id}`); + devices[device.data.id] = { + id: device.data.id, + name: device.name, + settings: device.settings, + }; + // callback( null, devices ); + socket.emit('success', device); + return devices; + + } + socket.emit('error', 'No valid HomeWizard found, re-pair if problem persists'); + + }); + + socket.setHandler('disconnect', () => { + this.log('User aborted pairing, or pairing is finished'); + }); + + } + +} + +module.exports = HomeWizardHeatlink; + + diff --git a/drivers/heatlink/pair/start.html b/drivers/heatlink/pair/start.html index 4e34acb4..97664d86 100755 --- a/drivers/heatlink/pair/start.html +++ b/drivers/heatlink/pair/start.html @@ -3,9 +3,17 @@ Homey.emit('get_homewizards'); Homey.on('hw_devices', function(hw_devices){ - $.each(hw_devices, function( index, value ) { - $('#homewizard_select').append($("'); + $.each(hw_devices, function( index, value ) { + $('#homewizard_select').append($("