sesame-kit 0.4.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.
Files changed (170) hide show
  1. package/LICENSE +26 -0
  2. package/LICENSE.biz3 +21 -0
  3. package/README.ja.md +225 -0
  4. package/README.md +222 -0
  5. package/bin/sesame.js +8 -0
  6. package/clients/js/sesame-client.mjs +208 -0
  7. package/clients/python/pyproject.toml +5 -0
  8. package/clients/python/sesame_client.py +323 -0
  9. package/clients/python/setup.cfg +11 -0
  10. package/docs/architecture.ja.md +132 -0
  11. package/docs/architecture.md +105 -0
  12. package/docs/commands.ja.md +316 -0
  13. package/docs/commands.md +308 -0
  14. package/docs/library.ja.md +152 -0
  15. package/docs/library.md +152 -0
  16. package/docs/migration.ja.md +13 -0
  17. package/docs/migration.md +13 -0
  18. package/package.json +114 -0
  19. package/src/access.js +375 -0
  20. package/src/account.js +36 -0
  21. package/src/auth.js +248 -0
  22. package/src/ble/devicemodel.js +164 -0
  23. package/src/ble/index.js +185 -0
  24. package/src/ble/protocol.js +319 -0
  25. package/src/ble/session.js +235 -0
  26. package/src/ble/transport.js +279 -0
  27. package/src/cli/access.js +373 -0
  28. package/src/cli/company.js +104 -0
  29. package/src/cli/iot.js +400 -0
  30. package/src/cli/org.js +788 -0
  31. package/src/cli/presetir.js +188 -0
  32. package/src/cli/schedule.js +83 -0
  33. package/src/cli/serve.js +308 -0
  34. package/src/cli.js +1815 -0
  35. package/src/client.js +957 -0
  36. package/src/company.js +147 -0
  37. package/src/config.js +575 -0
  38. package/src/crypto.js +162 -0
  39. package/src/devices.js +228 -0
  40. package/src/index.js +55 -0
  41. package/src/iot.js +513 -0
  42. package/src/ir.js +341 -0
  43. package/src/itemcodes.js +29 -0
  44. package/src/lock.js +194 -0
  45. package/src/org.js +803 -0
  46. package/src/paths.js +30 -0
  47. package/src/presetir.js +525 -0
  48. package/src/prompts.js +74 -0
  49. package/src/schedule.js +108 -0
  50. package/src/serve/daemon.js +251 -0
  51. package/src/serve/framing/grpc.js +145 -0
  52. package/src/serve/framing/http.js +144 -0
  53. package/src/serve/framing/ndjson.js +75 -0
  54. package/src/serve/framing/socket.js +73 -0
  55. package/src/serve/framing/stdio.js +28 -0
  56. package/src/serve/framing/token.js +36 -0
  57. package/src/serve/framing/ws.js +56 -0
  58. package/src/serve/grpc-methods.generated.json +378 -0
  59. package/src/serve/jsonrpc.js +164 -0
  60. package/src/serve/registry.js +226 -0
  61. package/src/serve/rpc-params.generated.json +1746 -0
  62. package/src/serve/sesame.proto +470 -0
  63. package/src/session-ui.js +181 -0
  64. package/src/sharekey.js +130 -0
  65. package/src/tokens.js +53 -0
  66. package/src/transport.js +634 -0
  67. package/src/util.js +26 -0
  68. package/types/access.d.ts +193 -0
  69. package/types/access.d.ts.map +1 -0
  70. package/types/account.d.ts +13 -0
  71. package/types/account.d.ts.map +1 -0
  72. package/types/auth.d.ts +80 -0
  73. package/types/auth.d.ts.map +1 -0
  74. package/types/ble/devicemodel.d.ts +212 -0
  75. package/types/ble/devicemodel.d.ts.map +1 -0
  76. package/types/ble/index.d.ts +160 -0
  77. package/types/ble/index.d.ts.map +1 -0
  78. package/types/ble/protocol.d.ts +201 -0
  79. package/types/ble/protocol.d.ts.map +1 -0
  80. package/types/ble/session.d.ts +129 -0
  81. package/types/ble/session.d.ts.map +1 -0
  82. package/types/ble/transport.d.ts +67 -0
  83. package/types/ble/transport.d.ts.map +1 -0
  84. package/types/cli/access.d.ts +6 -0
  85. package/types/cli/access.d.ts.map +1 -0
  86. package/types/cli/company.d.ts +6 -0
  87. package/types/cli/company.d.ts.map +1 -0
  88. package/types/cli/iot.d.ts +6 -0
  89. package/types/cli/iot.d.ts.map +1 -0
  90. package/types/cli/org.d.ts +6 -0
  91. package/types/cli/org.d.ts.map +1 -0
  92. package/types/cli/presetir.d.ts +6 -0
  93. package/types/cli/presetir.d.ts.map +1 -0
  94. package/types/cli/schedule.d.ts +6 -0
  95. package/types/cli/schedule.d.ts.map +1 -0
  96. package/types/cli/serve.d.ts +2 -0
  97. package/types/cli/serve.d.ts.map +1 -0
  98. package/types/cli.d.ts +2 -0
  99. package/types/cli.d.ts.map +1 -0
  100. package/types/client.d.ts +463 -0
  101. package/types/client.d.ts.map +1 -0
  102. package/types/company.d.ts +94 -0
  103. package/types/company.d.ts.map +1 -0
  104. package/types/config.d.ts +111 -0
  105. package/types/config.d.ts.map +1 -0
  106. package/types/crypto.d.ts +61 -0
  107. package/types/crypto.d.ts.map +1 -0
  108. package/types/devices.d.ts +116 -0
  109. package/types/devices.d.ts.map +1 -0
  110. package/types/index.d.ts +23 -0
  111. package/types/index.d.ts.map +1 -0
  112. package/types/iot.d.ts +312 -0
  113. package/types/iot.d.ts.map +1 -0
  114. package/types/ir.d.ts +147 -0
  115. package/types/ir.d.ts.map +1 -0
  116. package/types/itemcodes.d.ts +21 -0
  117. package/types/itemcodes.d.ts.map +1 -0
  118. package/types/lock.d.ts +89 -0
  119. package/types/lock.d.ts.map +1 -0
  120. package/types/org.d.ts +468 -0
  121. package/types/org.d.ts.map +1 -0
  122. package/types/paths.d.ts +10 -0
  123. package/types/paths.d.ts.map +1 -0
  124. package/types/presetir.d.ts +286 -0
  125. package/types/presetir.d.ts.map +1 -0
  126. package/types/prompts.d.ts +39 -0
  127. package/types/prompts.d.ts.map +1 -0
  128. package/types/schedule.d.ts +71 -0
  129. package/types/schedule.d.ts.map +1 -0
  130. package/types/serve/daemon.d.ts +133 -0
  131. package/types/serve/daemon.d.ts.map +1 -0
  132. package/types/serve/framing/grpc.d.ts +14 -0
  133. package/types/serve/framing/grpc.d.ts.map +1 -0
  134. package/types/serve/framing/http.d.ts +14 -0
  135. package/types/serve/framing/http.d.ts.map +1 -0
  136. package/types/serve/framing/ndjson.d.ts +19 -0
  137. package/types/serve/framing/ndjson.d.ts.map +1 -0
  138. package/types/serve/framing/socket.d.ts +14 -0
  139. package/types/serve/framing/socket.d.ts.map +1 -0
  140. package/types/serve/framing/stdio.d.ts +11 -0
  141. package/types/serve/framing/stdio.d.ts.map +1 -0
  142. package/types/serve/framing/token.d.ts +11 -0
  143. package/types/serve/framing/token.d.ts.map +1 -0
  144. package/types/serve/framing/ws.d.ts +13 -0
  145. package/types/serve/framing/ws.d.ts.map +1 -0
  146. package/types/serve/jsonrpc.d.ts +118 -0
  147. package/types/serve/jsonrpc.d.ts.map +1 -0
  148. package/types/serve/registry.d.ts +41 -0
  149. package/types/serve/registry.d.ts.map +1 -0
  150. package/types/session-ui.d.ts +36 -0
  151. package/types/session-ui.d.ts.map +1 -0
  152. package/types/sharekey.d.ts +35 -0
  153. package/types/sharekey.d.ts.map +1 -0
  154. package/types/tokens.d.ts +20 -0
  155. package/types/tokens.d.ts.map +1 -0
  156. package/types/transport.d.ts +138 -0
  157. package/types/transport.d.ts.map +1 -0
  158. package/types/util.d.ts +20 -0
  159. package/types/util.d.ts.map +1 -0
  160. package/vendor/biz3/README.md +37 -0
  161. package/vendor/biz3/constants/cmdCode.d.ts +48 -0
  162. package/vendor/biz3/constants/cmdCode.d.ts.map +1 -0
  163. package/vendor/biz3/constants/cmdCode.js +92 -0
  164. package/vendor/biz3/constants/messageConstants.d.ts +28 -0
  165. package/vendor/biz3/constants/messageConstants.d.ts.map +1 -0
  166. package/vendor/biz3/constants/messageConstants.js +30 -0
  167. package/vendor/biz3/constants/sesameDeviceModel.d.ts +75 -0
  168. package/vendor/biz3/constants/sesameDeviceModel.d.ts.map +1 -0
  169. package/vendor/biz3/constants/sesameDeviceModel.js +77 -0
  170. package/vendor/biz3/package.json +5 -0
