homebridge-echonet-lite-plus 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/config.schema.json +36 -0
- package/dist/index.js +5 -0
- package/dist/logger.js +22 -0
- package/dist/mra.js +91 -0
- package/dist/platform.js +618 -0
- package/dist/settings.js +5 -0
- package/dist/types.js +2 -0
- package/homebridge-ui/public/index.html +103 -0
- package/homebridge-ui/public/style.css +16 -0
- package/homebridge-ui/server.js +61 -0
- package/package.json +54 -0
- package/work/MRA_v1.4.0.zip +0 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
- Kept disabled devices out of Apple Home while retaining the complete discovered-device registry in the settings UI.
|
|
6
|
+
- Newly discovered devices now remain disabled until the user explicitly publishes them to HomeKit.
|
|
7
|
+
- Added bilingual per-device publication, naming and property controls to the custom configuration UI.
|
|
8
|
+
- Added reliable rediscovery handling with save/restart guidance and cached-list fallbacks.
|
|
9
|
+
- Added regression coverage for all-devices-off operation and Apple Home/extended meter-mode transitions.
|
|
10
|
+
- Removed routine custom-UI diagnostic logging and no longer reports a missing first-run device cache as an error.
|
|
11
|
+
|
|
12
|
+
## 0.6.6
|
|
13
|
+
|
|
14
|
+
- Aligned custom UI server loading with the current `@homebridge/plugin-ui-utils` 2.2.5 dynamic-import pattern used by the reference plugins.
|
|
15
|
+
- Added an IPC integration test that starts the real UI server and verifies a seven-device `/devices` response.
|
|
16
|
+
- Added a cache integration test that verifies all seven detected devices are written even when some are not published to HomeKit.
|
|
17
|
+
|
|
18
|
+
## 0.6.5
|
|
19
|
+
|
|
20
|
+
- Fixed a regression where opt-in filtering ran before the settled ECHONET facility list was saved, causing disabled devices to disappear from the settings page.
|
|
21
|
+
- Kept every detected device in the configuration list while applying opt-in filtering only to HomeKit publication.
|
|
22
|
+
- Added an Info log with the number of devices written to the detected-device cache.
|
|
23
|
+
|
|
24
|
+
## 0.6.4
|
|
25
|
+
|
|
26
|
+
- Added server-pushed device-list and restart-status events for Homebridge UI environments where custom request responses do not resolve reliably.
|
|
27
|
+
- Pushed the refreshed device cache to the settings page automatically after rediscovery.
|
|
28
|
+
|
|
29
|
+
## 0.6.3
|
|
30
|
+
|
|
31
|
+
- Preserved the complete configured device list when the live UI device-cache request is delayed or unavailable.
|
|
32
|
+
- Merged saved device settings, Homebridge accessory cache and live discovery results instead of falling back to published accessories only.
|
|
33
|
+
|
|
34
|
+
## 0.6.2
|
|
35
|
+
|
|
36
|
+
- Prevented the custom settings page from waiting indefinitely for UI-server status requests.
|
|
37
|
+
- Rendered the settings form immediately and loaded restart/discovery status asynchronously with explicit timeouts.
|
|
38
|
+
|
|
39
|
+
## 0.6.1
|
|
40
|
+
|
|
41
|
+
- Changed newly discovered devices to opt-in: they remain off until explicitly enabled for HomeKit.
|
|
42
|
+
- Blocked rediscovery while saved settings are waiting for a child bridge restart.
|
|
43
|
+
- Added bilingual guidance for saving and restarting after changing meter compatibility or device publication settings.
|
|
44
|
+
|
|
45
|
+
## 0.6.0
|
|
46
|
+
|
|
47
|
+
- Added periodic GET polling for devices that do not send state-change notifications.
|
|
48
|
+
- Added HomeKit mappings for air-conditioning ventilators, shutters, water heaters, bathroom heater/dryers, floor heating, hybrid water heaters and JEM-A/HA switches.
|
|
49
|
+
- Added read-only extended measurements for residential solar generation and EV charge/discharge equipment.
|
|
50
|
+
- Kept non-standard meter and energy values hidden in Apple Home compatible mode instead of representing them as unrelated HomeKit sensor types.
|
|
51
|
+
- Added safe handling for detected but unmapped device classes.
|
|
52
|
+
- Improved MRA decoding and encoding when a device defines multiple release-specific schemas for the same EPC.
|
|
53
|
+
|
|
54
|
+
## 0.5.6
|
|
55
|
+
|
|
56
|
+
- Added direct ECHONET Lite communication over UDP/3610 without MQTT.
|
|
57
|
+
- Added MRA 1.4.0 based property discovery, decoding and encoding.
|
|
58
|
+
- Added HomeKit mappings for air conditioners, lighting, temperature and humidity sensors, locks and fans.
|
|
59
|
+
- Added Apple Home compatible and extended display modes for electricity, distribution board, water and gas meters.
|
|
60
|
+
- Added a bilingual English/Japanese custom configuration UI with device discovery and per-device property selection.
|
|
61
|
+
- Limited frequent INF/push packet logging to Debug level.
|
|
62
|
+
- Prevented repeated disk and Homebridge cache writes for unchanged push data.
|
|
63
|
+
- Fixed ECHONET property-map parsing, empty SET response handling and air-conditioner target-temperature conversion.
|
|
64
|
+
- Added automated tests for MRA conversion, property maps, meter compatibility and SET response handling.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Homebridge ECHONET Lite Plus contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Homebridge ECHONET Lite Plus
|
|
2
|
+
|
|
3
|
+
[GitHub](https://github.com/sylpied/homebridge-echonet-lite-plus) · [Issues](https://github.com/sylpied/homebridge-echonet-lite-plus/issues) · [ECHONET公式MRA](https://echonet.jp/spec_mra_rr3/)
|
|
4
|
+
|
|
5
|
+
LAN内の ECHONET Lite 機器と直接通信し、HomeKit に公開するHomebridge動的プラットフォームです。MQTTブローカーは不要です。
|
|
6
|
+
|
|
7
|
+
ECHONET ConsortiumのMachine Readable Appendix Version 1.4.0を同梱し、EPCの名称、アクセス規則、数値型、符号、倍率、状態enumの読書きをMRAから解決します。HomeKitサービスへの割当ては、MRAで変換したプロパティ名を使用します。
|
|
8
|
+
|
|
9
|
+
MRAはECHONET Consortiumが公開する参考データです。本プラグインのMITライセンスはMRA自体の著作権や利用条件を変更するものではありません。
|
|
10
|
+
|
|
11
|
+
## 動作要件とインストール
|
|
12
|
+
|
|
13
|
+
- Node.js 18以降
|
|
14
|
+
- Homebridge 1.6以降(Homebridge 2.xを含む)
|
|
15
|
+
- HomebridgeとECHONET Lite機器が同じLANからUDP/3610で通信できること
|
|
16
|
+
|
|
17
|
+
Homebridge UIのプラグイン検索から `homebridge-echonet-lite-plus` をインストールします。npmを直接使用する場合は次のコマンドです。
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install -g homebridge-echonet-lite-plus
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
インストール後、プラグイン設定を開いて対象ネットワークを確認し、Child Bridgeを再起動してください。既存バージョンから更新する場合も設定は引き継がれます。
|
|
24
|
+
|
|
25
|
+
## 対応方針
|
|
26
|
+
|
|
27
|
+
ECHONET Liteの機器クラスとプロパティを、意味が一致するHomeKitサービスへ割り当てます。HomeKitに対応する型がない計測値を、照度や湿度など別の値へ偽装することはありません。
|
|
28
|
+
|
|
29
|
+
| ECHONET Lite機器 | EOJ | HomeKitでの扱い |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| 家庭用エアコン | `0130` | Heater/Cooler、運転、モード、室温、設定温度 |
|
|
32
|
+
| 換気扇・空調換気扇・空気清浄機 | `0133` / `0134` / `0135` | Fan |
|
|
33
|
+
| 電動雨戸・シャッター | `0263` | Window Covering、開閉、停止、開度(機器が対応する場合) |
|
|
34
|
+
| 電気温水器・瞬間式給湯器 | `026B` / `0272` | 風呂自動Switch |
|
|
35
|
+
| 電気錠 | `026F` | Lock Mechanism |
|
|
36
|
+
| 浴室暖房乾燥機 | `0273` | Fan、換気・予備暖房・暖房・乾燥・涼風Switch |
|
|
37
|
+
| 床暖房 | `027B` | Thermostat、運転、現在温度、設定温度 |
|
|
38
|
+
| ハイブリッド給湯機 | `02A6` | 自動沸き上げSwitch |
|
|
39
|
+
| JEM-A/HA対応スイッチ | `05FD` | Switch |
|
|
40
|
+
| 一般・単機能照明 | `0290` / `0291` | Lightbulb、電源、明るさ |
|
|
41
|
+
| 温度・湿度センサー | `0011` / `0012` | 専用センサー |
|
|
42
|
+
| 電力量計・分電盤・スマートメーター | `0280` / `0287` / `0288` | Eve互換の電力・電流・積算電力量 |
|
|
43
|
+
| 住宅用太陽光発電 | `0279` | 拡張モードで発電電力・積算発電量・積算売電量(読み取り専用) |
|
|
44
|
+
| 電気自動車充放電器 | `027E` | 拡張モードで充放電電力・残量・使用電力量(読み取り専用) |
|
|
45
|
+
| 水流量メーター | `0281` | 積算水道使用量、検針データ異常、標準Leak Sensorによる異常通知 |
|
|
46
|
+
| ガスメーター | `0282` | 積算ガス使用量 |
|
|
47
|
+
|
|
48
|
+
Appleのホームアプリには電力・発電量・EV残量・水道・ガス使用量の標準表示がありません。既定のAppleホーム互換モードでは、意味の異なるタイルへ偽装せず非表示にします。拡張モードでは正しい単位のカスタムCharacteristicとして公開し、Eveなど対応するHomeKitアプリから参照できます。
|
|
49
|
+
|
|
50
|
+
ECHONET Liteとして存在していても、HomeKit上で安全かつ自然に表現できない機器クラスは検出一覧にのみ表示し、対応マッピングを実装するまでHomeKitへは公開しません。
|
|
51
|
+
|
|
52
|
+
## 構成
|
|
53
|
+
|
|
54
|
+
HomebridgeをECHONET Lite機器と同じLANで動かし、設定画面で自動探索を有効にします。複数NIC環境では対象ネットワークを指定してください。
|
|
55
|
+
|
|
56
|
+
## 設定
|
|
57
|
+
|
|
58
|
+
- `targetNetwork`: 使用するIPv4ネットワーク。複数NIC環境で指定します。
|
|
59
|
+
- `autoDiscovery`: マルチキャストによる機器探索です。
|
|
60
|
+
- `knownDeviceIps`: 自動探索へ応答しない機器のIPアドレスです。
|
|
61
|
+
- `includeDevices` / `excludeDevices`: IP、EOJ、内部識別子によるフィルターです。
|
|
62
|
+
- `deviceSettings`: 検出済み機器ごとのHomeKit公開、表示名、表示・操作項目の選択です。通常はカスタムUIから設定します。
|
|
63
|
+
- `meterDisplayMode`: `appleHome`(推奨)はAppleホーム非対応の計測タイルを隠し、`extended`はEveなど対応アプリ向けのカスタム計測値を公開します。
|
|
64
|
+
- `pollInterval`: INF通知を送らない機器の状態を定期取得します。既定は60秒、最短は30秒です。大量の機器がある場合は5分または無効を選べます。
|
|
65
|
+
- `logLevel`: 通常は `info` を推奨します。
|
|
66
|
+
|
|
67
|
+
通常ログでは接続、機器追加、HomeKitからの操作、警告、エラーだけを表示します。ECHONET LiteのINF由来を含む頻繁なpushデータと検出詳細は `debug` を選んだ場合だけ表示します。
|
|
68
|
+
|
|
69
|
+
項目のチェックを外すと、そのEPCとの同期・操作を止めます。ただしHomeKitサービスで必須のCharacteristicはタイル上に残る場合があります。表示名の変更はChild Bridge再起動後に反映されますが、Appleホーム側で変更済みの名前は保持されることがあります。
|
|
70
|
+
|
|
71
|
+
新しく検出した機器は安全のため初期状態で「HomeKitに表示」がOFFです。追加したい機器だけをONにして設定を保存し、Child Bridgeを再起動してください。設定変更から再起動までの間は、稼働中の設定との食い違いを防ぐため「機器を再探索」を実行できません。
|
|
72
|
+
|
|
73
|
+
## 実機なしでの確認
|
|
74
|
+
|
|
75
|
+
ECHONET Liteエミュレーターを同じLANまたはローカル環境で動かし、探索、Get、SetC、INFを確認します。実機確認時は、機器ごとのプロパティマップに含まれる読み書き可能EPCだけを利用してください。
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "ECHONETLitePlus",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"customUi": true,
|
|
6
|
+
"customUiPath": "./homebridge-ui",
|
|
7
|
+
"headerDisplay": "## ECHONET Lite Plus\nDirectly discovers ECHONET Lite devices on your LAN and exposes them to HomeKit. MQTT is not required. \nLAN内のECHONET Lite機器を直接探索し、HomeKitへ追加します。MQTTは不要です。",
|
|
8
|
+
"footerDisplay": "Set **Log Level / ログレベル** to Debug only while troubleshooting, then return it to Info. \n問題調査時だけDebugへ変更し、確認後はInfoへ戻してください。",
|
|
9
|
+
"schema": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"properties": {
|
|
12
|
+
"name": { "title": "Platform Name / プラットフォーム名", "type": "string", "default": "ECHONET Lite", "required": true },
|
|
13
|
+
"targetNetwork": { "title": "Network Interface / 使用ネットワーク", "description": "Optional IPv4 interface address for hosts with multiple adapters. / 複数NIC環境で使用するIPv4インターフェースアドレス。", "type": "string", "placeholder": "192.168.1.10" },
|
|
14
|
+
"autoDiscovery": { "title": "Automatic Discovery / 自動探索", "description": "Discover devices using ECHONET Lite multicast. / マルチキャストで機器を探索します。", "type": "boolean", "default": true },
|
|
15
|
+
"knownDeviceIps": { "title": "Known Device IP Addresses / 既知の機器IP", "description": "Devices that do not respond to multicast discovery. / 自動探索へ応答しない機器を直接探索します。", "type": "array", "uniqueItems": true, "default": [], "items": { "title": "IP Address / IPアドレス", "type": "string", "placeholder": "192.168.1.100" } },
|
|
16
|
+
"includeDevices": { "title": "Include Devices / 追加する機器", "description": "Optional extra filter by IP, EOJ, or internal ID. Discovered devices must also be enabled explicitly. / IP、EOJ、内部IDによる追加フィルターです。検出機器は個別に有効化する必要があります。", "type": "array", "uniqueItems": true, "default": [], "items": { "title": "Device / 機器", "type": "string" } },
|
|
17
|
+
"excludeDevices": { "title": "Exclude Devices / 除外する機器", "description": "Specify an IP, EOJ, or internal ID. / IP、EOJ、内部IDを指定します。", "type": "array", "uniqueItems": true, "default": [], "items": { "title": "Device / 機器", "type": "string" } },
|
|
18
|
+
"deviceSettings": { "title": "Discovered Device Settings / 検出機器設定", "type": "array", "default": [], "items": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "enabled": { "type": "boolean", "default": false }, "properties": { "type": "array", "items": { "type": "string" } } }, "required": ["id"] } },
|
|
19
|
+
"meterDisplayMode": { "title": "Meter Display / メーター表示", "type": "string", "default": "appleHome", "oneOf": [{"title":"Apple Home Compatible / Appleホーム互換","enum":["appleHome"]},{"title":"Extended Apps (Eve) / 拡張アプリ(Eve)","enum":["extended"]}] },
|
|
20
|
+
"pollInterval": { "title": "State Update Interval / 状態更新間隔", "description": "Periodically requests selected readable properties. / 選択した読取可能プロパティを定期取得します。", "type": "number", "default": 60, "oneOf": [{"title":"Off / 無効","enum":[0]},{"title":"30 seconds / 30秒","enum":[30]},{"title":"60 seconds (Recommended) / 60秒(推奨)","enum":[60]},{"title":"5 minutes / 5分","enum":[300]}] },
|
|
21
|
+
"logLevel": { "title": "Log Level / ログレベル", "description": "Push packets are shown only at Debug level. Info is recommended. / push電文はDebugでのみ表示します。通常はInfoを推奨します。", "type": "string", "default": "info", "oneOf": [
|
|
22
|
+
{ "title": "Error / エラーのみ", "enum": ["error"] },
|
|
23
|
+
{ "title": "Warning / 警告以上", "enum": ["warn"] },
|
|
24
|
+
{ "title": "Info (Recommended) / 通常(推奨)", "enum": ["info"] },
|
|
25
|
+
{ "title": "Debug / デバッグ(全push表示)", "enum": ["debug"] }
|
|
26
|
+
] }
|
|
27
|
+
},
|
|
28
|
+
"required": ["name"]
|
|
29
|
+
},
|
|
30
|
+
"layout": [
|
|
31
|
+
{ "key": "name" },
|
|
32
|
+
{ "type": "fieldset", "title": "Network & Discovery / ネットワークと探索", "expandable": true, "expanded": true, "items": ["targetNetwork", "autoDiscovery", "knownDeviceIps", "meterDisplayMode"] },
|
|
33
|
+
{ "type": "fieldset", "title": "Device Filters / 機器フィルター", "expandable": true, "expanded": false, "items": ["includeDevices", "excludeDevices"] },
|
|
34
|
+
{ "type": "fieldset", "title": "Updates & Logging / 更新とログ", "expandable": true, "expanded": false, "items": ["pollInterval", "logLevel"] }
|
|
35
|
+
]
|
|
36
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const platform_1 = require("./platform");
|
|
4
|
+
const settings_1 = require("./settings");
|
|
5
|
+
exports.default = (api) => api.registerPlatform(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, platform_1.EchonetLitePlatform);
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PluginLogger = void 0;
|
|
4
|
+
const weight = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
5
|
+
class PluginLogger {
|
|
6
|
+
log;
|
|
7
|
+
level;
|
|
8
|
+
constructor(log, level = 'info') {
|
|
9
|
+
this.log = log;
|
|
10
|
+
this.level = level;
|
|
11
|
+
}
|
|
12
|
+
enabled(level) { return weight[level] <= weight[this.level]; }
|
|
13
|
+
error(message, ...args) { if (this.enabled('error'))
|
|
14
|
+
this.log.error(message, ...args); }
|
|
15
|
+
warn(message, ...args) { if (this.enabled('warn'))
|
|
16
|
+
this.log.warn(message, ...args); }
|
|
17
|
+
info(message, ...args) { if (this.enabled('info'))
|
|
18
|
+
this.log.info(message, ...args); }
|
|
19
|
+
debug(message, ...args) { if (this.enabled('debug'))
|
|
20
|
+
this.log.debug(message, ...args); }
|
|
21
|
+
}
|
|
22
|
+
exports.PluginLogger = PluginLogger;
|
package/dist/mra.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MraRepository = void 0;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const adm_zip_1 = __importDefault(require("adm-zip"));
|
|
9
|
+
class MraRepository {
|
|
10
|
+
devices = new Map();
|
|
11
|
+
definitions;
|
|
12
|
+
constructor(zipPath = node_path_1.default.resolve(__dirname, '../work/MRA_v1.4.0.zip')) {
|
|
13
|
+
const zip = new adm_zip_1.default(zipPath);
|
|
14
|
+
const json = (name) => JSON.parse(zip.readAsText(name));
|
|
15
|
+
this.definitions = json('MRA_v1.4.0/definitions/definitions.json').definitions;
|
|
16
|
+
for (const entry of zip.getEntries().filter(e => /^MRA_v1\.4\.0\/devices\/0x[0-9A-Fa-f]{4}\.json$/.test(e.entryName))) {
|
|
17
|
+
const device = JSON.parse(entry.getData().toString('utf8'));
|
|
18
|
+
this.devices.set(device.eoj.toLowerCase().replace(/^0x/, ''), device);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
device(eoj) { return this.devices.get(eoj.slice(0, 4).toLowerCase()); }
|
|
22
|
+
candidates(eoj, epc) { return this.device(eoj)?.elProperties.filter(p => p.epc.toLowerCase() === `0x${epc.toLowerCase()}`) ?? []; }
|
|
23
|
+
describe(eoj, epc) {
|
|
24
|
+
const property = this.candidates(eoj, epc)[0];
|
|
25
|
+
return property ? { name: property.shortName, ja: property.propertyName.ja, en: property.propertyName.en } : undefined;
|
|
26
|
+
}
|
|
27
|
+
isReadable(eoj, epc) { return this.candidates(eoj, epc).some(p => p.accessRule.get !== 'notApplicable'); }
|
|
28
|
+
isWritable(eoj, epc) { return this.candidates(eoj, epc).some(p => p.accessRule.set !== 'notApplicable'); }
|
|
29
|
+
decode(eoj, epc, raw) {
|
|
30
|
+
const candidates = this.candidates(eoj, epc);
|
|
31
|
+
if (!candidates.length)
|
|
32
|
+
return undefined;
|
|
33
|
+
const matched = candidates.map(property => ({ property, decoded: this.tryDecodeSchema(property.data, raw) })).find(x => x.decoded.matched);
|
|
34
|
+
const property = matched?.property ?? candidates[0];
|
|
35
|
+
return { epc, name: property.shortName, value: matched?.decoded.value ?? raw, raw, readable: candidates.some(p => p.accessRule.get !== 'notApplicable'), writable: candidates.some(p => p.accessRule.set !== 'notApplicable'), observable: candidates.some(p => p.accessRule.inf !== 'notApplicable') };
|
|
36
|
+
}
|
|
37
|
+
encode(eoj, propertyName, value) {
|
|
38
|
+
for (const property of this.device(eoj)?.elProperties.filter(p => p.shortName === propertyName && p.accessRule.set !== 'notApplicable') ?? []) {
|
|
39
|
+
const schema = this.resolve(property.data);
|
|
40
|
+
for (const state of this.schemasOfType(schema, 'state')) {
|
|
41
|
+
const match = state.enum?.find(e => e.name === String(value));
|
|
42
|
+
if (match)
|
|
43
|
+
return { epc: property.epc.slice(2), edt: match.edt.slice(2) };
|
|
44
|
+
}
|
|
45
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
46
|
+
for (const number of this.schemasOfType(schema, 'number')) {
|
|
47
|
+
const multiple = number.multiple ?? 1, size = this.sizeOf(number.format), signed = number.format?.startsWith('int') ?? false;
|
|
48
|
+
const n = Math.round(value / multiple);
|
|
49
|
+
if ((number.minimum !== undefined && n < number.minimum) || (number.maximum !== undefined && n > number.maximum))
|
|
50
|
+
continue;
|
|
51
|
+
const min = signed ? -(2 ** (size * 8 - 1)) : 0, max = signed ? 2 ** (size * 8 - 1) - 1 : 2 ** (size * 8) - 1;
|
|
52
|
+
if (n < min || n > max)
|
|
53
|
+
continue;
|
|
54
|
+
const encoded = n < 0 ? 2 ** (size * 8) + n : n;
|
|
55
|
+
return { epc: property.epc.slice(2), edt: encoded.toString(16).padStart(size * 2, '0') };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
resolve(schema) { if (schema.$ref)
|
|
62
|
+
return this.resolve(this.definitions[schema.$ref.split('/').pop()] ?? schema); return schema; }
|
|
63
|
+
schemasOfType(schema, type) { schema = this.resolve(schema); return [...(schema.type === type ? [schema] : []), ...(schema.oneOf ?? []).flatMap(item => this.schemasOfType(item, type))]; }
|
|
64
|
+
tryDecodeSchema(schema, raw) {
|
|
65
|
+
schema = this.resolve(schema);
|
|
66
|
+
if (schema.oneOf) {
|
|
67
|
+
for (const item of schema.oneOf) {
|
|
68
|
+
const decoded = this.tryDecodeSchema(item, raw);
|
|
69
|
+
if (decoded.matched)
|
|
70
|
+
return decoded;
|
|
71
|
+
}
|
|
72
|
+
return { matched: false };
|
|
73
|
+
}
|
|
74
|
+
if (schema.type === 'state') {
|
|
75
|
+
const found = schema.enum?.find(e => e.edt.slice(2).toLowerCase() === raw.toLowerCase());
|
|
76
|
+
return found ? { matched: true, value: found.name } : { matched: false };
|
|
77
|
+
}
|
|
78
|
+
if (schema.type === 'number') {
|
|
79
|
+
const size = this.sizeOf(schema.format);
|
|
80
|
+
if (!new RegExp(`^[0-9a-f]{${size * 2}}$`, 'i').test(raw))
|
|
81
|
+
return { matched: false };
|
|
82
|
+
const unsigned = parseInt(raw, 16), signed = schema.format?.startsWith('int') && unsigned >= 2 ** (size * 8 - 1) ? unsigned - 2 ** (size * 8) : unsigned;
|
|
83
|
+
if ((schema.minimum !== undefined && signed < schema.minimum) || (schema.maximum !== undefined && signed > schema.maximum))
|
|
84
|
+
return { matched: false };
|
|
85
|
+
return { matched: true, value: signed * (schema.multiple ?? 1) };
|
|
86
|
+
}
|
|
87
|
+
return { matched: true, value: raw };
|
|
88
|
+
}
|
|
89
|
+
sizeOf(format) { const bits = Number(format?.match(/\d+/)?.[0] ?? 8); return Math.max(1, bits / 8); }
|
|
90
|
+
}
|
|
91
|
+
exports.MraRepository = MraRepository;
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.EchonetLitePlatform = void 0;
|
|
40
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
41
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
42
|
+
const EL = __importStar(require("echonet-lite"));
|
|
43
|
+
const settings_1 = require("./settings");
|
|
44
|
+
const logger_1 = require("./logger");
|
|
45
|
+
const mra_1 = require("./mra");
|
|
46
|
+
/** Direct ECHONET Lite platform. MQTT is intentionally not involved. */
|
|
47
|
+
class EchonetLitePlatform {
|
|
48
|
+
config;
|
|
49
|
+
api;
|
|
50
|
+
cached = new Map();
|
|
51
|
+
pendingRemoval = [];
|
|
52
|
+
meterState = new Map();
|
|
53
|
+
logger;
|
|
54
|
+
mra = new mra_1.MraRepository();
|
|
55
|
+
rediscoveryTimer;
|
|
56
|
+
facilitySyncTimer;
|
|
57
|
+
deviceCacheTimer;
|
|
58
|
+
statePollTimer;
|
|
59
|
+
lastRediscovery = 0;
|
|
60
|
+
discovered = new Map();
|
|
61
|
+
constructor(log, config, api) {
|
|
62
|
+
this.config = config;
|
|
63
|
+
this.api = api;
|
|
64
|
+
this.logger = new logger_1.PluginLogger(log, config.logLevel ?? 'info');
|
|
65
|
+
api.on('didFinishLaunching', () => void this.start());
|
|
66
|
+
api.on('shutdown', () => {
|
|
67
|
+
if (this.rediscoveryTimer)
|
|
68
|
+
clearInterval(this.rediscoveryTimer);
|
|
69
|
+
if (this.facilitySyncTimer)
|
|
70
|
+
clearTimeout(this.facilitySyncTimer);
|
|
71
|
+
if (this.deviceCacheTimer)
|
|
72
|
+
clearTimeout(this.deviceCacheTimer);
|
|
73
|
+
if (this.statePollTimer)
|
|
74
|
+
clearInterval(this.statePollTimer);
|
|
75
|
+
try {
|
|
76
|
+
EL.release();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
this.logger.debug(`ECHONET Lite終了処理エラー: ${String(error)}`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
configureAccessory(a) {
|
|
84
|
+
const id = a.context?.ip && a.context?.eoj ? `${a.context.ip}-${a.context.eoj}` : '';
|
|
85
|
+
if (id && !this.allowed(id, a.context.ip, a.context.eoj)) {
|
|
86
|
+
this.pendingRemoval.push(a);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.cached.set(a.UUID, a);
|
|
90
|
+
}
|
|
91
|
+
async start() {
|
|
92
|
+
try {
|
|
93
|
+
// The running child bridge has now loaded the saved configuration.
|
|
94
|
+
// Clearing this marker lets the custom UI safely enable rediscovery.
|
|
95
|
+
try {
|
|
96
|
+
node_fs_1.default.rmSync(node_path_1.default.join(this.api.user.storagePath(), 'echonet-lite-plus-restart-required'), { force: true });
|
|
97
|
+
}
|
|
98
|
+
catch { /* UI marker is optional */ }
|
|
99
|
+
if (this.pendingRemoval.length) {
|
|
100
|
+
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, this.pendingRemoval);
|
|
101
|
+
this.logger.info(`設定により${this.pendingRemoval.length}台をHomeKitから除外しました`);
|
|
102
|
+
this.pendingRemoval.length = 0;
|
|
103
|
+
}
|
|
104
|
+
this.logger.info('ECHONET Lite直接通信を開始します(UDP/3610)');
|
|
105
|
+
EL.initialize(['05ff01'], ((r, e, error) => {
|
|
106
|
+
if (error) {
|
|
107
|
+
this.logger.warn(`ECHONET Lite受信処理エラー: ${String(error)}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!e)
|
|
111
|
+
return;
|
|
112
|
+
try {
|
|
113
|
+
this.receive(r, e);
|
|
114
|
+
}
|
|
115
|
+
catch (receiveError) {
|
|
116
|
+
this.logger.error(`ECHONET Lite機器の処理に失敗しました: ${String(receiveError)}`);
|
|
117
|
+
}
|
|
118
|
+
}), 4, {
|
|
119
|
+
v4: this.config.targetNetwork, ignoreMe: true, autoGetProperties: false, debugMode: false,
|
|
120
|
+
});
|
|
121
|
+
for (const ip of this.config.knownDeviceIps ?? [])
|
|
122
|
+
EL.sendOPC1(ip, '05ff01', '0ef001', EL.GET, 'd6', '');
|
|
123
|
+
if (this.config.autoDiscovery !== false) {
|
|
124
|
+
this.logger.info('ECHONET Lite機器を探索しています');
|
|
125
|
+
EL.search();
|
|
126
|
+
}
|
|
127
|
+
this.watchRediscoveryRequests();
|
|
128
|
+
this.startStatePolling();
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
this.logger.error(`ECHONET Liteの初期化に失敗しました: ${String(e)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
watchRediscoveryRequests() {
|
|
135
|
+
const marker = node_path_1.default.join(this.api.user.storagePath(), 'echonet-lite-plus-rediscover.json');
|
|
136
|
+
try {
|
|
137
|
+
this.lastRediscovery = JSON.parse(node_fs_1.default.readFileSync(marker, 'utf8')).requestedAt ?? 0;
|
|
138
|
+
}
|
|
139
|
+
catch { /* no previous request */ }
|
|
140
|
+
this.rediscoveryTimer = setInterval(() => {
|
|
141
|
+
try {
|
|
142
|
+
const request = JSON.parse(node_fs_1.default.readFileSync(marker, 'utf8'));
|
|
143
|
+
if ((request.requestedAt ?? 0) > this.lastRediscovery) {
|
|
144
|
+
this.lastRediscovery = request.requestedAt;
|
|
145
|
+
this.rediscover();
|
|
146
|
+
node_fs_1.default.rmSync(marker, { force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch { /* The marker is created by the UI on first use. */ }
|
|
150
|
+
}, 1000);
|
|
151
|
+
}
|
|
152
|
+
rediscover() {
|
|
153
|
+
this.logger.info('UIからECHONET Lite機器の再探索を開始しました');
|
|
154
|
+
for (const ip of this.config.knownDeviceIps ?? [])
|
|
155
|
+
EL.sendOPC1(ip, '05ff01', '0ef001', EL.GET, 'd6', '');
|
|
156
|
+
EL.search();
|
|
157
|
+
this.scheduleFacilitySync();
|
|
158
|
+
}
|
|
159
|
+
startStatePolling() {
|
|
160
|
+
const seconds = Number(this.config.pollInterval ?? 60);
|
|
161
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
162
|
+
return;
|
|
163
|
+
const interval = Math.max(30, seconds);
|
|
164
|
+
this.logger.info(`ECHONET Lite機器の状態を${interval}秒間隔で更新します`);
|
|
165
|
+
this.statePollTimer = setInterval(() => this.pollDeviceStates(), interval * 1000);
|
|
166
|
+
}
|
|
167
|
+
pollDeviceStates() {
|
|
168
|
+
for (const device of this.discovered.values()) {
|
|
169
|
+
if (!this.allowed(device.id, device.ip, device.eoj))
|
|
170
|
+
continue;
|
|
171
|
+
const selected = this.config.deviceSettings?.find(x => x.id === device.id)?.properties;
|
|
172
|
+
const epcs = (Array.isArray(selected) ? selected : Object.keys(device.properties)).map(epc => epc.toLowerCase()).filter(epc => !['9d', '9e', '9f'].includes(epc) && this.mra.isReadable(device.eoj, epc));
|
|
173
|
+
for (let offset = 0; offset < epcs.length; offset += 10) {
|
|
174
|
+
const details = epcs.slice(offset, offset + 10).map(epc => ({ [epc]: '' }));
|
|
175
|
+
if (details.length)
|
|
176
|
+
try {
|
|
177
|
+
EL.sendDetails(device.ip, '05ff01', device.eoj, EL.GET, details);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
this.logger.debug(`状態更新要求の送信に失敗しました: ${device.ip} / ${device.eoj}: ${String(error)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
receive(r, e) {
|
|
186
|
+
// INF/GET_RESは非常に多いため受信電文そのものはdebugに限定する。
|
|
187
|
+
this.logger.debug(`recv ${r.address} ${e.SEOJ} ESV=${e.ESV} ${JSON.stringify(e.DETAILs)}`);
|
|
188
|
+
if (e.SEOJ === '0ef001') {
|
|
189
|
+
const list = e.DETAILs?.d6;
|
|
190
|
+
if (typeof list === 'string' && list.length >= 2) {
|
|
191
|
+
const count = parseInt(list.slice(0, 2), 16);
|
|
192
|
+
for (let i = 0; i < count; i++) {
|
|
193
|
+
const eoj = list.slice(2 + i * 6, 8 + i * 6);
|
|
194
|
+
if (/^[0-9a-f]{6}$/i.test(eoj))
|
|
195
|
+
EL.sendDetails(r, '05ff01', eoj, EL.GET, [{ '9d': '' }, { '9e': '' }, { '9f': '' }]);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
this.scheduleFacilitySync();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const map = e.DETAILs?.['9f'];
|
|
202
|
+
if (typeof map === 'string') {
|
|
203
|
+
const details = this.propertyRequests(map);
|
|
204
|
+
if (Object.keys(details).length)
|
|
205
|
+
EL.sendDetails(r, '05ff01', e.SEOJ, EL.GET, details);
|
|
206
|
+
}
|
|
207
|
+
this.recordDevice(r.address, e.SEOJ, e.DETAILs);
|
|
208
|
+
const id = `${r.address}-${e.SEOJ}`;
|
|
209
|
+
if (!this.allowed(id, r.address, e.SEOJ)) {
|
|
210
|
+
this.logger.debug(`機器をフィルターにより除外しました: ${r.address} / ${e.SEOJ}`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.logger.debug(`ECHONET Lite機器を検出しました: ${r.address} / ${e.SEOJ}`);
|
|
214
|
+
const a = this.accessory(id, r.address, e.SEOJ);
|
|
215
|
+
this.cacheDetectedProperties(a, id);
|
|
216
|
+
this.apply(a, r.address, e.SEOJ, this.selectedDetails(id, e.DETAILs));
|
|
217
|
+
}
|
|
218
|
+
recordDevice(ip, eoj, details) {
|
|
219
|
+
const id = `${ip}-${eoj}`, existing = this.discovered.get(id), current = existing ?? { id, ip, eoj, name: this.className(eoj), properties: {} };
|
|
220
|
+
let changed = !existing;
|
|
221
|
+
for (const epc of Object.keys(details))
|
|
222
|
+
if (!['9d', '9e', '9f'].includes(epc.toLowerCase())) {
|
|
223
|
+
const description = this.mra.describe(eoj, epc) ?? { name: epc.toUpperCase(), ja: `EPC ${epc.toUpperCase()}`, en: `EPC ${epc.toUpperCase()}` };
|
|
224
|
+
if (JSON.stringify(current.properties[epc]) !== JSON.stringify(description)) {
|
|
225
|
+
current.properties[epc] = description;
|
|
226
|
+
changed = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.discovered.set(id, current);
|
|
230
|
+
if (changed)
|
|
231
|
+
this.scheduleDeviceCacheWrite();
|
|
232
|
+
}
|
|
233
|
+
scheduleDeviceCacheWrite() {
|
|
234
|
+
if (this.deviceCacheTimer)
|
|
235
|
+
clearTimeout(this.deviceCacheTimer);
|
|
236
|
+
this.deviceCacheTimer = setTimeout(() => this.writeDeviceCache(), 250);
|
|
237
|
+
}
|
|
238
|
+
writeDeviceCache() {
|
|
239
|
+
const file = node_path_1.default.join(this.api.user.storagePath(), 'echonet-lite-plus-devices.json'), temporary = `${file}.tmp`;
|
|
240
|
+
try {
|
|
241
|
+
const devices = [...this.discovered.values()], payload = { updatedAt: Date.now(), devices };
|
|
242
|
+
node_fs_1.default.writeFileSync(temporary, JSON.stringify(payload));
|
|
243
|
+
node_fs_1.default.renameSync(temporary, file);
|
|
244
|
+
const carriers = [...this.cached.values()];
|
|
245
|
+
for (const accessory of carriers)
|
|
246
|
+
accessory.context.echonetDiscoveredDevices = devices;
|
|
247
|
+
if (carriers.length)
|
|
248
|
+
this.api.updatePlatformAccessories(carriers);
|
|
249
|
+
this.logger.info(`検出機器一覧を更新しました: ${this.discovered.size}台`);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
this.logger.debug(`機器一覧の保存に失敗しました: ${String(error)}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
selectedDetails(id, details) {
|
|
256
|
+
const selected = this.config.deviceSettings?.find(x => x.id === id)?.properties;
|
|
257
|
+
if (!Array.isArray(selected))
|
|
258
|
+
return details;
|
|
259
|
+
const dependencies = new Set(selected);
|
|
260
|
+
if (selected.includes('e0')) {
|
|
261
|
+
dependencies.add('d3');
|
|
262
|
+
dependencies.add('e1');
|
|
263
|
+
}
|
|
264
|
+
return Object.fromEntries(Object.entries(details).filter(([epc]) => dependencies.has(epc)));
|
|
265
|
+
}
|
|
266
|
+
cacheDetectedProperties(a, id) {
|
|
267
|
+
const properties = this.discovered.get(id)?.properties;
|
|
268
|
+
if (!properties)
|
|
269
|
+
return;
|
|
270
|
+
if (JSON.stringify(a.context.detectedProperties) === JSON.stringify(properties))
|
|
271
|
+
return;
|
|
272
|
+
a.context.detectedProperties = properties;
|
|
273
|
+
this.api.updatePlatformAccessories([a]);
|
|
274
|
+
}
|
|
275
|
+
propertyRequests(map) {
|
|
276
|
+
const bytes = map.match(/../g)?.map(v => parseInt(v, 16)) ?? [];
|
|
277
|
+
const epcs = [];
|
|
278
|
+
if (bytes[0] <= 15) {
|
|
279
|
+
for (let i = 1; i <= bytes[0] && i < bytes.length; i++)
|
|
280
|
+
epcs.push(bytes[i].toString(16).padStart(2, '0'));
|
|
281
|
+
}
|
|
282
|
+
else
|
|
283
|
+
for (let i = 0; i < 16; i++)
|
|
284
|
+
for (let bit = 0; bit < 8; bit++)
|
|
285
|
+
if ((bytes[i + 1] ?? 0) & (1 << bit))
|
|
286
|
+
epcs.push(((bit + 8) * 16 + i).toString(16));
|
|
287
|
+
return Object.fromEntries(epcs.filter(epc => !['d6', '9d', '9e', '9f'].includes(epc)).map(epc => [epc, '']));
|
|
288
|
+
}
|
|
289
|
+
scheduleFacilitySync() {
|
|
290
|
+
if (this.facilitySyncTimer)
|
|
291
|
+
clearTimeout(this.facilitySyncTimer);
|
|
292
|
+
this.facilitySyncTimer = setTimeout(() => this.syncFacilities(), 5500);
|
|
293
|
+
}
|
|
294
|
+
syncFacilities() {
|
|
295
|
+
for (const [ip, objects] of Object.entries(EL.facilities ?? {}))
|
|
296
|
+
for (const [eoj, details] of Object.entries(objects)) {
|
|
297
|
+
if (eoj.toLowerCase() === '0ef001')
|
|
298
|
+
continue;
|
|
299
|
+
this.recordDevice(ip, eoj, details);
|
|
300
|
+
const id = `${ip}-${eoj}`;
|
|
301
|
+
if (!this.allowed(id, ip, eoj))
|
|
302
|
+
continue;
|
|
303
|
+
const a = this.accessory(id, ip, eoj);
|
|
304
|
+
this.cacheDetectedProperties(a, id);
|
|
305
|
+
this.apply(a, ip, eoj, this.selectedDetails(id, details));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
allowed(id, ip, eoj) {
|
|
309
|
+
const keys = [id, ip, eoj];
|
|
310
|
+
return this.supportedClass(eoj) && this.meterAllowed(eoj) && this.config.deviceSettings?.find(x => x.id === id)?.enabled === true && (!this.config.includeDevices?.length || keys.some(k => this.config.includeDevices.includes(k))) && !keys.some(k => this.config.excludeDevices?.includes(k));
|
|
311
|
+
}
|
|
312
|
+
supportedClass(eoj) { return new Set(['0011', '0012', '0130', '0133', '0134', '0135', '0263', '026b', '026f', '0272', '0273', '0279', '027b', '027e', '0280', '0281', '0282', '0287', '0288', '0290', '0291', '02a6', '05fd']).has(eoj.slice(0, 4).toLowerCase()); }
|
|
313
|
+
meterAllowed(eoj) { const cls = eoj.slice(0, 4).toLowerCase(); return (this.config.meterDisplayMode ?? 'appleHome') === 'extended' || !['0279', '027e', '0280', '0282', '0287', '0288'].includes(cls); }
|
|
314
|
+
accessory(id, ip, eoj) {
|
|
315
|
+
const uuid = this.api.hap.uuid.generate(`echonet-lite:${id}`);
|
|
316
|
+
let a = this.cached.get(uuid);
|
|
317
|
+
const setting = this.config.deviceSettings?.find(x => x.id === id);
|
|
318
|
+
if (!a) {
|
|
319
|
+
a = new this.api.platformAccessory(setting?.name || this.className(eoj), uuid);
|
|
320
|
+
a.context = { ip, eoj };
|
|
321
|
+
this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [a]);
|
|
322
|
+
this.cached.set(uuid, a);
|
|
323
|
+
this.logger.info(`機器を追加しました: ${ip} / ${eoj}`);
|
|
324
|
+
}
|
|
325
|
+
const displayName = setting?.name?.trim() || this.className(eoj);
|
|
326
|
+
a.displayName = displayName;
|
|
327
|
+
const info = a.getService(this.api.hap.Service.AccessoryInformation);
|
|
328
|
+
info.setCharacteristic(this.api.hap.Characteristic.Name, displayName)
|
|
329
|
+
.setCharacteristic(this.api.hap.Characteristic.Manufacturer, 'ECHONET Lite')
|
|
330
|
+
.setCharacteristic(this.api.hap.Characteristic.Model, `EOJ ${eoj.slice(0, 4).toUpperCase()}`)
|
|
331
|
+
.setCharacteristic(this.api.hap.Characteristic.SerialNumber, id);
|
|
332
|
+
return a;
|
|
333
|
+
}
|
|
334
|
+
className(eoj) { const device = this.mra.device(eoj); return device?.className.ja ?? device?.className.en ?? `ECHONET Lite ${eoj}`; }
|
|
335
|
+
service(a, eoj) {
|
|
336
|
+
const S = this.api.hap.Service;
|
|
337
|
+
const cls = eoj.slice(0, 4).toLowerCase();
|
|
338
|
+
const name = a.displayName || this.className(eoj);
|
|
339
|
+
if (cls === '0290' || cls === '0291')
|
|
340
|
+
return a.getService(S.Lightbulb) ?? a.addService(S.Lightbulb, name);
|
|
341
|
+
if (cls === '0011')
|
|
342
|
+
return a.getService(S.TemperatureSensor) ?? a.addService(S.TemperatureSensor, name);
|
|
343
|
+
if (cls === '0012')
|
|
344
|
+
return a.getService(S.HumiditySensor) ?? a.addService(S.HumiditySensor, name);
|
|
345
|
+
if (cls === '0130')
|
|
346
|
+
return a.getService(S.HeaterCooler) ?? a.addService(S.HeaterCooler, name);
|
|
347
|
+
if (cls === '0133' || cls === '0134' || cls === '0135')
|
|
348
|
+
return a.getService(S.Fanv2) ?? a.addService(S.Fanv2, name);
|
|
349
|
+
if (cls === '0263')
|
|
350
|
+
return a.getService(S.WindowCovering) ?? a.addService(S.WindowCovering, name);
|
|
351
|
+
if (cls === '026b' || cls === '0272')
|
|
352
|
+
return a.getServiceById(S.Switch, 'automatic-bath') ?? a.addService(S.Switch, `${name} 風呂自動`, 'automatic-bath');
|
|
353
|
+
if (cls === '026f')
|
|
354
|
+
return a.getService(S.LockMechanism) ?? a.addService(S.LockMechanism, name);
|
|
355
|
+
if (cls === '0273')
|
|
356
|
+
return a.getService(S.Fanv2) ?? a.addService(S.Fanv2, name);
|
|
357
|
+
if (cls === '027b')
|
|
358
|
+
return a.getService(S.Thermostat) ?? a.addService(S.Thermostat, name);
|
|
359
|
+
if (cls === '0279' || cls === '027e' || cls === '0280' || cls === '0287' || cls === '0288')
|
|
360
|
+
return this.energyService(a, name);
|
|
361
|
+
if (cls === '0281' && (this.config.meterDisplayMode ?? 'appleHome') === 'appleHome') {
|
|
362
|
+
const extended = a.services.find(service => service.UUID === '7A8C3201-3D84-4B4E-9A9E-000000002811');
|
|
363
|
+
if (extended)
|
|
364
|
+
a.removeService(extended);
|
|
365
|
+
return a.getServiceById(S.LeakSensor, 'water-meter-alarm') ?? a.addService(S.LeakSensor, name, 'water-meter-alarm');
|
|
366
|
+
}
|
|
367
|
+
if (cls === '0281') {
|
|
368
|
+
const leak = a.getServiceById(S.LeakSensor, 'water-meter-alarm');
|
|
369
|
+
if (leak)
|
|
370
|
+
a.removeService(leak);
|
|
371
|
+
return this.utilityMeterService(a, name, cls);
|
|
372
|
+
}
|
|
373
|
+
if (cls === '0282')
|
|
374
|
+
return this.utilityMeterService(a, name, cls);
|
|
375
|
+
if (cls === '02a6')
|
|
376
|
+
return a.getServiceById(S.Switch, 'automatic-water-heating') ?? a.addService(S.Switch, `${name} 自動沸き上げ`, 'automatic-water-heating');
|
|
377
|
+
if (cls === '05fd')
|
|
378
|
+
return a.getService(S.Switch) ?? a.addService(S.Switch, name);
|
|
379
|
+
throw new Error(`HomeKitサービス未対応の機器クラスです: ${cls}`);
|
|
380
|
+
}
|
|
381
|
+
apply(a, ip, eoj, d) {
|
|
382
|
+
const s = this.service(a, eoj), C = this.api.hap.Characteristic, cls = eoj.slice(0, 4).toLowerCase();
|
|
383
|
+
// SET_RES can contain an EPC with an empty EDT. It acknowledges the write,
|
|
384
|
+
// but does not carry a value and must not overwrite the HomeKit state.
|
|
385
|
+
const decoded = this.decodedDetails(eoj, d);
|
|
386
|
+
const on = decoded.operationStatus === true || decoded.operationStatus === 'true';
|
|
387
|
+
if (cls === '0290' || cls === '0291') {
|
|
388
|
+
if ('operationStatus' in decoded)
|
|
389
|
+
this.writable(s, C.On, on, v => this.setMra(ip, eoj, 'operationStatus', String(Boolean(v))));
|
|
390
|
+
if ('lightLevel' in decoded)
|
|
391
|
+
this.writable(s, C.Brightness, this.clamp(Number(decoded.lightLevel), 0, 100), v => this.setMra(ip, eoj, 'lightLevel', Number(v)));
|
|
392
|
+
}
|
|
393
|
+
else if (cls === '0130') {
|
|
394
|
+
if ('operationStatus' in decoded)
|
|
395
|
+
this.writable(s, C.Active, on ? C.Active.ACTIVE : C.Active.INACTIVE, v => this.setMra(ip, eoj, 'operationStatus', String(v === C.Active.ACTIVE)));
|
|
396
|
+
if ('roomTemperature' in decoded)
|
|
397
|
+
s.getCharacteristic(C.CurrentTemperature).updateValue(Number(decoded.roomTemperature));
|
|
398
|
+
if ('targetTemperature' in decoded) {
|
|
399
|
+
const temperature = Number(decoded.targetTemperature);
|
|
400
|
+
// ECHONET B3 is a shared target temperature. HomeKit gives its heating
|
|
401
|
+
// and cooling thresholds different defaults, so align both with B3.
|
|
402
|
+
s.getCharacteristic(C.CoolingThresholdTemperature).setProps({ minValue: 0, maxValue: 50, minStep: 1 });
|
|
403
|
+
s.getCharacteristic(C.HeatingThresholdTemperature).setProps({ minValue: 0, maxValue: 50, minStep: 1 });
|
|
404
|
+
this.writable(s, C.CoolingThresholdTemperature, temperature, v => this.setMra(ip, eoj, 'targetTemperature', Number(v)));
|
|
405
|
+
this.writable(s, C.HeatingThresholdTemperature, temperature, v => this.setMra(ip, eoj, 'targetTemperature', Number(v)));
|
|
406
|
+
}
|
|
407
|
+
const mode = decoded.operationMode;
|
|
408
|
+
if ('operationMode' in decoded) {
|
|
409
|
+
const target = mode === 'cooling' ? C.TargetHeaterCoolerState.COOL : mode === 'heating' ? C.TargetHeaterCoolerState.HEAT : C.TargetHeaterCoolerState.AUTO;
|
|
410
|
+
this.writable(s, C.TargetHeaterCoolerState, target, v => this.setMra(ip, eoj, 'operationMode', v === C.TargetHeaterCoolerState.COOL ? 'cooling' : v === C.TargetHeaterCoolerState.HEAT ? 'heating' : 'auto'));
|
|
411
|
+
}
|
|
412
|
+
const current = !on ? C.CurrentHeaterCoolerState.INACTIVE : mode === 'cooling' ? C.CurrentHeaterCoolerState.COOLING : mode === 'heating' ? C.CurrentHeaterCoolerState.HEATING : C.CurrentHeaterCoolerState.IDLE;
|
|
413
|
+
s.getCharacteristic(C.CurrentHeaterCoolerState).updateValue(current);
|
|
414
|
+
}
|
|
415
|
+
else if (cls === '0133' || cls === '0134' || cls === '0135') {
|
|
416
|
+
if ('operationStatus' in decoded)
|
|
417
|
+
this.writable(s, C.Active, on ? C.Active.ACTIVE : C.Active.INACTIVE, v => this.setMra(ip, eoj, 'operationStatus', String(v === C.Active.ACTIVE)));
|
|
418
|
+
}
|
|
419
|
+
else if (cls === '0263') {
|
|
420
|
+
const degree = Number(decoded.degreeOfOpening);
|
|
421
|
+
if (Number.isFinite(degree))
|
|
422
|
+
s.getCharacteristic(C.CurrentPosition).updateValue(this.clamp(degree, 0, 100));
|
|
423
|
+
const status = decoded.openCloseStatus;
|
|
424
|
+
if (status === 'fullyOpen') {
|
|
425
|
+
s.getCharacteristic(C.CurrentPosition).updateValue(100);
|
|
426
|
+
s.getCharacteristic(C.TargetPosition).updateValue(100);
|
|
427
|
+
}
|
|
428
|
+
else if (status === 'fullyClosed') {
|
|
429
|
+
s.getCharacteristic(C.CurrentPosition).updateValue(0);
|
|
430
|
+
s.getCharacteristic(C.TargetPosition).updateValue(0);
|
|
431
|
+
}
|
|
432
|
+
const positionState = status === 'opening' ? C.PositionState.INCREASING : status === 'closing' ? C.PositionState.DECREASING : C.PositionState.STOPPED;
|
|
433
|
+
s.getCharacteristic(C.PositionState).updateValue(positionState);
|
|
434
|
+
const target = s.getCharacteristic(C.TargetPosition);
|
|
435
|
+
target.removeOnSet();
|
|
436
|
+
target.onSet(value => {
|
|
437
|
+
const requested = this.clamp(Number(value), 0, 100);
|
|
438
|
+
if ('degreeOfOpening' in decoded)
|
|
439
|
+
this.setMra(ip, eoj, 'degreeOfOpening', requested);
|
|
440
|
+
else
|
|
441
|
+
this.setMra(ip, eoj, 'openCloseOperation', requested > Number(s.getCharacteristic(C.CurrentPosition).value ?? 0) ? 'open' : 'close');
|
|
442
|
+
});
|
|
443
|
+
const hold = s.getCharacteristic(C.HoldPosition);
|
|
444
|
+
hold.removeOnSet();
|
|
445
|
+
hold.onSet(value => { if (Boolean(value))
|
|
446
|
+
this.setMra(ip, eoj, 'openCloseOperation', 'stop'); });
|
|
447
|
+
}
|
|
448
|
+
else if (cls === '026b' || cls === '0272') {
|
|
449
|
+
if ('automaticBathOperation' in decoded)
|
|
450
|
+
this.writable(s, C.On, decoded.automaticBathOperation === 'true' || decoded.automaticBathOperation === true, v => this.setMra(ip, eoj, 'automaticBathOperation', String(Boolean(v))));
|
|
451
|
+
}
|
|
452
|
+
else if (cls === '026f') {
|
|
453
|
+
if ('e0' in d) {
|
|
454
|
+
const secured = d['e0'].toLowerCase() === '41';
|
|
455
|
+
s.getCharacteristic(C.LockCurrentState).updateValue(secured ? C.LockCurrentState.SECURED : C.LockCurrentState.UNSECURED);
|
|
456
|
+
this.writable(s, C.LockTargetState, secured ? C.LockTargetState.SECURED : C.LockTargetState.UNSECURED, v => this.set(ip, eoj, 'e0', v === C.LockTargetState.SECURED ? '41' : '42'));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
else if (cls === '0273') {
|
|
460
|
+
this.applyBathroomDryer(a, s, ip, eoj, decoded);
|
|
461
|
+
}
|
|
462
|
+
else if (cls === '027b') {
|
|
463
|
+
s.getCharacteristic(C.TargetHeatingCoolingState).setProps({ validValues: [C.TargetHeatingCoolingState.OFF, C.TargetHeatingCoolingState.HEAT] });
|
|
464
|
+
if ('operationStatus' in decoded) {
|
|
465
|
+
s.getCharacteristic(C.CurrentHeatingCoolingState).updateValue(on ? C.CurrentHeatingCoolingState.HEAT : C.CurrentHeatingCoolingState.OFF);
|
|
466
|
+
this.writable(s, C.TargetHeatingCoolingState, on ? C.TargetHeatingCoolingState.HEAT : C.TargetHeatingCoolingState.OFF, v => this.setMra(ip, eoj, 'operationStatus', String(v !== C.TargetHeatingCoolingState.OFF)));
|
|
467
|
+
}
|
|
468
|
+
if (typeof decoded.measuredRoomTemperature === 'number')
|
|
469
|
+
s.getCharacteristic(C.CurrentTemperature).updateValue(decoded.measuredRoomTemperature);
|
|
470
|
+
else if (typeof decoded.measuredFloorTemperature === 'number')
|
|
471
|
+
s.getCharacteristic(C.CurrentTemperature).updateValue(decoded.measuredFloorTemperature);
|
|
472
|
+
if (typeof decoded.targetTemperature1 === 'number')
|
|
473
|
+
this.writable(s, C.TargetTemperature, decoded.targetTemperature1, v => this.setMra(ip, eoj, 'targetTemperature1', Number(v)));
|
|
474
|
+
s.getCharacteristic(C.TemperatureDisplayUnits).updateValue(C.TemperatureDisplayUnits.CELSIUS);
|
|
475
|
+
}
|
|
476
|
+
else if (cls === '0279')
|
|
477
|
+
this.applySolar(s, decoded);
|
|
478
|
+
else if (cls === '027e')
|
|
479
|
+
this.applyEvCharger(s, decoded);
|
|
480
|
+
else if (cls === '0280' || cls === '0287' || cls === '0288')
|
|
481
|
+
this.applyEnergy(a, s, cls, d);
|
|
482
|
+
else if (cls === '0281' || cls === '0282')
|
|
483
|
+
this.applyUtilityMeter(a, s, cls, d);
|
|
484
|
+
else if (cls === '0011' && 'value' in decoded)
|
|
485
|
+
s.getCharacteristic(C.CurrentTemperature).updateValue(Number(decoded.value));
|
|
486
|
+
else if (cls === '0012' && 'value' in decoded)
|
|
487
|
+
s.getCharacteristic(C.CurrentRelativeHumidity).updateValue(this.clamp(Number(decoded.value), 0, 100));
|
|
488
|
+
else if (cls === '02a6' && 'automaticWaterHeating' in decoded) {
|
|
489
|
+
const enabled = decoded.automaticWaterHeating !== 'manualNotHeating';
|
|
490
|
+
this.writable(s, C.On, enabled, v => this.setMra(ip, eoj, 'automaticWaterHeating', Boolean(v) ? 'auto' : 'manualNotHeating'));
|
|
491
|
+
}
|
|
492
|
+
else if (cls === '05fd' && 'operationStatus' in decoded)
|
|
493
|
+
this.writable(s, C.On, on, v => this.setMra(ip, eoj, 'operationStatus', String(Boolean(v))));
|
|
494
|
+
}
|
|
495
|
+
decodedDetails(eoj, d) {
|
|
496
|
+
return Object.fromEntries(Object.entries(d).filter(([, raw]) => typeof raw === 'string' && raw.length > 0 && raw.length % 2 === 0 && /^[0-9a-f]+$/i.test(raw)).map(([epc, raw]) => { const p = this.mra.decode(eoj, epc, raw); return [p?.name ?? epc, p?.value ?? raw]; }));
|
|
497
|
+
}
|
|
498
|
+
writable(s, type, value, setter) { const c = s.getCharacteristic(type); c.updateValue(value); c.removeOnSet(); c.onSet(setter); }
|
|
499
|
+
applyBathroomDryer(a, s, ip, eoj, decoded) {
|
|
500
|
+
const C = this.api.hap.Characteristic, S = this.api.hap.Service, mode = String(decoded.operationSetting ?? 'stop');
|
|
501
|
+
this.writable(s, C.Active, mode === 'stop' ? C.Active.INACTIVE : C.Active.ACTIVE, value => this.setMra(ip, eoj, 'operationSetting', value === C.Active.ACTIVE ? 'ventilation' : 'stop'));
|
|
502
|
+
const modes = [['ventilation', '換気'], ['prewarming', '予備暖房'], ['heating', '暖房'], ['drying', '乾燥'], ['circulation', '涼風']];
|
|
503
|
+
for (const [value, label] of modes) {
|
|
504
|
+
const service = a.getServiceById(S.Switch, `bathroom-${value}`) ?? a.addService(S.Switch, `${a.displayName} ${label}`, `bathroom-${value}`);
|
|
505
|
+
this.writable(service, C.On, mode === value, on => { if (Boolean(on))
|
|
506
|
+
this.setMra(ip, eoj, 'operationSetting', value);
|
|
507
|
+
else if (mode === value)
|
|
508
|
+
this.setMra(ip, eoj, 'operationSetting', 'stop'); });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
clamp(value, min, max) { return Math.min(max, Math.max(min, value)); }
|
|
512
|
+
hexNumber(v) { const n = parseInt(v, 16); return Number.isFinite(n) ? n : 0; }
|
|
513
|
+
signedShort(v) { const n = this.hexNumber(v.slice(-4)); return n > 32767 ? n - 65536 : n; }
|
|
514
|
+
energyService(a, name) {
|
|
515
|
+
const uuid = 'E863F117-079E-48FF-8F27-9C2605A29F52';
|
|
516
|
+
const current = a.services.find(s => s.UUID === uuid);
|
|
517
|
+
if (current)
|
|
518
|
+
return current;
|
|
519
|
+
const service = new this.api.hap.Service(name, uuid, 'energy');
|
|
520
|
+
a.addService(service);
|
|
521
|
+
return service;
|
|
522
|
+
}
|
|
523
|
+
utilityMeterService(a, name, cls) {
|
|
524
|
+
const uuid = cls === '0281' ? '7A8C3201-3D84-4B4E-9A9E-000000002811' : '7A8C3201-3D84-4B4E-9A9E-000000002821';
|
|
525
|
+
const current = a.services.find(s => s.UUID === uuid);
|
|
526
|
+
if (current)
|
|
527
|
+
return current;
|
|
528
|
+
const service = new this.api.hap.Service(name, uuid, cls);
|
|
529
|
+
a.addService(service);
|
|
530
|
+
return service;
|
|
531
|
+
}
|
|
532
|
+
meterCharacteristic(s, name, uuid, unit) {
|
|
533
|
+
let c = s.characteristics.find(x => x.UUID === uuid);
|
|
534
|
+
if (!c) {
|
|
535
|
+
c = new this.api.hap.Characteristic(name, uuid, { format: "float" /* this.api.hap.Formats.FLOAT */, perms: ["pr" /* this.api.hap.Perms.PAIRED_READ */, "ev" /* this.api.hap.Perms.NOTIFY */], unit });
|
|
536
|
+
s.addCharacteristic(c);
|
|
537
|
+
}
|
|
538
|
+
return c;
|
|
539
|
+
}
|
|
540
|
+
applyEnergy(a, s, cls, d) {
|
|
541
|
+
const key = a.UUID, state = this.meterState.get(key) ?? { coefficient: 1, unit: 1 };
|
|
542
|
+
if ('d3' in d)
|
|
543
|
+
state.coefficient = this.hexNumber(d['d3']) || 1;
|
|
544
|
+
if ('e1' in d)
|
|
545
|
+
state.unit = this.energyUnit(d['e1']);
|
|
546
|
+
this.meterState.set(key, state);
|
|
547
|
+
// Smart meter: E7=instantaneous W, E8=R/T current (0.1A), E0=cumulative value.
|
|
548
|
+
if ('e7' in d)
|
|
549
|
+
this.meterCharacteristic(s, '現在の消費電力', 'E863F10D-079E-48FF-8F27-9C2605A29F52', 'W').updateValue(this.signedInt(d['e7']));
|
|
550
|
+
if ('e8' in d) {
|
|
551
|
+
const r = this.signedShort(d['e8'].slice(0, 4)) / 10, t = this.signedShort(d['e8'].slice(4, 8)) / 10;
|
|
552
|
+
this.meterCharacteristic(s, '電流', 'E863F126-079E-48FF-8F27-9C2605A29F52', 'A').updateValue(t >= 0 && t < 3276.6 ? r + t : r);
|
|
553
|
+
}
|
|
554
|
+
if ('e0' in d)
|
|
555
|
+
this.meterCharacteristic(s, '積算電力量', 'E863F10C-079E-48FF-8F27-9C2605A29F52', 'kWh').updateValue(this.hexNumber(d['e0']) * state.coefficient * state.unit);
|
|
556
|
+
// Distribution board: B7 is the channel list of instantaneous power values.
|
|
557
|
+
if (cls === '0287' && 'b7' in d) {
|
|
558
|
+
const values = this.channelValues(d['b7']);
|
|
559
|
+
const total = values.reduce((sum, v) => sum + v, 0);
|
|
560
|
+
this.meterCharacteristic(s, '現在の消費電力', 'E863F10D-079E-48FF-8F27-9C2605A29F52', 'W').updateValue(total);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
applySolar(s, decoded) {
|
|
564
|
+
if (typeof decoded.instantaneousElectricPowerGeneration === 'number')
|
|
565
|
+
this.meterCharacteristic(s, '現在の発電電力', '7A8C3279-3D84-4B4E-9A9E-000000000001', 'W').updateValue(decoded.instantaneousElectricPowerGeneration);
|
|
566
|
+
if (typeof decoded.cumulativeElectricEnergyOfGeneration === 'number')
|
|
567
|
+
this.meterCharacteristic(s, '積算発電電力量', '7A8C3279-3D84-4B4E-9A9E-000000000002', 'kWh').updateValue(decoded.cumulativeElectricEnergyOfGeneration);
|
|
568
|
+
if (typeof decoded.cumulativeElectricEnergySold === 'number')
|
|
569
|
+
this.meterCharacteristic(s, '積算売電電力量', '7A8C3279-3D84-4B4E-9A9E-000000000003', 'kWh').updateValue(decoded.cumulativeElectricEnergySold);
|
|
570
|
+
}
|
|
571
|
+
applyEvCharger(s, decoded) {
|
|
572
|
+
if (typeof decoded.instantaneousElectricPower === 'number')
|
|
573
|
+
this.meterCharacteristic(s, '充放電電力', '7A8C327E-3D84-4B4E-9A9E-000000000001', 'W').updateValue(decoded.instantaneousElectricPower);
|
|
574
|
+
if (typeof decoded.remainingCapacity1 === 'number')
|
|
575
|
+
this.meterCharacteristic(s, '充放電可能残量', '7A8C327E-3D84-4B4E-9A9E-000000000002', 'Wh').updateValue(decoded.remainingCapacity1);
|
|
576
|
+
if (typeof decoded.usedCapacity1 === 'number')
|
|
577
|
+
this.meterCharacteristic(s, '使用電力量', '7A8C327E-3D84-4B4E-9A9E-000000000003', 'Wh').updateValue(decoded.usedCapacity1);
|
|
578
|
+
}
|
|
579
|
+
signedInt(v) { const n = this.hexNumber(v.slice(-8)); return n > 0x7fffffff ? n - 0x100000000 : n; }
|
|
580
|
+
energyUnit(v) { return { '00': 1, '01': 0.1, '02': 0.01, '03': 0.001, '04': 0.0001, '0a': 10, '0b': 100, '0c': 1000, '0d': 10000 }[v.slice(-2).toLowerCase()] ?? 1; }
|
|
581
|
+
channelValues(v) { const range = this.hexNumber(v.slice(2, 4)); const result = []; for (let i = 0; i < range; i++) {
|
|
582
|
+
const raw = v.slice(4 + i * 8, 12 + i * 8);
|
|
583
|
+
if (raw.length === 8 && raw.toLowerCase() !== 'fffffffe')
|
|
584
|
+
result.push(this.signedInt(raw));
|
|
585
|
+
} return result; }
|
|
586
|
+
applyUtilityMeter(a, s, cls, d) {
|
|
587
|
+
if (cls === '0281') {
|
|
588
|
+
const appleMode = (this.config.meterDisplayMode ?? 'appleHome') === 'appleHome';
|
|
589
|
+
const unit = 'e1' in d ? this.waterUnit(d['e1']) : 1;
|
|
590
|
+
if (!appleMode && 'e0' in d)
|
|
591
|
+
this.meterCharacteristic(s, '積算水道使用量', '7A8C3210-3D84-4B4E-9A9E-000000002811', 'm³').updateValue(this.hexNumber(d['e0']) * unit);
|
|
592
|
+
if ('e3' in d) {
|
|
593
|
+
const abnormal = d['e3'].slice(-2).toLowerCase() === '41';
|
|
594
|
+
if (!appleMode)
|
|
595
|
+
this.meterCharacteristic(s, '検針データ異常', '7A8C3211-3D84-4B4E-9A9E-000000002811', '').updateValue(abnormal ? 1 : 0);
|
|
596
|
+
const leak = appleMode ? s : (a.getServiceById(this.api.hap.Service.LeakSensor, 'water-meter-alarm') ?? a.addService(this.api.hap.Service.LeakSensor, '水道メーター異常', 'water-meter-alarm'));
|
|
597
|
+
leak.getCharacteristic(this.api.hap.Characteristic.LeakDetected).updateValue(abnormal ? this.api.hap.Characteristic.LeakDetected.LEAK_DETECTED : this.api.hap.Characteristic.LeakDetected.LEAK_NOT_DETECTED);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
else if ('e0' in d)
|
|
601
|
+
this.meterCharacteristic(s, '積算ガス使用量', '7A8C3220-3D84-4B4E-9A9E-000000002821', 'm³').updateValue(this.hexNumber(d['e0']) * 0.001);
|
|
602
|
+
}
|
|
603
|
+
waterUnit(v) { return { '00': 0.001, '01': 0.01, '02': 0.1, '03': 1, '04': 10, '05': 100, '06': 1000 }[v.slice(-2).toLowerCase()] ?? 1; }
|
|
604
|
+
set(ip, eoj, epc, edt) {
|
|
605
|
+
this.logger.info(`操作を送信: ${ip} ${eoj} EPC=${epc}`);
|
|
606
|
+
try {
|
|
607
|
+
EL.sendOPC1(ip, '05ff01', eoj, EL.SETC, epc, String(edt));
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
this.logger.error(`ECHONET Lite操作の送信に失敗しました: ${String(error)}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
setMra(ip, eoj, name, value) { const encoded = this.mra.encode(eoj, name, value); if (!encoded) {
|
|
614
|
+
this.logger.warn(`MRAで書込値を変換できません: ${eoj}.${name}`);
|
|
615
|
+
return;
|
|
616
|
+
} this.set(ip, eoj, encoded.epc, encoded.edt); }
|
|
617
|
+
}
|
|
618
|
+
exports.EchonetLitePlatform = EchonetLitePlatform;
|
package/dist/settings.js
ADDED
package/dist/types.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<link rel="stylesheet" href="style.css?v=0.7.0">
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div class="elsp-shell">
|
|
10
|
+
<div class="elsp-heading"><div><h3>ECHONET Lite Plus</h3><p id="intro" class="text-muted"></p></div><div><label id="languageLabel" for="language" class="visually-hidden"></label><select id="language" class="form-select elsp-language"><option value="auto">Auto</option><option value="ja">日本語</option><option value="en">English</option></select></div></div>
|
|
11
|
+
<details class="elsp-section" open><summary id="basicTitle"></summary><div class="elsp-section-body elsp-grid">
|
|
12
|
+
<div class="elsp-field"><label id="nameLabel"></label><input id="name"></div>
|
|
13
|
+
<div class="elsp-field"><label id="networkLabel"></label><input id="targetNetwork" placeholder="192.168.1.10"><span class="help" id="networkHelp"></span></div>
|
|
14
|
+
<div class="elsp-field"><label id="meterModeLabel"></label><select id="meterDisplayMode"><option value="appleHome"></option><option value="extended"></option></select><span class="help" id="meterModeHelp"></span></div>
|
|
15
|
+
</div></details>
|
|
16
|
+
<details class="elsp-section" open><summary id="networkTitle"></summary><div class="elsp-section-body elsp-grid">
|
|
17
|
+
<div class="elsp-field check"><input id="autoDiscovery" type="checkbox"><label id="discoveryLabel" for="autoDiscovery"></label></div>
|
|
18
|
+
<div class="elsp-field"><label id="ipsLabel"></label><input id="knownDeviceIps" placeholder="192.168.1.100, 192.168.1.101"><span class="help" id="ipsHelp"></span></div>
|
|
19
|
+
<div class="elsp-actions"><button id="rediscover" type="button" class="btn btn-outline-primary btn-sm"></button><span id="restartRequired" class="help" hidden></span></div>
|
|
20
|
+
</div></details>
|
|
21
|
+
<details class="elsp-section" open><summary id="devicesTitle"></summary><div class="elsp-section-body">
|
|
22
|
+
<p class="help" id="devicesHelp"></p>
|
|
23
|
+
<div id="deviceList" class="elsp-device-list"></div>
|
|
24
|
+
</div></details>
|
|
25
|
+
<details class="elsp-section" open><summary id="filterTitle"></summary><div class="elsp-section-body">
|
|
26
|
+
<div class="elsp-field"><label id="includeLabel"></label><input id="includeDevices"><span class="help" id="filterHelp"></span></div>
|
|
27
|
+
<div class="elsp-field"><label id="excludeLabel"></label><input id="excludeDevices"></div>
|
|
28
|
+
</div></details>
|
|
29
|
+
<details class="elsp-section"><summary id="loggingTitle"></summary><div class="elsp-section-body elsp-grid">
|
|
30
|
+
<div class="elsp-field"><label id="pollLabel"></label><select id="pollInterval"><option value="0"></option><option value="30"></option><option value="60"></option><option value="300"></option></select><span class="help" id="pollHelp"></span></div>
|
|
31
|
+
<div class="elsp-field"><label id="logLabel"></label><select id="logLevel"><option value="error">Error</option><option value="warn">Warning</option><option value="info">Info</option><option value="debug">Debug</option></select><span class="help" id="logHelp"></span></div>
|
|
32
|
+
</div></details>
|
|
33
|
+
</div>
|
|
34
|
+
<script>
|
|
35
|
+
const text={
|
|
36
|
+
en:{intro:'Directly discover ECHONET Lite devices on your LAN. MQTT is not required.',languageLabel:'UI Language',basicTitle:'Basic Settings',networkTitle:'Device Discovery',devicesTitle:'Discovered Devices',devicesHelp:'New devices are off by default. Enable only the devices to publish to HomeKit, then save and restart the child bridge.',emptyDevices:'No devices discovered yet. Run device discovery first.',published:'Show in HomeKit',hiddenByMode:'Hidden in Apple Home Compatible mode',displayName:'HomeKit Name',properties:'Synchronized / controllable properties',meterModeLabel:'Meter Display Compatibility',meterModeHelp:'Apple Home hides unsupported meter tiles. Extended mode publishes measurements for apps such as Eve. Save and restart the child bridge after changing this mode.',meterApple:'Apple Home Compatible (Recommended)',meterExtended:'Extended Apps (Eve)',nameLabel:'Platform Name',networkLabel:'Network Interface',networkHelp:'Optional IPv4 interface address for hosts with multiple adapters.',discoveryLabel:'Enable automatic multicast discovery',ipsLabel:'Known Device IP Addresses',ipsHelp:'Comma-separated addresses for devices that do not respond to multicast.',filterTitle:'Advanced Device Filters',includeLabel:'Include Devices',excludeLabel:'Exclude Devices',filterHelp:'Optional comma-separated filter by IP address, EOJ, or internal ID. Devices must also be enabled above.',loggingTitle:'Updates & Logging',pollLabel:'State Update Interval',pollHelp:'Periodically requests selected readable properties from devices that do not send notifications.',pollOptions:['Off','30 seconds','60 seconds (Recommended)','5 minutes'],logLabel:'Log Level',logHelp:'Info is recommended. Push packets are shown only at Debug level.',rediscover:'Re-discover Devices',restartNotice:'Discovery request accepted. The device list will refresh shortly.',restartRequired:'Save the settings and restart the child bridge before rediscovery.',restartRequiredToast:'Settings have changed. Save and restart the child bridge before rediscovery.'},
|
|
37
|
+
ja:{intro:'LAN内のECHONET Lite機器を直接探索します。MQTTは不要です。',languageLabel:'UI言語',basicTitle:'基本設定',networkTitle:'機器探索',devicesTitle:'検出された機器',devicesHelp:'新しく検出した機器は初期状態でOFFです。HomeKitへ追加する機器だけをONにし、設定を保存してChild Bridgeを再起動してください。',emptyDevices:'機器はまだ検出されていません。先に機器を再探索してください。',published:'HomeKitに表示',hiddenByMode:'Appleホーム互換モードでは非表示',displayName:'HomeKit表示名',properties:'同期・操作する項目',meterModeLabel:'メーター表示互換性',meterModeHelp:'Appleホーム互換では非対応タイルを隠します。拡張モードはEveなどへ測定値を公開します。変更後は設定を保存してChild Bridgeを再起動してください。',meterApple:'Appleホーム互換(推奨)',meterExtended:'拡張アプリ(Eve)',nameLabel:'プラットフォーム名',networkLabel:'使用ネットワーク',networkHelp:'複数NIC環境で使用するIPv4インターフェースアドレスを指定します。',discoveryLabel:'マルチキャストによる自動探索を有効にする',ipsLabel:'既知の機器IP',ipsHelp:'自動探索へ応答しない機器をカンマ区切りで指定します。',filterTitle:'高度な機器フィルター',includeLabel:'追加する機器',excludeLabel:'除外する機器',filterHelp:'IP、EOJ、内部IDによる追加フィルターです。上の検出機器も個別にONにしてください。',loggingTitle:'更新とログ',pollLabel:'状態更新間隔',pollHelp:'通知を送らない機器から、選択した読取可能項目を定期取得します。',pollOptions:['無効','30秒','60秒(推奨)','5分'],logLabel:'ログレベル',logHelp:'通常はInfoを推奨します。push電文はDebugでのみ表示します。',rediscover:'機器を再探索',restartNotice:'再探索要求を受け付けました。機器一覧はしばらくすると更新されます。',restartRequired:'設定を保存してChild Bridgeを再起動してから再探索してください。',restartRequiredToast:'設定が変更されています。保存してChild Bridgeを再起動してから再探索してください。'}
|
|
38
|
+
};
|
|
39
|
+
text.en.notSupported='Detected (HomeKit mapping not supported yet)';
|
|
40
|
+
text.ja.notSupported='検出済み(HomeKit割り当て未対応)';
|
|
41
|
+
const ids=['intro','languageLabel','basicTitle','networkTitle','devicesTitle','devicesHelp','nameLabel','networkLabel','networkHelp','meterModeLabel','meterModeHelp','discoveryLabel','ipsLabel','ipsHelp','filterTitle','includeLabel','excludeLabel','filterHelp','loggingTitle','pollLabel','pollHelp','logLabel','logHelp','rediscover'];
|
|
42
|
+
const effective=()=>language.value==='auto'?(navigator.language.toLowerCase().startsWith('ja')?'ja':'en'):language.value;
|
|
43
|
+
function render(){const t=text[effective()];ids.forEach(id=>document.getElementById(id).textContent=t[id]);meterDisplayMode.options[0].textContent=t.meterApple;meterDisplayMode.options[1].textContent=t.meterExtended;[...pollInterval.options].forEach((option,index)=>option.textContent=t.pollOptions[index]);restartRequired.textContent=t.restartRequired;restartRequired.hidden=!needsRestart;if(discoveredDevices.length)renderDevices();homebridge.fixScrollHeight?.();}
|
|
44
|
+
const array=v=>v.split(',').map(x=>x.trim()).filter(Boolean);
|
|
45
|
+
const request=(route,timeout=3000)=>Promise.race([homebridge.request(route),new Promise((_,reject)=>setTimeout(()=>reject(new Error(`${route} timed out`)),timeout))]);
|
|
46
|
+
const defaults={platform:'ECHONETLitePlus',name:'ECHONET Lite',language:'auto',autoDiscovery:true,knownDeviceIps:[],includeDevices:[],excludeDevices:[],deviceSettings:[],meterDisplayMode:'appleHome',pollInterval:60,logLevel:'info'};
|
|
47
|
+
let discoveredDevices=[];
|
|
48
|
+
let baseConfig={};
|
|
49
|
+
let needsRestart=false;
|
|
50
|
+
async function load(){
|
|
51
|
+
const configs=await homebridge.getPluginConfig();
|
|
52
|
+
const missing=configs.length===0;
|
|
53
|
+
const c=missing?{...defaults}:{...defaults,...configs[0]};
|
|
54
|
+
baseConfig={...c};
|
|
55
|
+
if(missing)await homebridge.updatePluginConfig([baseConfig]);
|
|
56
|
+
language.value=c.language||'auto';name.value=c.name||'ECHONET Lite';targetNetwork.value=c.targetNetwork||'';meterDisplayMode.value=c.meterDisplayMode||'appleHome';pollInterval.value=String(c.pollInterval??60);autoDiscovery.checked=c.autoDiscovery!==false;knownDeviceIps.value=(c.knownDeviceIps||[]).join(', ');includeDevices.value=(c.includeDevices||[]).join(', ');excludeDevices.value=(c.excludeDevices||[]).join(', ');logLevel.value=c.logLevel||'info';render();homebridge.hideSpinner();request('/settings-status').then(status=>{needsRestart=Boolean(status?.restartRequired);render();}).catch(e=>console.warn(e));loadDevices().catch(e=>{console.error(e);renderDevices();});
|
|
57
|
+
}
|
|
58
|
+
language.addEventListener('change',()=>{render();update().catch(e=>homebridge.toast.error(String(e)));});
|
|
59
|
+
meterDisplayMode.addEventListener('change',render);
|
|
60
|
+
async function config(){return {...baseConfig,platform:'ECHONETLitePlus',name:name.value||'ECHONET Lite',language:language.value,targetNetwork:targetNetwork.value.trim()||undefined,meterDisplayMode:meterDisplayMode.value,pollInterval:Number(pollInterval.value),autoDiscovery:autoDiscovery.checked,knownDeviceIps:array(knownDeviceIps.value),includeDevices:array(includeDevices.value),excludeDevices:array(excludeDevices.value),deviceSettings:baseConfig.deviceSettings||[],logLevel:logLevel.value};}
|
|
61
|
+
async function update(){baseConfig=await config();await homebridge.updatePluginConfig([baseConfig]);homebridge.fixScrollHeight?.();}
|
|
62
|
+
function markRestartRequired(){if(needsRestart)return;needsRestart=true;render();request('/settings-changed').catch(e=>console.warn(e));}
|
|
63
|
+
let updateTimer;document.body.addEventListener('input',event=>{if(event.target.id==='language')return;markRestartRequired();clearTimeout(updateTimer);updateTimer=setTimeout(()=>update().catch(e=>homebridge.toast.error(String(e))),150);});
|
|
64
|
+
const esc=value=>String(value).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
65
|
+
const auxiliaryNames=new Set(['coefficient','unitForCumulativeElectricEnergy']);
|
|
66
|
+
function visibleProperties(device){return Object.entries(device.properties||{}).filter(([,meta])=>!auxiliaryNames.has(typeof meta==='string'?meta:meta.name));}
|
|
67
|
+
function propertyLabel(meta){if(typeof meta==='string')return meta;return meta?.[effective()]||meta?.name||'';}
|
|
68
|
+
function setting(device){let value=(baseConfig.deviceSettings||[]).find(x=>x.id===device.id);if(!value){value={id:device.id,name:device.name,enabled:false};const keys=visibleProperties(device).map(([epc])=>epc);if(keys.length)value.properties=keys;baseConfig.deviceSettings=[...(baseConfig.deviceSettings||[]),value];}return value;}
|
|
69
|
+
function hiddenByAppleMode(device){return meterDisplayMode.value==='appleHome'&&['0279','027e','0280','0282','0287','0288'].includes(String(device.eoj).slice(0,4).toLowerCase());}
|
|
70
|
+
function homeKitSupported(device){return ['0011','0012','0130','0133','0134','0135','0263','026b','026f','0272','0273','0279','027b','027e','0280','0281','0282','0287','0288','0290','0291','02a6','05fd'].includes(String(device.eoj).slice(0,4).toLowerCase());}
|
|
71
|
+
function renderDevices(){const t=text[effective()];deviceList.innerHTML=discoveredDevices.length?discoveredDevices.map((device,index)=>{const value=setting(device),properties=visibleProperties(device),selected=Array.isArray(value.properties)?value.properties:properties.map(([epc])=>epc),hidden=hiddenByAppleMode(device),supported=homeKitSupported(device),disabled=hidden||!supported,status=!supported?t.notSupported:hidden?t.hiddenByMode:t.published;return `<article class="elsp-device${disabled?' elsp-disabled':''}"><div class="elsp-device-head"><div><strong>${esc(device.name)}</strong><div class="help">${esc(device.ip)} · ${esc(device.eoj)}</div></div><label class="elsp-toggle"><input type="checkbox" data-device="${index}" data-role="enabled" ${value.enabled===true?'checked':''} ${disabled?'disabled':''}> ${status}</label></div><label>${t.displayName}<input data-device="${index}" data-role="name" value="${esc(value.name||device.name)}"></label>${properties.length?`<fieldset><legend>${t.properties}</legend><div class="elsp-properties">${properties.map(([epc,meta])=>`<label><input type="checkbox" data-device="${index}" data-property="${esc(epc)}" ${selected.includes(epc)?'checked':''}> ${esc(propertyLabel(meta))} <small>EPC ${esc(epc.toUpperCase())}</small></label>`).join('')}</div></fieldset>`:''}</article>`}).join(''):`<p class="help">${t.emptyDevices}</p>`;homebridge.fixScrollHeight?.();}
|
|
72
|
+
async function mergeDevices(liveDevices=[]){
|
|
73
|
+
const configured=(baseConfig.deviceSettings||[]).map(setting=>{const match=String(setting.id||'').match(/^(.*)-([0-9a-f]{6})$/i);return match?{id:setting.id,ip:match[1],eoj:match[2],name:setting.name||match[2],properties:{}}:null;}).filter(Boolean);
|
|
74
|
+
const cached=await homebridge.getCachedAccessories().catch(()=>[]);
|
|
75
|
+
const cachedDevices=(cached||[]).filter(a=>a.context?.ip&&a.context?.eoj).map(a=>({id:`${a.context.ip}-${a.context.eoj}`,ip:a.context.ip,eoj:a.context.eoj,name:a.displayName||a.context.eoj,properties:a.context.detectedProperties||{}}));
|
|
76
|
+
const cachedDiscovery=(cached||[]).flatMap(a=>Array.isArray(a.context?.echonetDiscoveredDevices)?a.context.echonetDiscoveredDevices:[]);
|
|
77
|
+
const merged=new Map();
|
|
78
|
+
for(const device of [...configured,...cachedDevices,...cachedDiscovery,...(Array.isArray(liveDevices)?liveDevices:[])])merged.set(device.id,{...(merged.get(device.id)||{}),...device,properties:{...(merged.get(device.id)?.properties||{}),...(device.properties||{})}});
|
|
79
|
+
discoveredDevices=[...merged.values()];
|
|
80
|
+
renderDevices();
|
|
81
|
+
}
|
|
82
|
+
async function loadDevices(retry=true){
|
|
83
|
+
let result;
|
|
84
|
+
try{result=await request('/devices',4000);}catch(error){console.warn(error);if(retry)setTimeout(()=>loadDevices(false).catch(e=>console.warn(e)),5000);}
|
|
85
|
+
const liveDevices=Array.isArray(result)?result:Array.isArray(result?.devices)?result.devices:Array.isArray(result?.data?.devices)?result.data.devices:[];
|
|
86
|
+
await mergeDevices(liveDevices);
|
|
87
|
+
}
|
|
88
|
+
deviceList.addEventListener('change',event=>{const input=event.target,index=Number(input.dataset.device),device=discoveredDevices[index];if(!device)return;const value=setting(device);if(input.dataset.role==='enabled')value.enabled=input.checked;else if(input.dataset.role==='name')value.name=input.value;else if(input.dataset.property){value.properties=value.properties||[];value.properties=input.checked?[...new Set([...value.properties,input.dataset.property])]:value.properties.filter(x=>x!==input.dataset.property);}update().catch(e=>homebridge.toast.error(String(e)));});
|
|
89
|
+
rediscover.addEventListener('click',async()=>{
|
|
90
|
+
if(needsRestart){homebridge.toast.error(text[effective()].restartRequiredToast);return;}
|
|
91
|
+
try{await update();}catch(e){homebridge.toast.error(String(e));return;}
|
|
92
|
+
homebridge.request('/rediscover')
|
|
93
|
+
.then(async result=>{if(result?.restartRequired){needsRestart=true;render();homebridge.toast.error(text[effective()].restartRequiredToast);return;}homebridge.toast.success(text[effective()].restartNotice);setTimeout(()=>loadDevices().catch(e=>homebridge.toast.error(String(e))),6500);})
|
|
94
|
+
.catch(e=>homebridge.toast.error(String(e)));
|
|
95
|
+
setTimeout(()=>loadDevices(false).catch(e=>console.warn(e)),6500);
|
|
96
|
+
homebridge.toast.info(text[effective()].restartNotice);
|
|
97
|
+
});
|
|
98
|
+
homebridge.addEventListener('echonet-devices',event=>mergeDevices(event.data?.devices).catch(e=>console.warn(e)));
|
|
99
|
+
homebridge.addEventListener('echonet-settings-status',event=>{needsRestart=Boolean(event.data?.restartRequired);render();});
|
|
100
|
+
homebridge.showSpinner();load().catch(e=>{homebridge.hideSpinner();render();renderDevices();homebridge.toast.error(String(e));});
|
|
101
|
+
</script>
|
|
102
|
+
</body>
|
|
103
|
+
</html>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.elsp-shell{padding:.25rem}
|
|
2
|
+
.elsp-heading{align-items:flex-start;display:flex;gap:1rem;justify-content:space-between;margin-bottom:1rem}
|
|
3
|
+
.elsp-heading h3{margin-bottom:.25rem}.elsp-language{flex:0 0 9rem}
|
|
4
|
+
.elsp-section{border:1px solid var(--bs-border-color,#ddd);border-radius:.5rem;margin-bottom:.75rem;overflow:hidden}
|
|
5
|
+
.elsp-section summary{cursor:pointer;font-size:1.05rem;font-weight:600;padding:.9rem 1rem;user-select:none}
|
|
6
|
+
.elsp-section-body{border-top:1px solid var(--bs-border-color,#ddd);padding:1rem}
|
|
7
|
+
.elsp-grid{display:grid;gap:.9rem;grid-template-columns:minmax(0,1fr)}
|
|
8
|
+
.elsp-field{display:block;margin:0;min-width:0;width:100%}.elsp-field label{display:block;font-weight:600;margin-bottom:.35rem}
|
|
9
|
+
.elsp-field input:not([type=checkbox]),.elsp-field select{box-sizing:border-box;display:block;width:100%;padding:.5rem .75rem;border:1px solid var(--bs-border-color,#ced4da);border-radius:.375rem;background:var(--bs-body-bg,transparent);color:inherit}
|
|
10
|
+
.elsp-field.check{align-items:center;display:flex}.elsp-field.check input{flex:0 0 auto;margin:0 .5rem 0 0}.elsp-field.check label{display:inline;margin:0}
|
|
11
|
+
.help{display:block;font-size:.84rem;margin-top:.25rem;opacity:.7}
|
|
12
|
+
.elsp-actions{display:flex;justify-content:flex-start;margin-top:.1rem}
|
|
13
|
+
#rediscover{display:inline-block;margin:0;padding:.4rem .85rem;width:auto}
|
|
14
|
+
.elsp-device-list{display:grid;gap:.8rem;margin-top:.8rem}.elsp-device{border:1px solid var(--bs-border-color,#ddd);border-radius:.5rem;padding:.85rem}.elsp-device-head{align-items:flex-start;display:flex;gap:1rem;justify-content:space-between;margin-bottom:.75rem}.elsp-device label{display:block;font-weight:600}.elsp-device input[type=text],.elsp-device input:not([type=checkbox]){box-sizing:border-box;display:block;margin-top:.3rem;width:100%;padding:.45rem .65rem}.elsp-toggle{white-space:nowrap}.elsp-device fieldset{border:0;margin:.8rem 0 0;padding:0}.elsp-device legend{font-size:.9rem;font-weight:600;margin-bottom:.4rem}.elsp-properties{display:grid;gap:.35rem;grid-template-columns:repeat(2,minmax(0,1fr))}.elsp-properties label{font-weight:400}.elsp-properties small{opacity:.6}
|
|
15
|
+
.elsp-disabled{opacity:.65}.elsp-disabled .elsp-toggle{white-space:normal}
|
|
16
|
+
@media(max-width:640px){.elsp-heading{display:block}.elsp-language{margin-top:.75rem;width:100%}.elsp-device-head{display:block}.elsp-toggle{margin-top:.5rem}.elsp-properties{grid-template-columns:1fr}}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const fs = require('node:fs')
|
|
2
|
+
const path = require('node:path')
|
|
3
|
+
;(async () => {
|
|
4
|
+
const { HomebridgePluginUiServer } = await import('@homebridge/plugin-ui-utils')
|
|
5
|
+
class EchonetLiteUiServer extends HomebridgePluginUiServer {
|
|
6
|
+
constructor() {
|
|
7
|
+
super()
|
|
8
|
+
const storagePath = () => {
|
|
9
|
+
if (!this.homebridgeStoragePath) throw new Error('Homebridge storage path is unavailable')
|
|
10
|
+
return this.homebridgeStoragePath
|
|
11
|
+
}
|
|
12
|
+
const restartMarker = () => path.join(storagePath(), 'echonet-lite-plus-restart-required')
|
|
13
|
+
const deviceFile = () => path.join(storagePath(), 'echonet-lite-plus-devices.json')
|
|
14
|
+
const readDevices = () => {
|
|
15
|
+
const file = deviceFile()
|
|
16
|
+
try {
|
|
17
|
+
const result = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
18
|
+
if (!Array.isArray(result?.devices)) throw new Error('devices is not an array')
|
|
19
|
+
return result
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error?.code === 'ENOENT') return { devices: [], updatedAt: 0 }
|
|
22
|
+
console.error(`ECHONET Lite device cache could not be read: ${file}: ${String(error)}`)
|
|
23
|
+
return { devices: [], updatedAt: 0 }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const pushDevices = () => this.pushEvent('echonet-devices', readDevices())
|
|
27
|
+
this.onRequest('/settings-changed', async () => {
|
|
28
|
+
fs.writeFileSync(restartMarker(), String(Date.now()))
|
|
29
|
+
this.pushEvent('echonet-settings-status', { restartRequired: true })
|
|
30
|
+
return { restartRequired: true }
|
|
31
|
+
})
|
|
32
|
+
this.onRequest('/settings-status', async () => {
|
|
33
|
+
const status = { restartRequired: fs.existsSync(restartMarker()) }
|
|
34
|
+
this.pushEvent('echonet-settings-status', status)
|
|
35
|
+
return status
|
|
36
|
+
})
|
|
37
|
+
this.onRequest('/rediscover', async () => {
|
|
38
|
+
if (fs.existsSync(restartMarker())) {
|
|
39
|
+
this.pushEvent('echonet-settings-status', { restartRequired: true })
|
|
40
|
+
return { accepted: false, restartRequired: true }
|
|
41
|
+
}
|
|
42
|
+
fs.writeFileSync(path.join(storagePath(), 'echonet-lite-plus-rediscover.json'), JSON.stringify({ requestedAt: Date.now() }))
|
|
43
|
+
setTimeout(pushDevices, 6500)
|
|
44
|
+
return { accepted: true }
|
|
45
|
+
})
|
|
46
|
+
this.onRequest('/devices', async () => {
|
|
47
|
+
const result = readDevices()
|
|
48
|
+
// Send the normal request response first, then provide the same list as
|
|
49
|
+
// a stream event. Some Homebridge UI/browser combinations have lost a
|
|
50
|
+
// request response while keeping the custom UI process alive.
|
|
51
|
+
setTimeout(() => this.pushEvent('echonet-devices', result), 0)
|
|
52
|
+
return result
|
|
53
|
+
})
|
|
54
|
+
this.ready()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
new EchonetLiteUiServer()
|
|
58
|
+
})().catch(error => {
|
|
59
|
+
console.error(error)
|
|
60
|
+
process.exitCode = 1
|
|
61
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-echonet-lite-plus",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Expose ECHONET Lite devices directly to HomeKit",
|
|
5
|
+
"author": "ECHONET Lite Plus contributors",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/sylpied/homebridge-echonet-lite-plus#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/sylpied/homebridge-echonet-lite-plus.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/sylpied/homebridge-echonet-lite-plus/issues"
|
|
14
|
+
},
|
|
15
|
+
"main": "dist/index.js",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"homebridge-ui",
|
|
19
|
+
"config.schema.json",
|
|
20
|
+
"README.md",
|
|
21
|
+
"CHANGELOG.md",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"work/MRA_v1.4.0.zip"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"homebridge-plugin",
|
|
27
|
+
"echonet-lite",
|
|
28
|
+
"homekit",
|
|
29
|
+
"hems"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18",
|
|
33
|
+
"homebridge": ">=1.6.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "rimraf dist && tsc",
|
|
37
|
+
"test": "jest --runInBand"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@homebridge/plugin-ui-utils": "^2.2.5",
|
|
41
|
+
"adm-zip": "^0.5.16",
|
|
42
|
+
"echonet-lite": "^2.19.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/adm-zip": "^0.5.7",
|
|
46
|
+
"@types/jest": "^29.5.14",
|
|
47
|
+
"@types/node": "^22.15.3",
|
|
48
|
+
"homebridge": "^1.11.0",
|
|
49
|
+
"jest": "^29.7.0",
|
|
50
|
+
"rimraf": "^6.0.1",
|
|
51
|
+
"ts-jest": "^29.3.2",
|
|
52
|
+
"typescript": "^5.8.3"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
Binary file
|