homebridge-enertalk-km81 1.0.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/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Km81
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.
22
+
23
+ ---
24
+
25
+ Portions of this software are derived from:
26
+ - homebridge-xiaomi-fan (Marcin, MIT) — fan device implementations
27
+ - homebridge-miot (merdok, MIT) — miio low-level protocol
28
+ - homebridge-mi-humidifier (nt0xa, MIT) — humidifier model definitions
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # homebridge-enertalk-km81
2
+
3
+ EnerTalk(Encored) 전력 미터를 Homebridge/HomeKit 으로 노출하는 플러그인.
4
+
5
+ 에너톡 제조사 앱은 앱스토어에서 내려갔고 개발자 포털(developer.enertalk.com)도 폐쇄됐지만,
6
+ 기기는 여전히 클라우드로 실시간 데이터를 올리고 있고 `api2.enertalk.com` / `auth2.enertalk.com`
7
+ 은 살아있습니다. 이 플러그인은 **에너톡 앱 로그인 이메일/비밀번호**만으로 토큰을 발급받아
8
+ 데이터를 가져옵니다. (별도 개발자 등록 불필요)
9
+
10
+ > 참고: 에너톡 앱 화면에서 사용량이 0 으로만 보이더라도, 그건 앱의 유료 "베이직 서비스"
11
+ > 구독이 만료돼 **앱 UI 만** 가려진 것입니다. 원시 API 는 실시간 값을 그대로 내려줍니다.
12
+
13
+ ## 노출되는 값
14
+
15
+ 기본은 **Outlet 서비스 + Eve 커스텀 특성**으로 노출됩니다. **Eve 앱**에서 확인하세요.
16
+
17
+ | 특성 | 의미 | 원시 필드 |
18
+ |---|---|---|
19
+ | Consumption (W) | 실시간 소비전력 | `activePower` /1000 |
20
+ | Total Consumption (kWh) | 당월 누적(검침일 기준) | `usage` /1e6 |
21
+ | Voltage (V) | 전압 | `voltage` /1000 |
22
+ | Electric Current (A) | 전류 | `current` /1000 |
23
+
24
+ Eve 앱은 실시간 W 를 자동으로 히스토리 그래프로 쌓아줍니다.
25
+
26
+ ### Apple '홈' 앱에서 숫자를 보고 싶다면
27
+
28
+ 홈킷에는 전력 표준 특성이 없어 Apple 기본 '홈' 앱은 위 값을 숫자로 못 보여줍니다.
29
+ `exposeLightSensors` 를 켜면 **조도센서(lux) 트릭**으로 실시간 W 와 당월 kWh 를 추가 노출해
30
+ 홈 앱 기본화면에서 숫자를 볼 수 있습니다 (단위는 lux 로 표시됨).
31
+
32
+ ## 설치
33
+
34
+ ```bash
35
+ npm install -g homebridge-enertalk-km81
36
+ ```
37
+
38
+ (이 저장소 서브폴더에서 직접 쓰는 경우: `npm install -g ./homebridge-enertalk-km81`)
39
+
40
+ ## 설정
41
+
42
+ Homebridge UI 의 플러그인 설정 화면(`EnerTalkKm81`)에서 이메일/비밀번호를 입력하거나,
43
+ `config.json` 의 `platforms` 에 아래를 추가합니다.
44
+
45
+ ```json
46
+ {
47
+ "platform": "EnerTalkKm81",
48
+ "name": "EnerTalk",
49
+ "email": "you@example.com",
50
+ "password": "your-enertalk-password",
51
+ "pollingInterval": 30,
52
+ "billingInterval": 300,
53
+ "exposeLightSensors": false
54
+ }
55
+ ```
56
+
57
+ | 항목 | 기본 | 설명 |
58
+ |---|---|---|
59
+ | `email` / `password` | (필수) | 에너톡 앱 로그인 자격증명 |
60
+ | `pollingInterval` | 30 | 실시간 W 조회 주기(초) |
61
+ | `billingInterval` | 300 | 당월 누적 kWh/요금 조회 주기(초) |
62
+ | `exposeLightSensors` | false | 홈 앱용 조도센서 미러 노출 |
63
+ | `clientId` / `clientSecret` | (선택) | 비우면 앱 기본값 사용 |
64
+
65
+ 계정에 연결된 site 가 여러 개면 각각 액세서리로 등록됩니다.
66
+
67
+ ## 동작 방식
68
+
69
+ 1. `POST https://auth2.enertalk.com/token` — `grant_type=password` 로 access_token 발급
70
+ (만료 시 자동 재발급, 401 시 1회 재로그인).
71
+ 2. `GET https://api2.enertalk.com/sites` — site 목록.
72
+ 3. 주기적으로 `/sites/{id}/usages/realtime` (실시간) 과 `/usages/billing` (당월 누적) 폴링.
73
+
74
+ ## 보안 메모
75
+
76
+ - 플러그인이 사용하는 client_id/secret 은 에너톡 앱(APK)에 공개적으로 포함된 값이라
77
+ 노출돼도 계정 위험과 무관합니다.
78
+ - 다만 **에너톡 계정 비밀번호**는 `config.json` 에 평문 저장되니, Homebridge 호스트
79
+ 접근 권한을 신뢰할 수 있는 환경에서만 사용하세요.
80
+
81
+ ## 라이선스
82
+
83
+ MIT
@@ -0,0 +1,77 @@
1
+ {
2
+ "pluginAlias": "EnerTalkKm81",
3
+ "pluginType": "platform",
4
+ "singular": true,
5
+ "customUi": true,
6
+ "headerDisplay": "EnerTalk(Encored) 전력 미터를 HomeKit 으로 노출합니다. 에너톡 앱 로그인 이메일/비밀번호만 있으면 됩니다. 실시간 소비전력(W)과 당월 누적 사용량(kWh)은 **Eve 앱**에서 그래프까지 확인할 수 있고, 홈 앱 기본화면에서 숫자를 보고 싶으면 아래 '조도센서 노출'을 켜세요.",
7
+ "footerDisplay": "developer.enertalk.com 이 폐쇄돼도 auth2/api2 는 동작합니다. client_id/secret 은 비워두면 앱 기본값을 사용합니다.",
8
+ "schema": {
9
+ "type": "object",
10
+ "properties": {
11
+ "name": {
12
+ "title": "액세서리 이름",
13
+ "type": "string",
14
+ "default": "EnerTalk",
15
+ "description": "비워두면 site 이름(예: F3000DB8)을 사용합니다."
16
+ },
17
+ "email": {
18
+ "title": "에너톡 이메일",
19
+ "type": "string",
20
+ "required": true,
21
+ "format": "email"
22
+ },
23
+ "password": {
24
+ "title": "에너톡 비밀번호",
25
+ "type": "string",
26
+ "required": true,
27
+ "x-schema-form": { "type": "password" }
28
+ },
29
+ "pollingInterval": {
30
+ "title": "실시간 폴링 주기(초)",
31
+ "type": "integer",
32
+ "default": 30,
33
+ "minimum": 10,
34
+ "maximum": 3600,
35
+ "description": "실시간 소비전력(W) 조회 주기. 너무 짧게 두지 마세요(권장 30초)."
36
+ },
37
+ "billingInterval": {
38
+ "title": "당월 사용량 폴링 주기(초)",
39
+ "type": "integer",
40
+ "default": 300,
41
+ "minimum": 60,
42
+ "maximum": 86400,
43
+ "description": "당월 누적 사용량(kWh)/요금 조회 주기(권장 300초)."
44
+ },
45
+ "exposeLightSensors": {
46
+ "title": "조도센서(lux) 미러 노출 — 홈 앱 기본화면용",
47
+ "type": "boolean",
48
+ "default": false,
49
+ "description": "켜면 실시간 W 와 당월 kWh 를 조도센서 값(lux)으로도 노출해 Apple '홈' 앱에서 숫자를 볼 수 있습니다. Eve 앱만 쓰면 꺼두어도 됩니다."
50
+ },
51
+ "clientId": {
52
+ "title": "client_id (고급, 선택)",
53
+ "type": "string",
54
+ "description": "비워두면 앱 기본 client_id 사용."
55
+ },
56
+ "clientSecret": {
57
+ "title": "client_secret (고급, 선택)",
58
+ "type": "string",
59
+ "x-schema-form": { "type": "password" },
60
+ "description": "비워두면 앱 기본 client_secret 사용."
61
+ }
62
+ },
63
+ "required": ["email", "password"]
64
+ },
65
+ "layout": [
66
+ "name",
67
+ "email",
68
+ "password",
69
+ {
70
+ "type": "fieldset",
71
+ "title": "고급 설정",
72
+ "expandable": true,
73
+ "expanded": false,
74
+ "items": ["pollingInterval", "billingInterval", "exposeLightSensors", "clientId", "clientSecret"]
75
+ }
76
+ ]
77
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,81 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <style>
6
+ .et-card { border: 1px solid #d9dde2; border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
7
+ .et-title { font-weight: 600; margin-bottom: 6px; }
8
+ .et-sub { color: #6c757d; font-size: 0.83rem; line-height: 1.5; margin: 0 0 12px 0; }
9
+ .et-result { margin-top: 12px; padding: 10px 12px; border-radius: 8px; font-size: 0.88rem; line-height: 1.5; display: none; }
10
+ .et-result.show { display: block; }
11
+ .et-result.ok { background: #e6f4ea; color: #1e7e34; border: 1px solid #bfe3c9; }
12
+ .et-result.none { background: #fdecea; color: #b02a37; border: 1px solid #f3c4c0; }
13
+ .et-result.wait { background: #fff6e5; color: #946200; border: 1px solid #f0dcb0; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <div class="et-card">
18
+ <div class="et-title">EnerTalk 연결 테스트</div>
19
+ <p class="et-sub">
20
+ 아래 폼에 <b>에너톡 앱 이메일/비밀번호</b>를 입력한 뒤 <b>연결 테스트</b>를 누르면,
21
+ 로그인·사이트 조회·현재 실시간값(W/V/A)·당월 누적(kWh)을 저장 전에 바로 확인합니다.
22
+ <br>사용하는 client_id/secret 은 앱 공개값이라 비워두면 됩니다.
23
+ </p>
24
+ <button id="etTestBtn" class="btn btn-primary btn-sm" type="button">연결 테스트 / 사이트 조회</button>
25
+ <div id="etResult" class="et-result"></div>
26
+ </div>
27
+
28
+ <script>
29
+ (async () => {
30
+ // 표준 설정 폼(이메일/비밀번호/주기 등)을 그대로 렌더링
31
+ homebridge.showSchemaForm();
32
+
33
+ const resultEl = document.getElementById('etResult');
34
+ const setResult = (cls, html) => {
35
+ resultEl.className = 'et-result show ' + cls;
36
+ resultEl.innerHTML = html;
37
+ };
38
+
39
+ document.getElementById('etTestBtn').addEventListener('click', async () => {
40
+ const cfgs = await homebridge.getPluginConfig();
41
+ const c = (cfgs && cfgs[0]) || {};
42
+ if (!c.email || !c.password) {
43
+ setResult('none', '먼저 위 폼에 이메일/비밀번호를 입력하세요.');
44
+ return;
45
+ }
46
+ homebridge.showSpinner();
47
+ setResult('wait', '로그인 및 조회 중…');
48
+ try {
49
+ const r = await homebridge.request('/test', {
50
+ email: c.email,
51
+ password: c.password,
52
+ clientId: c.clientId,
53
+ clientSecret: c.clientSecret,
54
+ });
55
+ if (!r || !r.ok) {
56
+ setResult('none', '실패: ' + ((r && r.error) || '알 수 없는 오류'));
57
+ } else {
58
+ let html = '<b>연결 성공 ✓</b><br>사이트 ' + r.sites.length + '개';
59
+ if (r.sites.length) {
60
+ html += ': ' + r.sites.map((s) => s.name + ' (' + String(s.id).slice(0, 8) + '…)').join(', ');
61
+ }
62
+ if (r.realtime) {
63
+ html += '<br>실시간: <b>' + r.realtime.watts + ' W</b> / ' + r.realtime.volts + ' V / ' + r.realtime.amps + ' A';
64
+ }
65
+ if (r.billing) {
66
+ html += '<br>당월 누적: <b>' + r.billing.kwh + ' kWh</b>'
67
+ + (r.billing.charge != null ? (' / ' + r.billing.charge + '원') : '');
68
+ }
69
+ setResult('ok', html);
70
+ homebridge.toast.success('EnerTalk 연결 성공');
71
+ }
72
+ } catch (e) {
73
+ setResult('none', '오류: ' + (e && e.message ? e.message : e));
74
+ } finally {
75
+ homebridge.hideSpinner();
76
+ }
77
+ });
78
+ })();
79
+ </script>
80
+ </body>
81
+ </html>
@@ -0,0 +1,72 @@
1
+ /*
2
+ * Homebridge Custom UI 서버 (ESM).
3
+ *
4
+ * 설정 화면에서 '연결 테스트' 버튼을 누르면, 입력한 이메일/비밀번호로 EnerTalk 클라우드에
5
+ * 로그인해 site 목록과 현재 실시간값/당월 누적을 조회해 돌려준다. 실제 인증이 되는지,
6
+ * 어느 site 가 잡히는지 저장 전에 바로 확인할 수 있다.
7
+ *
8
+ * @homebridge/plugin-ui-utils v2 는 ESM 전용이라 이 파일도 ESM 으로 작성한다
9
+ * (homebridge-ui/package.json 의 "type":"module"). 본체 플러그인(index.js/lib)은
10
+ * CommonJS 이며 createRequire 로 지연 로드한다.
11
+ */
12
+
13
+ import { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils';
14
+ import { createRequire } from 'module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+
18
+ function round(n, d) {
19
+ const f = Math.pow(10, d);
20
+ return Math.round(Number(n || 0) * f) / f;
21
+ }
22
+
23
+ class UiServer extends HomebridgePluginUiServer {
24
+ constructor() {
25
+ super();
26
+ this.onRequest('/test', this.handleTest.bind(this));
27
+ this.ready();
28
+ }
29
+
30
+ async handleTest(payload) {
31
+ const EnerTalkApi = require('../lib/EnerTalkApi.js');
32
+ const { email, password, clientId, clientSecret } = payload || {};
33
+ if (!email || !password) {
34
+ return { ok: false, error: '이메일과 비밀번호를 입력하세요.' };
35
+ }
36
+ try {
37
+ const client = new EnerTalkApi({ email, password, clientId, clientSecret });
38
+ await client.login();
39
+
40
+ const sitesRaw = await client.getSites();
41
+ const sites = Array.isArray(sitesRaw)
42
+ ? sitesRaw.map((s) => ({ id: s.id, name: s.name || s.id }))
43
+ : [];
44
+
45
+ let realtime = null;
46
+ let billing = null;
47
+ if (sites.length) {
48
+ try {
49
+ const rt = await client.getRealtime(sites[0].id);
50
+ realtime = {
51
+ watts: round(EnerTalkApi.toWatts(rt.activePower), 1),
52
+ volts: round(EnerTalkApi.toVolts(rt.voltage), 1),
53
+ amps: round(EnerTalkApi.toAmps(rt.current), 2),
54
+ };
55
+ } catch (e) { /* 실시간 실패는 치명적이지 않음 */ }
56
+ try {
57
+ const b = await client.getBilling(sites[0].id);
58
+ billing = {
59
+ kwh: round(EnerTalkApi.toKwh(b.usage), 2),
60
+ charge: b && b.bill ? b.bill.charge : null,
61
+ };
62
+ } catch (e) { /* billing 실패도 무시 */ }
63
+ }
64
+
65
+ return { ok: true, sites, realtime, billing };
66
+ } catch (e) {
67
+ return { ok: false, error: e && e.message ? e.message : String(e) };
68
+ }
69
+ }
70
+ }
71
+
72
+ (() => new UiServer())();
package/index.js ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * homebridge-enertalk-km81
3
+ *
4
+ * EnerTalk(Encored) 전력 미터를 Homebridge/HomeKit 으로 노출한다.
5
+ * 제조사 앱/개발자포털이 사실상 종료됐지만, 기기는 여전히 클라우드로 실시간 업로드 중이고
6
+ * api2.enertalk.com 이 살아있어, 앱 번들의 공개 client 자격증명 + password grant 로
7
+ * 이메일/비밀번호만으로 데이터를 끌어온다.
8
+ *
9
+ * 노출 방식:
10
+ * - 기본: Outlet 서비스 + Eve 커스텀 특성
11
+ * · Consumption(W) = 실시간 소비전력
12
+ * · Total Consumption(kWh)= 당월 누적(검침일 기준)
13
+ * · Voltage(V) / Electric Current(A)
14
+ * → Eve 앱에서 실시간 W + 전력량 그래프까지 보인다.
15
+ * - 옵션(exposeLightSensors): Apple '홈' 앱 기본 화면에서 숫자를 보고 싶을 때
16
+ * 조도센서(lux) 트릭으로 실시간 W / 당월 kWh 를 추가 노출한다.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const packageJson = require('./package.json');
22
+ const EnerTalkApi = require('./lib/EnerTalkApi.js');
23
+ const buildEveCharacteristics = require('./lib/EveCharacteristics.js');
24
+
25
+ const PLUGIN_NAME = packageJson.name; // homebridge-enertalk-km81
26
+ const PLATFORM_NAME = 'EnerTalkKm81'; // config.schema.json 의 pluginAlias
27
+
28
+ module.exports = (homebridge) => {
29
+ homebridge.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, EnerTalkPlatform, true /* dynamic */);
30
+ };
31
+
32
+ class EnerTalkPlatform {
33
+ constructor(log, config, api) {
34
+ this.log = log;
35
+ this.config = config || {};
36
+ this.api = api;
37
+ this.hap = api.hap;
38
+ this.accessories = new Map(); // uuid -> PlatformAccessory (캐시 복원본)
39
+ this.contexts = new Map(); // uuid -> { services..., timers } 런타임 상태
40
+
41
+ this.Eve = buildEveCharacteristics(this.hap);
42
+
43
+ // 설정값
44
+ this.pollingInterval = Math.max(10, Number(this.config.pollingInterval) || 30); // 초, 실시간
45
+ this.billingInterval = Math.max(60, Number(this.config.billingInterval) || 300); // 초, 당월 누적
46
+ this.exposeLightSensors = this.config.exposeLightSensors === true;
47
+
48
+ if (!this.config.email || !this.config.password) {
49
+ this.log.error('[EnerTalk] config 에 email/password 가 없습니다. 플러그인을 시작하지 않습니다.');
50
+ this.enabled = false;
51
+ } else {
52
+ this.enabled = true;
53
+ this.client = new EnerTalkApi({
54
+ email: this.config.email,
55
+ password: this.config.password,
56
+ clientId: this.config.clientId,
57
+ clientSecret: this.config.clientSecret,
58
+ log: this.log,
59
+ });
60
+ }
61
+
62
+ if (this.api) {
63
+ this.api.on('didFinishLaunching', () => this._start().catch((e) => {
64
+ this.log.error('[EnerTalk] 시작 실패:', e && e.message ? e.message : e);
65
+ }));
66
+ this.api.on('shutdown', () => this._stopAllTimers());
67
+ }
68
+ }
69
+
70
+ /** Homebridge 가 캐시된 액세서리를 복원할 때 호출 */
71
+ configureAccessory(accessory) {
72
+ this.accessories.set(accessory.UUID, accessory);
73
+ }
74
+
75
+ async _start() {
76
+ if (!this.enabled) return;
77
+
78
+ let sites;
79
+ try {
80
+ sites = await this.client.getSites();
81
+ } catch (e) {
82
+ this.log.error('[EnerTalk] site 목록 조회 실패 — 이메일/비밀번호를 확인하세요:', e.message);
83
+ return;
84
+ }
85
+ if (!Array.isArray(sites) || sites.length === 0) {
86
+ this.log.error('[EnerTalk] 연결된 site 가 없습니다.');
87
+ return;
88
+ }
89
+
90
+ const seen = new Set();
91
+ for (const site of sites) {
92
+ if (!site || !site.id) continue;
93
+ const label = this.config.name || site.name || site.id;
94
+ const uuid = this.api.hap.uuid.generate(`${PLUGIN_NAME}:${site.id}`);
95
+ seen.add(uuid);
96
+ this._setupSiteAccessory(uuid, site, label);
97
+ }
98
+
99
+ // config 에서 사라졌거나 계정에 없는 캐시 액세서리 정리
100
+ for (const [uuid, acc] of this.accessories) {
101
+ if (!seen.has(uuid)) {
102
+ this.log.info('[EnerTalk] 사용하지 않는 액세서리 제거:', acc.displayName);
103
+ this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [acc]);
104
+ this.accessories.delete(uuid);
105
+ }
106
+ }
107
+ }
108
+
109
+ _setupSiteAccessory(uuid, site, label) {
110
+ const { Service, Characteristic } = this.hap;
111
+ let accessory = this.accessories.get(uuid);
112
+ const isNew = !accessory;
113
+
114
+ if (isNew) {
115
+ accessory = new this.api.platformAccessory(label, uuid);
116
+ this.accessories.set(uuid, accessory);
117
+ }
118
+ accessory.context.siteId = site.id;
119
+
120
+ // AccessoryInformation
121
+ const info = accessory.getService(Service.AccessoryInformation)
122
+ || accessory.addService(Service.AccessoryInformation);
123
+ info
124
+ .setCharacteristic(Characteristic.Manufacturer, 'Encored / EnerTalk')
125
+ .setCharacteristic(Characteristic.Model, 'EnerTalk Energy Meter')
126
+ .setCharacteristic(Characteristic.SerialNumber, String(site.id).slice(0, 16))
127
+ .setCharacteristic(Characteristic.FirmwareRevision, packageJson.version);
128
+
129
+ // ── 메인: Outlet + Eve 특성 ────────────────────────────────
130
+ const outlet = accessory.getService(Service.Outlet)
131
+ || accessory.addService(Service.Outlet, label);
132
+ outlet.setCharacteristic(Characteristic.Name, label);
133
+ // 상시 ON/사용중으로 고정 (전력 미터는 스위치가 아님)
134
+ outlet.getCharacteristic(Characteristic.On)
135
+ .onGet(() => true)
136
+ .onSet(() => { /* 스위치 아님 — 무시하고 항상 ON 유지 */ });
137
+ outlet.updateCharacteristic(Characteristic.On, true);
138
+ this._ensureCharacteristic(outlet, Characteristic.OutletInUse).onGet(() => true);
139
+ outlet.updateCharacteristic(Characteristic.OutletInUse, true);
140
+ this._ensureCharacteristic(outlet, this.Eve.CurrentConsumption);
141
+ this._ensureCharacteristic(outlet, this.Eve.TotalConsumption);
142
+ this._ensureCharacteristic(outlet, this.Eve.Voltage);
143
+ this._ensureCharacteristic(outlet, this.Eve.ElectricCurrent);
144
+
145
+ // ── 옵션: 홈 앱 기본화면용 조도센서(lux) 미러 ──────────────
146
+ let powerLux = null;
147
+ let usageLux = null;
148
+ if (this.exposeLightSensors) {
149
+ powerLux = accessory.getServiceById(Service.LightSensor, 'power')
150
+ || accessory.addService(Service.LightSensor, `${label} 실시간전력`, 'power');
151
+ powerLux.setCharacteristic(Characteristic.Name, `${label} 실시간전력(W)`);
152
+
153
+ usageLux = accessory.getServiceById(Service.LightSensor, 'usage')
154
+ || accessory.addService(Service.LightSensor, `${label} 당월사용량`, 'usage');
155
+ usageLux.setCharacteristic(Characteristic.Name, `${label} 당월사용량(kWh)`);
156
+ } else {
157
+ // 옵션 껐을 때 이전에 만든 조도센서 제거
158
+ for (const sid of ['power', 'usage']) {
159
+ const s = accessory.getServiceById(Service.LightSensor, sid);
160
+ if (s) accessory.removeService(s);
161
+ }
162
+ }
163
+
164
+ if (isNew) {
165
+ this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
166
+ this.log.info('[EnerTalk] 액세서리 등록:', label);
167
+ } else {
168
+ this.log.info('[EnerTalk] 액세서리 복원:', label);
169
+ }
170
+
171
+ // 런타임 컨텍스트 + 폴링 시작
172
+ this._stopTimers(uuid);
173
+ const ctx = { accessory, site, outlet, powerLux, usageLux, timers: [] };
174
+ this.contexts.set(uuid, ctx);
175
+
176
+ const pollRealtime = () => this._pollRealtime(uuid).catch((e) =>
177
+ this.log.debug('[EnerTalk] realtime 폴링 오류:', e.message));
178
+ const pollBilling = () => this._pollBilling(uuid).catch((e) =>
179
+ this.log.debug('[EnerTalk] billing 폴링 오류:', e.message));
180
+
181
+ pollRealtime();
182
+ pollBilling();
183
+ ctx.timers.push(setInterval(pollRealtime, this.pollingInterval * 1000));
184
+ ctx.timers.push(setInterval(pollBilling, this.billingInterval * 1000));
185
+ }
186
+
187
+ _ensureCharacteristic(service, Ctor) {
188
+ if (!service.testCharacteristic(Ctor)) {
189
+ service.addCharacteristic(Ctor);
190
+ }
191
+ return service.getCharacteristic(Ctor);
192
+ }
193
+
194
+ async _pollRealtime(uuid) {
195
+ const ctx = this.contexts.get(uuid);
196
+ if (!ctx) return;
197
+ const data = await this.client.getRealtime(ctx.site.id);
198
+
199
+ const watts = EnerTalkApi.toWatts(data.activePower);
200
+ const volts = EnerTalkApi.toVolts(data.voltage);
201
+ const amps = EnerTalkApi.toAmps(data.current);
202
+
203
+ ctx.outlet.getCharacteristic(this.Eve.CurrentConsumption).updateValue(round(watts, 1));
204
+ ctx.outlet.getCharacteristic(this.Eve.Voltage).updateValue(round(volts, 1));
205
+ ctx.outlet.getCharacteristic(this.Eve.ElectricCurrent).updateValue(round(amps, 2));
206
+
207
+ if (ctx.powerLux) {
208
+ ctx.powerLux.getCharacteristic(this.hap.Characteristic.CurrentAmbientLightLevel)
209
+ .updateValue(clampLux(watts));
210
+ }
211
+ this.log.debug(`[EnerTalk] realtime: ${round(watts, 1)}W / ${round(volts, 1)}V / ${round(amps, 2)}A`);
212
+ }
213
+
214
+ async _pollBilling(uuid) {
215
+ const ctx = this.contexts.get(uuid);
216
+ if (!ctx) return;
217
+ const data = await this.client.getBilling(ctx.site.id);
218
+
219
+ const kwh = EnerTalkApi.toKwh(data.usage);
220
+ ctx.outlet.getCharacteristic(this.Eve.TotalConsumption).updateValue(round(kwh, 3));
221
+
222
+ if (ctx.usageLux) {
223
+ ctx.usageLux.getCharacteristic(this.hap.Characteristic.CurrentAmbientLightLevel)
224
+ .updateValue(clampLux(kwh));
225
+ }
226
+ const charge = data && data.bill && data.bill.charge != null ? `${data.bill.charge}원` : 'n/a';
227
+ this.log.debug(`[EnerTalk] billing: ${round(kwh, 2)}kWh / ${charge}`);
228
+ }
229
+
230
+ _stopTimers(uuid) {
231
+ const ctx = this.contexts.get(uuid);
232
+ if (ctx && ctx.timers) {
233
+ for (const t of ctx.timers) clearInterval(t);
234
+ ctx.timers = [];
235
+ }
236
+ }
237
+
238
+ _stopAllTimers() {
239
+ for (const uuid of this.contexts.keys()) this._stopTimers(uuid);
240
+ }
241
+ }
242
+
243
+ function round(n, digits) {
244
+ const f = Math.pow(10, digits);
245
+ return Math.round(Number(n || 0) * f) / f;
246
+ }
247
+
248
+ /** 조도센서 특성은 0.0001~100000 lux 범위. W/kWh 를 그 안으로 클램프. */
249
+ function clampLux(v) {
250
+ const x = Number(v || 0);
251
+ if (x < 0.0001) return 0.0001;
252
+ if (x > 100000) return 100000;
253
+ return round(x, 4);
254
+ }
@@ -0,0 +1,149 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * EnerTalk cloud API client.
5
+ *
6
+ * 인증은 앱(kr.encored.enertalk) 번들에 박혀 있는 공개 client 자격증명 + password grant 를
7
+ * 그대로 사용한다. 개발자 포털(developer.enertalk.com)이 폐쇄됐어도 auth2/api2 는 살아있어
8
+ * 이메일/비밀번호만으로 access_token 을 발급받을 수 있다.
9
+ *
10
+ * POST https://auth2.enertalk.com/token
11
+ * Authorization: Basic base64(clientId:clientSecret)
12
+ * { "grant_type": "password", "credentials": { "email", "password" } }
13
+ *
14
+ * 데이터:
15
+ * GET https://api2.enertalk.com/sites
16
+ * GET https://api2.enertalk.com/sites/{siteId}/usages/realtime (accept-version: 2.0.0)
17
+ * GET https://api2.enertalk.com/sites/{siteId}/usages/billing (accept-version: 2.0.0)
18
+ *
19
+ * 원시 값 단위(실측 확인):
20
+ * activePower / billingActivePower : mW → W = /1000
21
+ * voltage : mV → V = /1000
22
+ * current : mA → A = /1000
23
+ * billing.usage / positiveEnergy : mWh → kWh= /1e6
24
+ * bill.charge : KRW (원, 정수)
25
+ */
26
+
27
+ const DEFAULT_CLIENT_ID = 'a29hbnNhbmdAZ21haWwuY29tX0VuZXJ0YWxrS3I=';
28
+ const DEFAULT_CLIENT_SECRET = 'ak1bb5bh00s48d8hz5zw9r882b5bf36y34x6mk1';
29
+ const AUTH_BASE = 'https://auth2.enertalk.com';
30
+ const API_BASE = 'https://api2.enertalk.com';
31
+
32
+ class EnerTalkApi {
33
+ constructor({ email, password, clientId, clientSecret, log } = {}) {
34
+ if (!email || !password) {
35
+ throw new Error('EnerTalkApi: email 과 password 는 필수입니다.');
36
+ }
37
+ this.email = email;
38
+ this.password = password;
39
+ this.clientId = clientId || DEFAULT_CLIENT_ID;
40
+ this.clientSecret = clientSecret || DEFAULT_CLIENT_SECRET;
41
+ this.log = log || console;
42
+
43
+ this.accessToken = null;
44
+ this.refreshToken = null;
45
+ this.expiresAt = 0;
46
+ this._authPromise = null;
47
+ }
48
+
49
+ _basicAuth() {
50
+ return 'Basic ' + Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
51
+ }
52
+
53
+ async _fetch(url, opts = {}, timeoutMs = 15000) {
54
+ const ctrl = new AbortController();
55
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
56
+ try {
57
+ return await fetch(url, { ...opts, signal: ctrl.signal });
58
+ } finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+
63
+ /** password grant 로 새 토큰 발급. 동시 호출은 하나로 합친다. */
64
+ async login() {
65
+ if (this._authPromise) return this._authPromise;
66
+ this._authPromise = (async () => {
67
+ const res = await this._fetch(`${AUTH_BASE}/token`, {
68
+ method: 'POST',
69
+ headers: {
70
+ 'Authorization': this._basicAuth(),
71
+ 'Content-Type': 'application/json',
72
+ },
73
+ body: JSON.stringify({
74
+ grant_type: 'password',
75
+ credentials: { email: this.email, password: this.password },
76
+ }),
77
+ });
78
+ if (!res.ok) {
79
+ const text = await res.text().catch(() => '');
80
+ throw new Error(`EnerTalk 로그인 실패: HTTP ${res.status} ${text}`.trim());
81
+ }
82
+ const data = await res.json();
83
+ if (!data || !data.access_token) {
84
+ throw new Error('EnerTalk 로그인 응답에 access_token 이 없습니다.');
85
+ }
86
+ this.accessToken = data.access_token;
87
+ this.refreshToken = data.refresh_token || null;
88
+ const expiresIn = Number(data.expires_in) || 3600;
89
+ // 만료 60초 전에는 갱신하도록 여유를 둔다.
90
+ this.expiresAt = Date.now() + Math.max(60, expiresIn - 60) * 1000;
91
+ return data;
92
+ })().finally(() => { this._authPromise = null; });
93
+ return this._authPromise;
94
+ }
95
+
96
+ async _ensureToken() {
97
+ if (!this.accessToken || Date.now() >= this.expiresAt) {
98
+ await this.login();
99
+ }
100
+ }
101
+
102
+ async _apiGet(path) {
103
+ await this._ensureToken();
104
+ const doRequest = () => this._fetch(`${API_BASE}${path}`, {
105
+ headers: {
106
+ 'Authorization': `Bearer ${this.accessToken}`,
107
+ 'accept-version': '2.0.0',
108
+ },
109
+ });
110
+
111
+ let res = await doRequest();
112
+ if (res.status === 401) {
113
+ // 토큰 만료/무효 → 재로그인 후 1회 재시도
114
+ await this.login();
115
+ res = await doRequest();
116
+ }
117
+ if (!res.ok) {
118
+ const text = await res.text().catch(() => '');
119
+ throw new Error(`EnerTalk API ${path} 실패: HTTP ${res.status} ${text}`.trim());
120
+ }
121
+ return res.json();
122
+ }
123
+
124
+ /** 계정에 연결된 site 목록. [{ id, name, ... }] */
125
+ getSites() {
126
+ return this._apiGet('/sites');
127
+ }
128
+
129
+ /** 실시간 사용량(원시 단위). */
130
+ getRealtime(siteId) {
131
+ return this._apiGet(`/sites/${encodeURIComponent(siteId)}/usages/realtime`);
132
+ }
133
+
134
+ /** 검침일 기준 당월 누적 사용량 + 요금. */
135
+ getBilling(siteId) {
136
+ return this._apiGet(`/sites/${encodeURIComponent(siteId)}/usages/billing`);
137
+ }
138
+
139
+ // ── 단위 변환 헬퍼 ───────────────────────────────────────────────
140
+ static toWatts(raw) { return Number(raw || 0) / 1000; }
141
+ static toVolts(raw) { return Number(raw || 0) / 1000; }
142
+ static toAmps(raw) { return Number(raw || 0) / 1000; }
143
+ static toKwh(rawMilliWh) { return Number(rawMilliWh || 0) / 1e6; }
144
+ }
145
+
146
+ EnerTalkApi.DEFAULT_CLIENT_ID = DEFAULT_CLIENT_ID;
147
+ EnerTalkApi.DEFAULT_CLIENT_SECRET = DEFAULT_CLIENT_SECRET;
148
+
149
+ module.exports = EnerTalkApi;
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Elgato Eve 커스텀 특성 정의.
5
+ *
6
+ * HomeKit 에는 "전력(W)/전력량(kWh)" 표준 특성이 없어서, Eve 앱이 읽는
7
+ * Elgato 커스텀 UUID 를 그대로 쓴다. Eve 앱에서 실시간 W 와 kWh 그래프(히스토리)까지 보인다.
8
+ *
9
+ * 반환: { CurrentConsumption, TotalConsumption, Voltage, ElectricCurrent }
10
+ * homebridge 의 hap(Characteristic/Formats/Perms)을 넘겨 호출한다.
11
+ */
12
+ module.exports = function buildEveCharacteristics(hap) {
13
+ const { Characteristic, Formats, Perms } = hap;
14
+
15
+ class CurrentConsumption extends Characteristic {
16
+ constructor() {
17
+ super('Consumption', CurrentConsumption.UUID, {
18
+ format: Formats.FLOAT,
19
+ unit: 'W',
20
+ minValue: 0,
21
+ maxValue: 1000000,
22
+ minStep: 0.1,
23
+ perms: [Perms.PAIRED_READ, Perms.NOTIFY],
24
+ });
25
+ this.value = this.getDefaultValue();
26
+ }
27
+ }
28
+ CurrentConsumption.UUID = 'E863F10D-079E-48FF-8F27-9C2605A29F52';
29
+
30
+ class TotalConsumption extends Characteristic {
31
+ constructor() {
32
+ super('Total Consumption', TotalConsumption.UUID, {
33
+ format: Formats.FLOAT,
34
+ unit: 'kWh',
35
+ minValue: 0,
36
+ maxValue: 1000000000,
37
+ minStep: 0.001,
38
+ perms: [Perms.PAIRED_READ, Perms.NOTIFY],
39
+ });
40
+ this.value = this.getDefaultValue();
41
+ }
42
+ }
43
+ TotalConsumption.UUID = 'E863F10C-079E-48FF-8F27-9C2605A29F52';
44
+
45
+ class Voltage extends Characteristic {
46
+ constructor() {
47
+ super('Voltage', Voltage.UUID, {
48
+ format: Formats.FLOAT,
49
+ unit: 'V',
50
+ minValue: 0,
51
+ maxValue: 1000,
52
+ minStep: 0.1,
53
+ perms: [Perms.PAIRED_READ, Perms.NOTIFY],
54
+ });
55
+ this.value = this.getDefaultValue();
56
+ }
57
+ }
58
+ Voltage.UUID = 'E863F10A-079E-48FF-8F27-9C2605A29F52';
59
+
60
+ class ElectricCurrent extends Characteristic {
61
+ constructor() {
62
+ super('Electric Current', ElectricCurrent.UUID, {
63
+ format: Formats.FLOAT,
64
+ unit: 'A',
65
+ minValue: 0,
66
+ maxValue: 1000,
67
+ minStep: 0.01,
68
+ perms: [Perms.PAIRED_READ, Perms.NOTIFY],
69
+ });
70
+ this.value = this.getDefaultValue();
71
+ }
72
+ }
73
+ ElectricCurrent.UUID = 'E863F126-079E-48FF-8F27-9C2605A29F52';
74
+
75
+ return { CurrentConsumption, TotalConsumption, Voltage, ElectricCurrent };
76
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "homebridge-enertalk-km81",
3
+ "version": "1.0.0",
4
+ "description": "Homebridge plugin for EnerTalk (Encored) energy meters — realtime power (W) and monthly usage (kWh) via the EnerTalk cloud API, exposed with Eve energy characteristics.",
5
+ "main": "index.js",
6
+ "engines": {
7
+ "node": ">=18.15.0",
8
+ "homebridge": ">=1.6.0"
9
+ },
10
+ "keywords": [
11
+ "homebridge-plugin",
12
+ "enertalk",
13
+ "encored",
14
+ "energy",
15
+ "power",
16
+ "electricity",
17
+ "eve",
18
+ "homekit"
19
+ ],
20
+ "author": "Km81",
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "@homebridge/plugin-ui-utils": "^2.0.0"
24
+ },
25
+ "files": [
26
+ "index.js",
27
+ "config.schema.json",
28
+ "lib/",
29
+ "homebridge-ui/",
30
+ "LICENSE",
31
+ "README.md"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/Km81/homebridge-enertalk-km81.git"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/Km81/homebridge-enertalk-km81/issues"
39
+ },
40
+ "homepage": "https://github.com/Km81/homebridge-enertalk-km81#readme"
41
+ }