package/src/client.js ADDED
@@ -0,0 +1,957 @@
1
+ // SesameHub3 高レベルクライアント。
2
+ // 低レベル WS (Hub3WsClient) とトークン管理 / 設定解決を内部で抱える。
3
+ //
4
+ // 使い方:
5
+ // const hub = await SesameHub3.fromConfig(); // ~/.config/sesame-hub3 から
6
+ // await hub.connect();
7
+ // await hub.send("ac", "停止"); // (remote名, キー名 or keyUUID)
8
+ // await hub.close();
9
+ //
10
+ // ライブラリ消費者で独自のトークン保管をしたい場合は、下記 TokenStore 実装を渡す。
11
+ //
12
+ // API の 2 系統:
13
+ // - name-based : config の hub3s/remotes/locks に登録した名前で操作 (`unlock("front")`)
14
+ // - direct : config を介さず deviceUUID + secretKey を直接渡す (`unlockDevice({...})`)
15
+ // → `*Device` / `*Direct` サフィックスが付くものが direct 系。
16
+
17
+ /**
18
+ * トークン永続化インターフェース。FileTokenStore がデフォルト実装。
19
+ * 独自実装 (keychain / DB / メモリ) を渡す場合は下記 6 メソッドすべて必須。
20
+ *
21
+ * @typedef {Object} TokenStore
22
+ * @property {() => (object|null)} load 保存済みトークン {idToken, refreshToken, clientId, accessToken?, deviceKey?} を返す。無ければ null
23
+ * @property {(tokens: object) => void} save トークンを永続化 (refresh 時に呼ばれる)
24
+ * @property {() => void} clear トークンを破棄
25
+ * @property {() => (object|null)} loadPending sign-in 進行中の一時状態を返す。無ければ null
26
+ * @property {(state: object) => void} savePending sign-in 進行中の一時状態を保存
27
+ * @property {() => void} clearPending sign-in 一時状態を破棄
28
+ */
29
+
30
+ import { Hub3WsClient, sendIR, getIRCodes } from "./transport.js";
31
+ import { ConfigStore } from "./config.js";
32
+ import { FileTokenStore } from "./tokens.js";
33
+ import { getValidIdToken, jwtSub } from "./auth.js";
34
+ import { configPaths } from "./paths.js";
35
+ import { lockLock, lockUnlock, lockToggle, botClick, triggerLock, setAutolock } from "./lock.js";
36
+ import { CMD, cmacTime, uuidToHistoryBase64 } from "./crypto.js";
37
+ import { ACTION_TYPES } from "../vendor/biz3/constants/messageConstants.js";
38
+ import * as ir from "./ir.js";
39
+ import * as devices from "./devices.js";
40
+ import * as account from "./account.js";
41
+ import * as schedule from "./schedule.js";
42
+ import * as org from "./org.js";
43
+ import * as company from "./company.js";
44
+ import * as access from "./access.js";
45
+ import * as iot from "./iot.js";
46
+ import * as presetir from "./presetir.js";
47
+
48
+ const DEFAULT_CONFIG = {
49
+ companyID: "ch_CandyhouseMobile",
50
+ wsUrl: "wss://82q6nuplv0.execute-api.ap-northeast-1.amazonaws.com/public", // 公式ステージ (旧 /production は web 由来の誤値)
51
+ lang: "ja",
52
+ default: { remote: null, lock: null },
53
+ hub3s: {},
54
+ remotes: {},
55
+ locks: {},
56
+ };
57
+
58
+ const STATE_CHANGE_KEY = `${ACTION_TYPES.BIZ3_TRIGGER_LOCKER}:pubDeviceStateChange`;
59
+
60
+ function normalizeUuid(s) {
61
+ return typeof s === "string" ? s.replace(/-/g, "").toLowerCase() : "";
62
+ }
63
+
64
+ const UUID_RE = /^[0-9a-fA-F-]{32,}$/;
65
+
66
+ export class SesameHub3 {
67
+ /**
68
+ * 既定の設定ディレクトリ (~/.config/sesame-hub3 等) から読み込んで構築。
69
+ * CLI 内部はこのファクトリを使う。
70
+ * @param {{ configDir?: string, debug?: boolean }} [opts]
71
+ */
72
+ static async fromConfig(opts = {}) {
73
+ const paths = configPaths(opts.configDir);
74
+ const configStore = new ConfigStore(paths.config);
75
+ const tokenStore = FileTokenStore.fromConfigDir(opts.configDir);
76
+ return new SesameHub3({
77
+ config: configStore.load(),
78
+ configStore,
79
+ tokenStore,
80
+ debug: !!opts.debug,
81
+ });
82
+ }
83
+
84
+ /**
85
+ * 自動 connect/close ヘルパ。boilerplate 削減用。
86
+ *
87
+ * await SesameHub3.use(async (hub) => { await hub.unlock("front"); });
88
+ * await SesameHub3.use({ configDir: "/tmp/cfg" }, async (hub) => { ... });
89
+ * await SesameHub3.use({ tokenStore: myStore, config: {...} }, async (hub) => { ... });
90
+ *
91
+ * config / tokenStore を opts で渡せば fromConfig をスキップ (他プロジェクト埋込み用)。
92
+ *
93
+ * @param {((hub:SesameHub3) => Promise<any>) | object} fnOrOpts
94
+ * @param {(hub:SesameHub3) => Promise<any>} [maybeFn]
95
+ */
96
+ static async use(fnOrOpts, maybeFn) {
97
+ let opts = {}, fn;
98
+ if (typeof fnOrOpts === "function") { fn = fnOrOpts; }
99
+ else { opts = fnOrOpts || {}; fn = maybeFn; }
100
+ if (typeof fn !== "function") {
101
+ throw new Error("usage: SesameHub3.use([opts], async (hub) => {...})");
102
+ }
103
+
104
+ const hub = (opts.tokenStore && opts.config)
105
+ ? new SesameHub3({
106
+ config: { ...DEFAULT_CONFIG, ...opts.config },
107
+ tokenStore: opts.tokenStore,
108
+ configStore: opts.configStore || null,
109
+ debug: !!opts.debug,
110
+ })
111
+ : await SesameHub3.fromConfig(opts);
112
+
113
+ await hub.connect();
114
+ try { return await fn(hub); }
115
+ finally { await hub.close(); }
116
+ }
117
+
118
+ /**
119
+ * @param {{
120
+ * config: object,
121
+ * tokenStore: TokenStore,
122
+ * configStore?: ConfigStore,
123
+ * debug?: boolean,
124
+ * }} args
125
+ */
126
+ constructor({ config, tokenStore, configStore = null, debug = false }) {
127
+ if (!config) throw new Error("config required");
128
+ if (!tokenStore) throw new Error("tokenStore required");
129
+ this._config = config;
130
+ this._configStore = configStore;
131
+ this._tokenStore = tokenStore;
132
+ this._debug = debug;
133
+ /** @type {Hub3WsClient | null} */
134
+ this._ws = null;
135
+ this._subUUID = null; // connect() で idToken から抽出
136
+ /**
137
+ * close() 時に await したい async cleanup 関数の集合 (2nd-pass M-1)。
138
+ * `onIRLearned` 等の戻り値 unsubscribe は呼び出し側の await 忘れで Hub3 が
139
+ * REGISTER モードに残るリスクがあるため、ここに登録しておくと close() で確実に走る。
140
+ */
141
+ this._pendingCleanups = new Set();
142
+ /** WS 再接続 (初回以外の OPEN) で呼ぶコールバック集合。購読者の再 subscribe 用。 */
143
+ this._reconnectCbs = new Set();
144
+ }
145
+
146
+ /**
147
+ * WS 再接続時に呼ばれるコールバックを登録する。戻り値で解除。
148
+ * デーモン等、再接続後にサーバ購読 (subscribe frame) を張り直したい用途向け。
149
+ * @param {() => void} cb
150
+ * @returns {() => void} unsubscribe
151
+ */
152
+ onReconnect(cb) {
153
+ this._reconnectCbs.add(cb);
154
+ return () => this._reconnectCbs.delete(cb);
155
+ }
156
+
157
+ /** 登録済み再接続コールバックを発火する (transport の onReopen から呼ばれる)。 */
158
+ _fireReconnect() {
159
+ for (const cb of [...this._reconnectCbs]) {
160
+ try { cb(); } catch (e) { if (this._debug) console.error("[hub3] onReconnect cb error:", e?.message || e); }
161
+ }
162
+ }
163
+
164
+ get config() { return this._config; }
165
+ get configStore() { return this._configStore; }
166
+ get tokenStore() { return this._tokenStore; }
167
+ get connected() { return !!this._ws; }
168
+
169
+ /**
170
+ * remote 名 (省略時は default) から remote 定義と親 hub3 をまとめて取得。
171
+ */
172
+ resolveRemote(name) {
173
+ if (this._configStore) return this._configStore.resolveRemote(name);
174
+ // configStore なしで直接構築された場合: 手動で解決
175
+ const cfg = this._config;
176
+ const remotes = cfg.remotes || {};
177
+ const names = Object.keys(remotes);
178
+ const chosen = name || cfg.default?.remote || (names.length === 1 ? names[0] : null);
179
+ if (!chosen) throw new Error("No remote specified and no default");
180
+ const remote = remotes[chosen];
181
+ if (!remote) throw new Error(`Unknown remote "${chosen}"`);
182
+ const hub3 = cfg.hub3s?.[remote.hub3];
183
+ if (!hub3) throw new Error(`Remote "${chosen}" references missing hub3 "${remote.hub3}"`);
184
+ return { name: chosen, remote, hub3Name: remote.hub3, hub3 };
185
+ }
186
+
187
+ /** WS 接続を確立。既に接続済みなら何もしない。 */
188
+ async connect() {
189
+ if (this._ws) return;
190
+ const idToken = await getValidIdToken(this._tokenStore);
191
+ this._subUUID = jwtSub(idToken);
192
+ this._ws = new Hub3WsClient({
193
+ wsUrl: this._config.wsUrl,
194
+ idToken,
195
+ lang: this._config.lang,
196
+ debug: this._debug,
197
+ // 再接続が MAX_RETRIES_BEFORE_TOKEN_CHECK に達した時、
198
+ // token を強制 refresh して再接続を継続する。
199
+ onTokenRefreshNeeded: async () => {
200
+ try {
201
+ // marginSec を大きくして必ず refresh する
202
+ return await getValidIdToken(this._tokenStore, { marginSec: 999999 });
203
+ } catch (e) {
204
+ if (this._debug) console.error("[hub3] token refresh failed:", e?.message || e);
205
+ return null;
206
+ }
207
+ },
208
+ // 再接続後に登録済みコールバックを発火 (購読者の subscribe frame 再送など)。
209
+ onReopen: () => this._fireReconnect(),
210
+ });
211
+ await this._ws.connect();
212
+ }
213
+
214
+ async close() {
215
+ // pending cleanups を先に走らせる (onIRLearned 等の await 忘れ救済)
216
+ if (this._pendingCleanups.size > 0) {
217
+ const fns = [...this._pendingCleanups];
218
+ this._pendingCleanups.clear();
219
+ await Promise.allSettled(fns.map((fn) => Promise.resolve().then(fn)));
220
+ }
221
+ if (!this._ws) return;
222
+ this._ws.close();
223
+ this._ws = null;
224
+ }
225
+
226
+ _ensureConnected() {
227
+ if (!this._ws) throw new Error("not connected (call connect() first)");
228
+ }
229
+
230
+ // ---------- ドメイン namespace ----------
231
+ // 各機能モジュール (schedule/org/company/access/iot/presetir) は
232
+ // `fn(client, params)` の純関数集。それを namespace getter で薄く委譲する
233
+ // (client.js を God object 化させない設計)。companyID / subUUID は
234
+ // this._config / connect 時の値を自動注入し、params で上書きできる。
235
+ //
236
+ // await hub.schedule.getScheduleList(); // companyID/subUUID 自動
237
+ // await hub.org.getEmployees(); // companyID 自動
238
+ // await hub.access.getCards({ deviceUUIDs: [...] });
239
+ //
240
+ // 低レベル関数を直接使いたい場合は `import { org } from "sesame-kit"` で
241
+ // モジュールごと取り、第1引数に WS client を渡す。
242
+
243
+ _bindNs(mod) {
244
+ this._ensureConnected();
245
+ const ws = this._ws;
246
+ const companyID = this._config.companyID;
247
+ const subUUID = this._subUUID;
248
+ // モジュールが NAMESPACE_OPS (公開 op の allowlist) を持つ場合はそれだけを露出。
249
+ // presetir/iot のように client を取らない純ロジック (class/builder) が export に
250
+ // 混じるモジュールで、それらを誤ってラップして壊すのを防ぐ。
251
+ const names = Array.isArray(mod.NAMESPACE_OPS)
252
+ ? mod.NAMESPACE_OPS
253
+ : Object.keys(mod).filter((k) => typeof mod[k] === "function");
254
+ const out = {};
255
+ for (const name of names) {
256
+ const fn = mod[name];
257
+ if (typeof fn !== "function") continue;
258
+ // companyID/subUUID を既定注入。params で明示すればそちら優先。
259
+ out[name] = (params = {}) => fn(ws, { companyID, subUUID, ...params });
260
+ }
261
+ return out;
262
+ }
263
+
264
+ /** スケジュール (biz3Schedule)。 */
265
+ get schedule() { return this._bindNs(schedule); }
266
+ /** 組織管理 (employee/group/role/device-group/employee-device)。 */
267
+ get org() { return this._bindNs(org); }
268
+ /** 会社 (biz3ManageCompany)。 */
269
+ get company() { return this._bindNs(company); }
270
+ /** 認証データ (NFC カード/パスコードの WS op)。 */
271
+ get access() { return this._bindNs(access); }
272
+ /** IoT cmd (biz3OperateIoT: DFU/LED/リレー/Sesame item)。 */
273
+ get iot() { return this._bindNs(iot); }
274
+ /** プリセットリモコン command 生成 (remoteEmit, HXD)。 */
275
+ get presetir() { return this._bindNs(presetir); }
276
+
277
+ /**
278
+ * IR 発射 (name-based)。`keyOrUUID` が UUID 形式ならそのまま command として、
279
+ * そうでなければ remote.keys から名前解決する。
280
+ * config を介さない版は {@link SesameHub3#sendIRDirect}。
281
+ *
282
+ * @param {string|null} remoteName リモコン名 (null で default.remote)
283
+ * @param {string} keyOrUUID キー名 or keyUUID
284
+ * @returns {Promise<object>} sendIR の応答 (success / data.message 等)
285
+ */
286
+ async send(remoteName, keyOrUUID) {
287
+ this._ensureConnected();
288
+ if (!keyOrUUID) throw new Error("key (name or UUID) required");
289
+ const { remote, hub3 } = this.resolveRemote(remoteName);
290
+ const isUUID = UUID_RE.test(keyOrUUID);
291
+ const command = isUUID ? keyOrUUID : remote.keys?.[keyOrUUID];
292
+ if (!command) {
293
+ const avail = Object.keys(remote.keys || {}).join(", ") || "(none)";
294
+ throw new Error(`Unknown key "${keyOrUUID}". 利用可能: ${avail}`);
295
+ }
296
+ return sendIR(this._ws, {
297
+ deviceId: hub3.deviceId,
298
+ irDeviceUUID: remote.irDeviceUUID,
299
+ irType: remote.irType,
300
+ command,
301
+ operation: remote.irOperation,
302
+ companyID: this._config.companyID,
303
+ });
304
+ }
305
+
306
+ /**
307
+ * リモコンに登録されている IR キー一覧をサーバから取得 (name-based)。
308
+ * config を介さない版は {@link SesameHub3#getIRCodesDirect}。
309
+ *
310
+ * @param {string|null} remoteName リモコン名 (null で default.remote)
311
+ * @returns {Promise<Array<{name:string, keyUUID:string}>>}
312
+ */
313
+ async listKeys(remoteName) {
314
+ this._ensureConnected();
315
+ const { remote, hub3 } = this.resolveRemote(remoteName);
316
+ return getIRCodes(this._ws, {
317
+ deviceId: hub3.deviceId,
318
+ irDeviceUUID: remote.irDeviceUUID,
319
+ companyID: this._config.companyID,
320
+ });
321
+ }
322
+
323
+ /**
324
+ * 接続疎通確認: biz3KeepAlive を 1 往復して ack を待つ。
325
+ * 失敗時は throw。
326
+ */
327
+ async ping() {
328
+ this._ensureConnected();
329
+ return this._ws.ping();
330
+ }
331
+
332
+ // ---------- アカウント (ログインユーザ情報) ----------
333
+
334
+ /**
335
+ * ログインユーザの customerInfo / quotas を取得 (biz3GetLoginUser)。
336
+ * email は tokenStore の username (login に使った値) を使う。
337
+ * @returns {Promise<{customerInfo: object|null, quotas: object|null}>}
338
+ */
339
+ async getLoginUser() {
340
+ this._ensureConnected();
341
+ const email = this._tokenStore.load()?.username;
342
+ if (!email) throw new Error("email (username) が token store にありません。再 login が必要かもしれません。");
343
+ return account.getLoginUser(this._ws, { email });
344
+ }
345
+
346
+ /**
347
+ * biz3GetLoginUser で実 companyID / subUUID を取得し、config と内部状態に反映する。
348
+ * companyID は従来デフォルト (ch_CandyhouseMobile) を置いていたが、これで実値に上書きできる。
349
+ * @returns {Promise<object|null>} customerInfo
350
+ */
351
+ async refreshAccount() {
352
+ const { customerInfo } = await this.getLoginUser();
353
+ if (customerInfo?.companyID) {
354
+ this._config.companyID = customerInfo.companyID;
355
+ if (this._configStore) {
356
+ const cfg = this._configStore.load();
357
+ cfg.companyID = customerInfo.companyID;
358
+ this._configStore.save();
359
+ }
360
+ }
361
+ if (customerInfo?.subUUID) {
362
+ this._subUUID = customerInfo.subUUID; // jwtSub と同値のはずだが、正式値で上書き
363
+ }
364
+ return customerInfo ?? null;
365
+ }
366
+
367
+ /**
368
+ * 全 SESAME デバイス (Hub3 含む) のリストを取得。
369
+ * biz3ManageDevice/getCompanyDevice → PubedCompanyDevice の応答を待つ。
370
+ */
371
+ async listDevices({ timeoutMs = 10_000 } = {}) {
372
+ this._ensureConnected();
373
+ let resolveGot;
374
+ const got = new Promise((resolve) => { resolveGot = resolve; });
375
+ const listener = (msg) => {
376
+ if (msg.action === ACTION_TYPES.BIZ3_MANAGE_DEVICE && msg.op === "PubedCompanyDevice") {
377
+ resolveGot(msg);
378
+ }
379
+ };
380
+ // 3rd-pass L-3: onMessage の戻り unsubscribe を必ず呼んで listener leak を防ぐ
381
+ // (daemon 用途で listDevices を繰り返し呼ぶと累積していた)
382
+ const off = this._ws.onMessage(listener);
383
+ let timeoutId;
384
+ try {
385
+ this._ws.send({
386
+ action: ACTION_TYPES.BIZ3_MANAGE_DEVICE,
387
+ op: "getCompanyDevice",
388
+ companyID: this._config.companyID,
389
+ });
390
+ const timeout = new Promise((_, rej) => {
391
+ timeoutId = setTimeout(() => rej(new Error("getCompanyDevice timeout")), timeoutMs);
392
+ });
393
+ const msg = await Promise.race([got, timeout]);
394
+ return msg?.data?.data?.list || [];
395
+ } finally {
396
+ if (timeoutId) clearTimeout(timeoutId);
397
+ off();
398
+ }
399
+ }
400
+
401
+ // ---------- devices → config 同期 (ドメイン操作) ----------
402
+ // CLI 専用だった自動化を library 利用者も使えるよう SesameHub3 に集約。
403
+ // いずれも内部で listDevices / listIRRemotes を引いて ConfigStore に委譲する。
404
+ // configStore 無しで構築された場合は使えない (throw)。
405
+
406
+ _requireConfigStore(op) {
407
+ if (!this._configStore) throw new Error(`${op} requires a ConfigStore (fromConfig で構築してください)`);
408
+ }
409
+
410
+ /**
411
+ * 全 SESAME デバイスを引いてロックを config に取り込む。
412
+ * @param {{prune?:boolean}} [opts]
413
+ * @returns {Promise<{added:string[], updated:string[], removed:string[]}>}
414
+ */
415
+ async syncLocksFromDevices(opts = {}) {
416
+ this._ensureConnected();
417
+ this._requireConfigStore("syncLocksFromDevices");
418
+ const list = await this.listDevices();
419
+ return this._configStore.syncLocksFromDevices(list, opts);
420
+ }
421
+
422
+ /**
423
+ * 全 SESAME デバイスを引いて Hub3 を config に取り込む。
424
+ * @param {{prune?:boolean}} [opts]
425
+ * @returns {Promise<{added:string[], updated:string[], removed:string[]}>}
426
+ */
427
+ async syncHub3sFromDevices(opts = {}) {
428
+ this._ensureConnected();
429
+ this._requireConfigStore("syncHub3sFromDevices");
430
+ const list = await this.listDevices();
431
+ return this._configStore.syncHub3sFromDevices(list, opts);
432
+ }
433
+
434
+ /**
435
+ * `devices` 応答だけからリモコンを config に取り込む (引数不要)。
436
+ * 内部で Hub3 を自動登録してから、各 Hub3 の stateInfo.remoteList を展開する。
437
+ * irType はリモコン側が持っているのでユーザー指定不要。
438
+ * @returns {Promise<{hub3:{added,updated,removed}, remotes:{added,updated}}>}
439
+ */
440
+ async syncRemotesFromDevices() {
441
+ this._ensureConnected();
442
+ this._requireConfigStore("syncRemotesFromDevices");
443
+ const list = await this.listDevices();
444
+ const hub3 = this._configStore.syncHub3sFromDevices(list);
445
+ const remotes = this._configStore.syncRemotesFromDevices(list);
446
+ return { hub3, remotes };
447
+ }
448
+
449
+ /**
450
+ * devices から「Hub3 とその配下リモコン」をフラットに取得 (登録せず一覧だけ)。
451
+ * 対話 add で候補を見せる用途。
452
+ * @returns {Promise<Array<{hub3DeviceUUID:string, hub3Name:string, uuid:string, type:number, alias:string|null}>>}
453
+ */
454
+ async listRemotesFromDevices() {
455
+ this._ensureConnected();
456
+ const list = await this.listDevices();
457
+ const out = [];
458
+ for (const d of list) {
459
+ if (d.deviceModel !== "hub_3" && d.deviceModel !== "hub_3_lte") continue;
460
+ for (const r of d.stateInfo?.remoteList || []) {
461
+ const uuid = r.uuid || r.irDeviceUUID;
462
+ if (!uuid) continue;
463
+ out.push({
464
+ hub3DeviceUUID: d.deviceUUID,
465
+ hub3Name: d.deviceName || d.deviceUUID,
466
+ uuid,
467
+ type: Number(r.type ?? r.irType),
468
+ alias: r.alias || r.name || null,
469
+ });
470
+ }
471
+ }
472
+ return out;
473
+ }
474
+
475
+ /**
476
+ * server 側 (getRemoteList) のリモコンを config に取り込む (上級/代替経路)。
477
+ * 通常は syncRemotesFromDevices で足りる。
478
+ * @param {string} hub3Name これらのリモコンが属する Hub3 の config 名
479
+ * @param {number} irType 取得するリモコンの irType (例 49152=エアコン)
480
+ * @returns {Promise<{added:string[], updated:string[]}>}
481
+ */
482
+ async syncRemotesFromServer(hub3Name, irType) {
483
+ this._ensureConnected();
484
+ this._requireConfigStore("syncRemotesFromServer");
485
+ const list = await this.listIRRemotes(irType);
486
+ return this._configStore.syncRemotesFromServer(list, hub3Name);
487
+ }
488
+
489
+ /**
490
+ * 指定 remote のキー一覧を server から取得して config に書き戻す。
491
+ * @param {string|null} remoteName
492
+ * @returns {Promise<{name:string, keyCount:number}>}
493
+ */
494
+ async syncRemoteKeys(remoteName) {
495
+ this._ensureConnected();
496
+ this._requireConfigStore("syncRemoteKeys");
497
+ const { name } = this.resolveRemote(remoteName);
498
+ const codes = await this.listKeys(name);
499
+ const keys = {};
500
+ for (const c of codes) keys[c.name] = c.keyUUID;
501
+ this._configStore.updateRemoteKeys(name, keys);
502
+ return { name, keyCount: Object.keys(keys).length };
503
+ }
504
+
505
+ // ---------- lock ----------
506
+
507
+ /**
508
+ * lock 設定を name から解決。name 省略時は default.lock、
509
+ * 無ければ locks が 1 つだけならそれ。
510
+ */
511
+ resolveLock(name) {
512
+ const cfg = this._config;
513
+ const locks = cfg.locks || {};
514
+ const names = Object.keys(locks);
515
+ const chosen = name || cfg.default?.lock || (names.length === 1 ? names[0] : null);
516
+ if (!chosen) {
517
+ throw new Error(`No lock specified and no default. 設定済み: [${names.join(", ") || "(none)"}]`);
518
+ }
519
+ const lock = locks[chosen];
520
+ if (!lock) throw new Error(`Unknown lock "${chosen}". 設定済み: [${names.join(", ") || "(none)"}]`);
521
+ return { name: chosen, lock };
522
+ }
523
+
524
+ _lockParams(name) {
525
+ if (!this._subUUID) throw new Error("subUUID not available (connect() を先に呼んでください)");
526
+ const { lock } = this.resolveLock(name);
527
+ if (!lock.deviceUUID) throw new Error(`lock "${name || "(default)"}" missing deviceUUID`);
528
+ if (!lock.secretKey) throw new Error(`lock "${name || "(default)"}" missing secretKey`);
529
+ return { deviceId: lock.deviceUUID, secretKey: lock.secretKey, subUUID: this._subUUID };
530
+ }
531
+
532
+ /**
533
+ * ロック施錠 (name-based, cmd=82)。config を介さない版は {@link SesameHub3#lockDevice}。
534
+ * @param {string|null} [name] ロック名 (null で default.lock)
535
+ * @returns {Promise<object>} pubDeviceStateChange の応答
536
+ */
537
+ async lock(name) {
538
+ this._ensureConnected();
539
+ return lockLock(this._ws, this._lockParams(name));
540
+ }
541
+
542
+ /**
543
+ * ロック解錠 (name-based, cmd=83)。config を介さない版は {@link SesameHub3#unlockDevice}。
544
+ * @param {string|null} [name] ロック名 (null で default.lock)
545
+ * @returns {Promise<object>} pubDeviceStateChange の応答
546
+ */
547
+ async unlock(name) {
548
+ this._ensureConnected();
549
+ return lockUnlock(this._ws, this._lockParams(name));
550
+ }
551
+
552
+ /**
553
+ * トグル (name-based, cmd=88, cloud のみの合成命令)。
554
+ * @param {string|null} [name] ロック名 (null で default.lock)
555
+ * @returns {Promise<object>}
556
+ */
557
+ async toggle(name) {
558
+ this._ensureConnected();
559
+ return lockToggle(this._ws, this._lockParams(name));
560
+ }
561
+
562
+ /**
563
+ * SESAME Bot クリック (name-based, cmd=89)。
564
+ * 注: lock.js の低レベル関数 `botClick(client, params)` とは別物 (こちらは name で解決)。
565
+ * @param {string|null} [name] ロック名 (null で default.lock)
566
+ * @returns {Promise<object>}
567
+ */
568
+ async botClick(name) {
569
+ this._ensureConnected();
570
+ return botClick(this._ws, this._lockParams(name));
571
+ }
572
+
573
+ /**
574
+ * デバッグ用: WS の全受信メッセージを購読する (戻り値で unsubscribe)。
575
+ * fire-and-forget な op (autolock 等) のサーバ応答を観測するのに使う。
576
+ * @param {(msg:object)=>void} fn
577
+ * @returns {()=>void} unsubscribe
578
+ */
579
+ onAnyMessage(fn) {
580
+ this._ensureConnected();
581
+ return this._ws.onMessage(fn);
582
+ }
583
+
584
+ /** 任意 cmd 直指定 (上級用)。 */
585
+ async triggerLockRaw(name, cmd) {
586
+ this._ensureConnected();
587
+ return triggerLock(this._ws, { ...this._lockParams(name), cmd });
588
+ }
589
+
590
+ /**
591
+ * オートロック設定 (name-based)。解錠 N 秒後に自動施錠。`seconds=0` で無効。
592
+ *
593
+ * ⚠️ 実験的 / 実機未検証: クラウド中継 (Hub3) が autolock(ItemCode 11) を通すかは前例が無い。
594
+ * 公式アプリは BLE で送っている。fire-and-forget (応答待ちしない)。
595
+ *
596
+ * @param {string|null} name ロック名 (null で default.lock)
597
+ * @param {number} seconds 0..65535 (0=無効)
598
+ * @param {number} [timeoutMs] ack 待ちタイムアウト
599
+ * @returns {Promise<{ack:any, cmd:number, seconds:number}>}
600
+ */
601
+ async setAutolock(name, seconds, timeoutMs) {
602
+ this._ensureConnected();
603
+ const { deviceId, secretKey } = this._lockParams(name); // subUUID は autolock では未使用
604
+ return setAutolock(this._ws, { deviceId, secretKey, seconds, timeoutMs });
605
+ }
606
+
607
+ get subUUID() { return this._subUUID; }
608
+
609
+ // ---------- IR advanced (Phase C) ----------
610
+
611
+ /** Hub3 を学習モードに入れ、物理リモコンの 1 ボタンを学習して remote にキー登録。 */
612
+ async learnIR(remoteName, keyName, { timeoutMs = 60_000, onPrompt } = {}) {
613
+ this._ensureConnected();
614
+ const { remote, hub3, name: rName } = this.resolveRemote(remoteName);
615
+ const result = await ir.learnIRKey(this._ws, {
616
+ hub3DeviceId: hub3.deviceId,
617
+ remoteId: remote.irDeviceUUID,
618
+ keyName,
619
+ irType: remote.irType,
620
+ companyID: this._config.companyID,
621
+ timeoutMs,
622
+ onPrompt,
623
+ });
624
+ // keyUUID はクライアント発番 (learnIRKey が返す)。config にキー名→keyUUID を反映。
625
+ const keyUUID = result.keyUUID;
626
+ if (keyUUID && this._configStore) {
627
+ const cur = remote.keys || {};
628
+ cur[keyName] = keyUUID;
629
+ this._configStore.updateRemoteKeys(rName, cur);
630
+ }
631
+ return result;
632
+ }
633
+
634
+ async listIRRemotes(type, { page, pageSize } = {}) {
635
+ this._ensureConnected();
636
+ return ir.getRemoteList(this._ws, { type, companyID: this._config.companyID, page, pageSize });
637
+ }
638
+
639
+ async searchPresetIRRemotes(type, searchTerm) {
640
+ this._ensureConnected();
641
+ return ir.searchRemoteList(this._ws, { type, companyID: this._config.companyID, searchTerm });
642
+ }
643
+
644
+ async addIRRemoteServer(remoteObj) {
645
+ this._ensureConnected();
646
+ return ir.addIRRemote(this._ws, { remote: remoteObj, companyID: this._config.companyID });
647
+ }
648
+
649
+ async deleteIRRemoteServer(remoteName) {
650
+ this._ensureConnected();
651
+ const { remote, hub3 } = this.resolveRemote(remoteName);
652
+ return ir.deleteIRRemote(this._ws, {
653
+ hub3DeviceId: hub3.deviceId,
654
+ uuid: remote.irDeviceUUID,
655
+ companyID: this._config.companyID,
656
+ });
657
+ }
658
+
659
+ async renameIRRemote(remoteName, alias) {
660
+ this._ensureConnected();
661
+ const { remote, hub3 } = this.resolveRemote(remoteName);
662
+ return ir.updateRemoteAlias(this._ws, {
663
+ hub3DeviceId: hub3.deviceId,
664
+ uuid: remote.irDeviceUUID,
665
+ alias,
666
+ companyID: this._config.companyID,
667
+ });
668
+ }
669
+
670
+ async deleteIRKey(remoteName, keyOrUUID) {
671
+ this._ensureConnected();
672
+ const { remote, hub3, name } = this.resolveRemote(remoteName);
673
+ const keyUUID = remote.keys?.[keyOrUUID] || keyOrUUID;
674
+ const resp = await ir.deleteIRCode(this._ws, {
675
+ hub3DeviceId: hub3.deviceId,
676
+ remoteId: remote.irDeviceUUID,
677
+ keyUUID,
678
+ companyID: this._config.companyID,
679
+ });
680
+ // config 側からも除去
681
+ if (this._configStore && remote.keys?.[keyOrUUID]) {
682
+ const { [keyOrUUID]: _, ...rest } = remote.keys;
683
+ this._configStore.updateRemoteKeys(name, rest);
684
+ }
685
+ return resp;
686
+ }
687
+
688
+ async renameIRKey(remoteName, keyOrUUID, newName) {
689
+ this._ensureConnected();
690
+ const { remote, hub3, name } = this.resolveRemote(remoteName);
691
+ const keyUUID = remote.keys?.[keyOrUUID] || keyOrUUID;
692
+ const resp = await ir.updateIRCode(this._ws, {
693
+ hub3DeviceId: hub3.deviceId,
694
+ remoteId: remote.irDeviceUUID,
695
+ keyUUID,
696
+ name: newName,
697
+ companyID: this._config.companyID,
698
+ });
699
+ if (this._configStore && remote.keys?.[keyOrUUID]) {
700
+ const next = { ...remote.keys };
701
+ delete next[keyOrUUID];
702
+ next[newName] = keyUUID;
703
+ this._configStore.updateRemoteKeys(name, next);
704
+ }
705
+ return resp;
706
+ }
707
+
708
+ async getIRMode(hub3Name) {
709
+ this._ensureConnected();
710
+ const hub3 = this._resolveHub3(hub3Name);
711
+ return ir.getIRMode(this._ws, { deviceId: hub3.deviceId, companyID: this._config.companyID });
712
+ }
713
+
714
+ async setIRMode(hub3Name, mode) {
715
+ this._ensureConnected();
716
+ const hub3 = this._resolveHub3(hub3Name);
717
+ return ir.setIRMode(this._ws, { deviceId: hub3.deviceId, mode, companyID: this._config.companyID });
718
+ }
719
+
720
+ async matchIRRemote({ irData, irType, brandName }) {
721
+ this._ensureConnected();
722
+ return ir.matchRemote(this._ws, { irData, irType, brandName, companyID: this._config.companyID });
723
+ }
724
+
725
+ _resolveHub3(name) {
726
+ const cfg = this._config;
727
+ const hub3s = cfg.hub3s || {};
728
+ const names = Object.keys(hub3s);
729
+ const chosen = name || (names.length === 1 ? names[0] : null);
730
+ if (!chosen) throw new Error("No hub3 specified and not exactly one configured");
731
+ const h = hub3s[chosen];
732
+ if (!h) throw new Error(`Unknown hub3 "${chosen}"`);
733
+ return h;
734
+ }
735
+
736
+ // ---------- Device management (Phase D) ----------
737
+
738
+ /** 個人ユーザのデバイス一覧 (会社 vs 個人で別 op)。 */
739
+ async listUserDevices() {
740
+ this._ensureConnected();
741
+ return devices.getUserDevices(this._ws);
742
+ }
743
+
744
+ async getDeviceStatus(deviceUUID) {
745
+ this._ensureConnected();
746
+ return devices.getDeviceStatus(this._ws, { deviceUUID });
747
+ }
748
+
749
+ async renameDevice(deviceUUID, deviceName) {
750
+ this._ensureConnected();
751
+ if (!this._subUUID) throw new Error("subUUID not available (connect first)");
752
+ return devices.updateDeviceName(this._ws, { subUUID: this._subUUID, deviceUUID, deviceName });
753
+ }
754
+
755
+ /** company から指定 UUID のデバイスを削除。 */
756
+ async deleteDevice(deviceUUID) {
757
+ this._ensureConnected();
758
+ return devices.deleteDevices(this._ws, {
759
+ companyID: this._config.companyID,
760
+ items: [{ deviceUUID }],
761
+ });
762
+ }
763
+
764
+ /**
765
+ * @deprecated `onDeviceUpdate(items, fn)` を使ってください (on* イベント命名に統一)。
766
+ * 後方互換のため残置。内部実装は onDeviceUpdate と同一。
767
+ */
768
+ subscribeDeviceUpdates(deviceInfos, onUpdate) {
769
+ return this.onDeviceUpdate(deviceInfos, onUpdate);
770
+ }
771
+
772
+ /** ロック開閉履歴を取得。`list` はデバイス指定の配列。 */
773
+ async getDeviceHistory(list, pageSize) {
774
+ this._ensureConnected();
775
+ return devices.getDeviceHistory(this._ws, {
776
+ companyID: this._config.companyID,
777
+ list,
778
+ pageSize,
779
+ });
780
+ }
781
+
782
+ /** 電池履歴を取得 (1ページ)。lastEvaluatedKey でページング。 */
783
+ async getDeviceBattery(deviceUUID, { lastEvaluatedKey = null, pageSize = 100 } = {}) {
784
+ this._ensureConnected();
785
+ return devices.getBatteryRecord(this._ws, { deviceUUID, lastEvaluatedKey, pageSize });
786
+ }
787
+
788
+ async listFirmware() {
789
+ this._ensureConnected();
790
+ return devices.listFirmware(this._ws);
791
+ }
792
+
793
+ /** WebAPI proxy 経由で REST API を叩く。apiKeyId は config 側に保存。 */
794
+ async invokeWebAPI({ func, query, body, apiKeyId }) {
795
+ this._ensureConnected();
796
+ const key = apiKeyId || this._config.apiKeyId;
797
+ if (!key) throw new Error("apiKeyId required (config.apiKeyId か引数で指定)");
798
+ return devices.invokeWebAPI(this._ws, { func, apiKeyId: key, query, body });
799
+ }
800
+
801
+ // ---------- config-less direct API ----------
802
+ // 他プロジェクトに組み込むとき、name 経由の config lookup を介さず
803
+ // deviceUUID + secretKey を直接渡して操作するための関数群。
804
+
805
+ /**
806
+ * 直接 lock 制御 (config を介さない, 任意 cmd)。`unlockDevice`/`lockDevice` 等の基底。
807
+ * @param {{deviceUUID:string, secretKey:string, cmd:number, timeoutMs?:number}} p
808
+ * deviceUUID: ロックの UUID / secretKey: 32hex 共通鍵 (devices で取得) /
809
+ * cmd: 82=LOCK 83=UNLOCK 88=TOGGLE 89=CLICK
810
+ * @returns {Promise<object>} pubDeviceStateChange の応答
811
+ */
812
+ async triggerLockDevice({ deviceUUID, secretKey, cmd, timeoutMs }) {
813
+ this._ensureConnected();
814
+ if (!this._subUUID) throw new Error("subUUID not available (connect first)");
815
+ return triggerLock(this._ws, {
816
+ deviceId: deviceUUID,
817
+ secretKey,
818
+ subUUID: this._subUUID,
819
+ cmd,
820
+ timeoutMs,
821
+ });
822
+ }
823
+
824
+ /**
825
+ * 直接 解錠 (config を介さない, cmd=83)。
826
+ * @param {{deviceUUID:string, secretKey:string, timeoutMs?:number}} p
827
+ * @returns {Promise<object>} pubDeviceStateChange の応答
828
+ */
829
+ unlockDevice(p) { return this.triggerLockDevice({ ...p, cmd: CMD.UNLOCK }); }
830
+ /**
831
+ * 直接 施錠 (config を介さない, cmd=82)。
832
+ * @param {{deviceUUID:string, secretKey:string, timeoutMs?:number}} p
833
+ * @returns {Promise<object>}
834
+ */
835
+ lockDevice(p) { return this.triggerLockDevice({ ...p, cmd: CMD.LOCK }); }
836
+ /**
837
+ * 直接 トグル (config を介さない, cmd=88)。
838
+ * @param {{deviceUUID:string, secretKey:string, timeoutMs?:number}} p
839
+ * @returns {Promise<object>}
840
+ */
841
+ toggleDevice(p) { return this.triggerLockDevice({ ...p, cmd: CMD.TOGGLE }); }
842
+ /**
843
+ * 直接 Bot クリック (config を介さない, cmd=89)。
844
+ * @param {{deviceUUID:string, secretKey:string, timeoutMs?:number}} p
845
+ * @returns {Promise<object>}
846
+ */
847
+ botClickDevice(p) { return this.triggerLockDevice({ ...p, cmd: CMD.CLICK }); }
848
+
849
+ /**
850
+ * 直接 IR 発射 (config を介さない)。
851
+ * @param {{hub3DeviceId:string, irDeviceUUID:string, irType:number, command:string, operation?:string}} p
852
+ * hub3DeviceId: Hub3 UUID / irDeviceUUID: リモコン UUID / irType: 例 49152 /
853
+ * command: keyUUID か 16byte hex / operation: "learnEmit" (default) | "remoteEmit"
854
+ * @returns {Promise<object>} sendIR の応答
855
+ */
856
+ async sendIRDirect({ hub3DeviceId, irDeviceUUID, irType, command, operation = "learnEmit" }) {
857
+ this._ensureConnected();
858
+ return sendIR(this._ws, {
859
+ deviceId: hub3DeviceId,
860
+ irDeviceUUID,
861
+ irType,
862
+ command,
863
+ operation,
864
+ companyID: this._config.companyID,
865
+ });
866
+ }
867
+
868
+ /**
869
+ * 直接 IR キー一覧取得 (config を介さない)。
870
+ * @param {{hub3DeviceId:string, irDeviceUUID:string}} p
871
+ * @returns {Promise<Array<{name:string, keyUUID:string}>>}
872
+ */
873
+ async getIRCodesDirect({ hub3DeviceId, irDeviceUUID }) {
874
+ this._ensureConnected();
875
+ return getIRCodes(this._ws, {
876
+ deviceId: hub3DeviceId,
877
+ irDeviceUUID,
878
+ companyID: this._config.companyID,
879
+ });
880
+ }
881
+
882
+ // ---------- high-level event subscriptions ----------
883
+ // 低レベル `this._ws.subscribe(key, fn)` の薄い wrapper だが、
884
+ // deviceId フィルタ・複数 unsubscribe の合成・モード切替の自動化など、
885
+ // 「やりたいこと」レベルで使えるよう包んだ。
886
+
887
+ /** name で指定したロックの state change push を購読。戻り値は unsubscribe。 */
888
+ onLockStateChange(name, fn) {
889
+ this._ensureConnected();
890
+ const { lock } = this.resolveLock(name);
891
+ return this.onLockStateChangeDevice(lock.deviceUUID, fn);
892
+ }
893
+
894
+ /** UUID 直指定で state change を購読。 */
895
+ onLockStateChangeDevice(deviceUUID, fn) {
896
+ this._ensureConnected();
897
+ const target = normalizeUuid(deviceUUID);
898
+ return this._ws.subscribe(STATE_CHANGE_KEY, (msg) => {
899
+ // biz3 の pubDeviceStateChange 本体は data.deviceUUID (useManageDevice.js:147)
900
+ const incoming = normalizeUuid(
901
+ msg?.data?.deviceUUID || msg.deviceUUID || msg.deviceId || msg.device_id || msg?.data?.deviceId,
902
+ );
903
+ if (incoming !== target) return;
904
+ try { fn(msg); } catch { /* ignore */ }
905
+ });
906
+ }
907
+
908
+ /**
909
+ * IR 学習データの購読 (受け取った波形を fn に流す)。
910
+ * 内部で setIRMode(REGISTER) → subscribeIRData を発行する。
911
+ *
912
+ * **重要**: 戻り値の async unsubscribe 関数は **必ず `await` してください**。
913
+ * await 忘れで親プロセスが先に終了すると、Hub3 が REGISTER モードに残ります
914
+ * (Review M-1)。`hub.close()` を呼んでも Hub3 側のモードは元に戻りません。
915
+ *
916
+ * 戻り値: async () => Promise<void> — subscribe 解除 + setIRMode(CONTROL) 復帰
917
+ */
918
+ async onIRLearned(hub3Name, fn) {
919
+ this._ensureConnected();
920
+ const h = this._resolveHub3(hub3Name);
921
+ const companyID = this._config.companyID;
922
+ await ir.setIRMode(this._ws, { deviceId: h.deviceId, mode: ir.MODE.REGISTER, companyID });
923
+ const sub = await ir.subscribeIRData(this._ws, { deviceId: h.deviceId, companyID });
924
+ const off = sub.onData((msg) => {
925
+ // biz3: 生波形は response.data.data (learn/index.js:219)
926
+ try { fn(msg?.data?.data ?? msg?.data ?? msg); } catch { /* ignore */ }
927
+ });
928
+ let cleaned = false;
929
+ const cleanup = async () => {
930
+ if (cleaned) return;
931
+ cleaned = true;
932
+ this._pendingCleanups.delete(cleanup);
933
+ off();
934
+ sub.unsubscribe();
935
+ try {
936
+ await ir.setIRMode(this._ws, { deviceId: h.deviceId, mode: ir.MODE.CONTROL, companyID });
937
+ } catch { /* best effort */ }
938
+ };
939
+ // close() 時の自動 cleanup 用に登録 (2nd-pass M-1)
940
+ this._pendingCleanups.add(cleanup);
941
+ return cleanup;
942
+ }
943
+
944
+ /**
945
+ * デバイス state push の購読 (複数デバイスまとめて)。
946
+ * @param {{deviceUUID:string, deviceModel?:string}[]} items
947
+ * @param {(msg:any) => void} fn
948
+ */
949
+ onDeviceUpdate(items, fn) {
950
+ this._ensureConnected();
951
+ return devices.subscribeDevicesUpdate(this._ws, {
952
+ companyID: this._config.companyID,
953
+ items,
954
+ onUpdate: fn,
955
+ });
956
+ }
957
+ }