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.
- package/LICENSE +26 -0
- package/LICENSE.biz3 +21 -0
- package/README.ja.md +225 -0
- package/README.md +222 -0
- package/bin/sesame.js +8 -0
- package/clients/js/sesame-client.mjs +208 -0
- package/clients/python/pyproject.toml +5 -0
- package/clients/python/sesame_client.py +323 -0
- package/clients/python/setup.cfg +11 -0
- package/docs/architecture.ja.md +132 -0
- package/docs/architecture.md +105 -0
- package/docs/commands.ja.md +316 -0
- package/docs/commands.md +308 -0
- package/docs/library.ja.md +152 -0
- package/docs/library.md +152 -0
- package/docs/migration.ja.md +13 -0
- package/docs/migration.md +13 -0
- package/package.json +114 -0
- package/src/access.js +375 -0
- package/src/account.js +36 -0
- package/src/auth.js +248 -0
- package/src/ble/devicemodel.js +164 -0
- package/src/ble/index.js +185 -0
- package/src/ble/protocol.js +319 -0
- package/src/ble/session.js +235 -0
- package/src/ble/transport.js +279 -0
- package/src/cli/access.js +373 -0
- package/src/cli/company.js +104 -0
- package/src/cli/iot.js +400 -0
- package/src/cli/org.js +788 -0
- package/src/cli/presetir.js +188 -0
- package/src/cli/schedule.js +83 -0
- package/src/cli/serve.js +308 -0
- package/src/cli.js +1815 -0
- package/src/client.js +957 -0
- package/src/company.js +147 -0
- package/src/config.js +575 -0
- package/src/crypto.js +162 -0
- package/src/devices.js +228 -0
- package/src/index.js +55 -0
- package/src/iot.js +513 -0
- package/src/ir.js +341 -0
- package/src/itemcodes.js +29 -0
- package/src/lock.js +194 -0
- package/src/org.js +803 -0
- package/src/paths.js +30 -0
- package/src/presetir.js +525 -0
- package/src/prompts.js +74 -0
- package/src/schedule.js +108 -0
- package/src/serve/daemon.js +251 -0
- package/src/serve/framing/grpc.js +145 -0
- package/src/serve/framing/http.js +144 -0
- package/src/serve/framing/ndjson.js +75 -0
- package/src/serve/framing/socket.js +73 -0
- package/src/serve/framing/stdio.js +28 -0
- package/src/serve/framing/token.js +36 -0
- package/src/serve/framing/ws.js +56 -0
- package/src/serve/grpc-methods.generated.json +378 -0
- package/src/serve/jsonrpc.js +164 -0
- package/src/serve/registry.js +226 -0
- package/src/serve/rpc-params.generated.json +1746 -0
- package/src/serve/sesame.proto +470 -0
- package/src/session-ui.js +181 -0
- package/src/sharekey.js +130 -0
- package/src/tokens.js +53 -0
- package/src/transport.js +634 -0
- package/src/util.js +26 -0
- package/types/access.d.ts +193 -0
- package/types/access.d.ts.map +1 -0
- package/types/account.d.ts +13 -0
- package/types/account.d.ts.map +1 -0
- package/types/auth.d.ts +80 -0
- package/types/auth.d.ts.map +1 -0
- package/types/ble/devicemodel.d.ts +212 -0
- package/types/ble/devicemodel.d.ts.map +1 -0
- package/types/ble/index.d.ts +160 -0
- package/types/ble/index.d.ts.map +1 -0
- package/types/ble/protocol.d.ts +201 -0
- package/types/ble/protocol.d.ts.map +1 -0
- package/types/ble/session.d.ts +129 -0
- package/types/ble/session.d.ts.map +1 -0
- package/types/ble/transport.d.ts +67 -0
- package/types/ble/transport.d.ts.map +1 -0
- package/types/cli/access.d.ts +6 -0
- package/types/cli/access.d.ts.map +1 -0
- package/types/cli/company.d.ts +6 -0
- package/types/cli/company.d.ts.map +1 -0
- package/types/cli/iot.d.ts +6 -0
- package/types/cli/iot.d.ts.map +1 -0
- package/types/cli/org.d.ts +6 -0
- package/types/cli/org.d.ts.map +1 -0
- package/types/cli/presetir.d.ts +6 -0
- package/types/cli/presetir.d.ts.map +1 -0
- package/types/cli/schedule.d.ts +6 -0
- package/types/cli/schedule.d.ts.map +1 -0
- package/types/cli/serve.d.ts +2 -0
- package/types/cli/serve.d.ts.map +1 -0
- package/types/cli.d.ts +2 -0
- package/types/cli.d.ts.map +1 -0
- package/types/client.d.ts +463 -0
- package/types/client.d.ts.map +1 -0
- package/types/company.d.ts +94 -0
- package/types/company.d.ts.map +1 -0
- package/types/config.d.ts +111 -0
- package/types/config.d.ts.map +1 -0
- package/types/crypto.d.ts +61 -0
- package/types/crypto.d.ts.map +1 -0
- package/types/devices.d.ts +116 -0
- package/types/devices.d.ts.map +1 -0
- package/types/index.d.ts +23 -0
- package/types/index.d.ts.map +1 -0
- package/types/iot.d.ts +312 -0
- package/types/iot.d.ts.map +1 -0
- package/types/ir.d.ts +147 -0
- package/types/ir.d.ts.map +1 -0
- package/types/itemcodes.d.ts +21 -0
- package/types/itemcodes.d.ts.map +1 -0
- package/types/lock.d.ts +89 -0
- package/types/lock.d.ts.map +1 -0
- package/types/org.d.ts +468 -0
- package/types/org.d.ts.map +1 -0
- package/types/paths.d.ts +10 -0
- package/types/paths.d.ts.map +1 -0
- package/types/presetir.d.ts +286 -0
- package/types/presetir.d.ts.map +1 -0
- package/types/prompts.d.ts +39 -0
- package/types/prompts.d.ts.map +1 -0
- package/types/schedule.d.ts +71 -0
- package/types/schedule.d.ts.map +1 -0
- package/types/serve/daemon.d.ts +133 -0
- package/types/serve/daemon.d.ts.map +1 -0
- package/types/serve/framing/grpc.d.ts +14 -0
- package/types/serve/framing/grpc.d.ts.map +1 -0
- package/types/serve/framing/http.d.ts +14 -0
- package/types/serve/framing/http.d.ts.map +1 -0
- package/types/serve/framing/ndjson.d.ts +19 -0
- package/types/serve/framing/ndjson.d.ts.map +1 -0
- package/types/serve/framing/socket.d.ts +14 -0
- package/types/serve/framing/socket.d.ts.map +1 -0
- package/types/serve/framing/stdio.d.ts +11 -0
- package/types/serve/framing/stdio.d.ts.map +1 -0
- package/types/serve/framing/token.d.ts +11 -0
- package/types/serve/framing/token.d.ts.map +1 -0
- package/types/serve/framing/ws.d.ts +13 -0
- package/types/serve/framing/ws.d.ts.map +1 -0
- package/types/serve/jsonrpc.d.ts +118 -0
- package/types/serve/jsonrpc.d.ts.map +1 -0
- package/types/serve/registry.d.ts +41 -0
- package/types/serve/registry.d.ts.map +1 -0
- package/types/session-ui.d.ts +36 -0
- package/types/session-ui.d.ts.map +1 -0
- package/types/sharekey.d.ts +35 -0
- package/types/sharekey.d.ts.map +1 -0
- package/types/tokens.d.ts +20 -0
- package/types/tokens.d.ts.map +1 -0
- package/types/transport.d.ts +138 -0
- package/types/transport.d.ts.map +1 -0
- package/types/util.d.ts +20 -0
- package/types/util.d.ts.map +1 -0
- package/vendor/biz3/README.md +37 -0
- package/vendor/biz3/constants/cmdCode.d.ts +48 -0
- package/vendor/biz3/constants/cmdCode.d.ts.map +1 -0
- package/vendor/biz3/constants/cmdCode.js +92 -0
- package/vendor/biz3/constants/messageConstants.d.ts +28 -0
- package/vendor/biz3/constants/messageConstants.d.ts.map +1 -0
- package/vendor/biz3/constants/messageConstants.js +30 -0
- package/vendor/biz3/constants/sesameDeviceModel.d.ts +75 -0
- package/vendor/biz3/constants/sesameDeviceModel.d.ts.map +1 -0
- package/vendor/biz3/constants/sesameDeviceModel.js +77 -0
- package/vendor/biz3/package.json +5 -0
package/src/transport.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
// SESAME Hub3 Biz cloud WebSocket client.
|
|
2
|
+
//
|
|
3
|
+
// Ported from biz3 (CANDY-HOUSE/biz3, MIT):
|
|
4
|
+
// - vendor reference: references_web/src/websocket/WebSocketManager.ts
|
|
5
|
+
// - vendor reference: references_web/src/hooks/useCallbacks.js
|
|
6
|
+
// - vendor reference: references_web/src/api/useRemoteCtrl.js (sendIR / getIRCodes frame)
|
|
7
|
+
// - vendor reference: references_web/src/constants/messageConstants.js (timing constants)
|
|
8
|
+
//
|
|
9
|
+
// 認証は Cognito idToken を URL クエリパラメータ ?token= で渡す。
|
|
10
|
+
// 公式 Web (biz3) と挙動を揃えるため、以下を実装している:
|
|
11
|
+
// - exponential backoff auto-reconnect (1s → 10s)
|
|
12
|
+
// - keepalive (60s) + pong timeout (3s) → 半開接続検知で active reconnect
|
|
13
|
+
// - retry 3 回失敗で token refresh callback を呼ぶ
|
|
14
|
+
// - 未接続中 send は messageQueue にバッファし OPEN 復帰時に flush
|
|
15
|
+
// - sleep/wake 検知 (setInterval の skew で host suspend を検出)
|
|
16
|
+
// - idle 検知 (1.5 × heartbeat 以上アイドルなら再接続)
|
|
17
|
+
// - 同 (action, op) の並行 request を FIFO で正しく解決
|
|
18
|
+
// (biz3 の useCallbacks は同一 op の全 callback に同じ response を流すバグがあるが、
|
|
19
|
+
// こちらは FIFO で意味的に正しい)
|
|
20
|
+
|
|
21
|
+
import WebSocket from "ws";
|
|
22
|
+
import { ACTION_TYPES } from "../vendor/biz3/constants/messageConstants.js";
|
|
23
|
+
|
|
24
|
+
const STATUS = Object.freeze({
|
|
25
|
+
DISCONNECTED: "disconnected",
|
|
26
|
+
CONNECTING: "connecting",
|
|
27
|
+
OPEN: "open",
|
|
28
|
+
CLOSING: "closing",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// 公式 messageConstants.js と同値
|
|
32
|
+
const KEEPALIVE_INTERVAL_MS = 60_000;
|
|
33
|
+
const PONG_TIMEOUT_MS = 3_000;
|
|
34
|
+
const CONNECT_TIMEOUT_MS = 5_000;
|
|
35
|
+
const MIN_RECONNECT_DELAY_MS = 1_000;
|
|
36
|
+
const MAX_RECONNECT_DELAY_MS = 10_000;
|
|
37
|
+
const RECONNECT_BACKOFF_BASE = 1.5;
|
|
38
|
+
const MAX_RETRIES_BEFORE_TOKEN_CHECK = 3;
|
|
39
|
+
const SLEEP_CHECK_INTERVAL_MS = 2_000;
|
|
40
|
+
const SLEEP_THRESHOLD_MS = 5_000;
|
|
41
|
+
const IDLE_TIME_MULTIPLIER = 1.5;
|
|
42
|
+
// queue に積んだ payload が古くなり過ぎたら破棄 (lock の sign 期限は 256s)
|
|
43
|
+
const QUEUE_ENTRY_MAX_AGE_MS = 60_000;
|
|
44
|
+
|
|
45
|
+
const KEEPALIVE_ACTION = ACTION_TYPES.BIZ3_KEEP_ALIVE; // "biz3KeepAlive" (vendor 由来)
|
|
46
|
+
|
|
47
|
+
// transport が投げるエラーの分類コード (.code)。serve の daemon が文字列正規表現でなく
|
|
48
|
+
// これで kind (timeout/connection_lost) を決定する (跨モジュールの暗黙文字列契約を排除)。
|
|
49
|
+
export const TRANSPORT_ERR = Object.freeze({ TIMEOUT: "TRANSPORT_TIMEOUT", CLOSED: "TRANSPORT_CLOSED" });
|
|
50
|
+
function timeoutErr(msg) { const e = new Error(msg); e.code = TRANSPORT_ERR.TIMEOUT; return e; }
|
|
51
|
+
function closedErr(msg) { const e = new Error(msg); e.code = TRANSPORT_ERR.CLOSED; return e; }
|
|
52
|
+
|
|
53
|
+
export class Hub3WsClient {
|
|
54
|
+
/**
|
|
55
|
+
* @param {{
|
|
56
|
+
* wsUrl: string,
|
|
57
|
+
* idToken: string,
|
|
58
|
+
* lang?: string,
|
|
59
|
+
* debug?: boolean,
|
|
60
|
+
* autoReconnect?: boolean, // default true
|
|
61
|
+
* onTokenRefreshNeeded?: (oldToken: string) => Promise<string|null>,
|
|
62
|
+
* // retry が MAX_RETRIES_BEFORE_TOKEN_CHECK に達した時に呼ばれる。
|
|
63
|
+
* // 新 token を返すと idToken を差し替えて retryCount をリセットして再接続継続。
|
|
64
|
+
* // null を返すと諦めず exponential backoff を続行 (token は古いまま)。
|
|
65
|
+
* }} cfg
|
|
66
|
+
*/
|
|
67
|
+
constructor(cfg) {
|
|
68
|
+
if (!cfg.wsUrl) throw new Error("wsUrl required");
|
|
69
|
+
if (!cfg.idToken) throw new Error("idToken required (Cognito idToken JWT)");
|
|
70
|
+
this.cfg = { lang: "ja", debug: false, autoReconnect: true, ...cfg };
|
|
71
|
+
this.idToken = cfg.idToken;
|
|
72
|
+
this.onTokenRefreshNeeded = cfg.onTokenRefreshNeeded || null;
|
|
73
|
+
// 再接続 (初回以外の OPEN) で呼ばれる。サーバへ subscribe frame を再送したい購読者用。
|
|
74
|
+
// 初回 OPEN では呼ばれない (初回購読は通常の API 経由で張られるため)。
|
|
75
|
+
this.onReopen = cfg.onReopen || null;
|
|
76
|
+
this._everConnected = false;
|
|
77
|
+
|
|
78
|
+
/** @type {import("ws").WebSocket | null} */
|
|
79
|
+
this.ws = null;
|
|
80
|
+
this.status = STATUS.DISCONNECTED;
|
|
81
|
+
|
|
82
|
+
/** @type {Map<string, ((msg:any|Error)=>void)[]>} action+op → FIFO の resolver 配列 */
|
|
83
|
+
this.pending = new Map();
|
|
84
|
+
/** @type {((msg:any)=>void)[]} 任意メッセージのリスナ */
|
|
85
|
+
this.listeners = [];
|
|
86
|
+
/** @type {Map<string, Set<(msg:any)=>void>>} action+op → 永続購読 fn 集合 (biz3 の pub* 系イベント受信用) */
|
|
87
|
+
this.subscribers = new Map();
|
|
88
|
+
/** @type {any[]} 未接続中の送信をバッファ */
|
|
89
|
+
this.messageQueue = [];
|
|
90
|
+
|
|
91
|
+
this.retryCount = 0;
|
|
92
|
+
this.closedByUser = false;
|
|
93
|
+
this.lastActiveTime = Date.now();
|
|
94
|
+
this.lastTickTime = Date.now();
|
|
95
|
+
this._refreshedThisCycle = false; // 1 接続サイクルに 1 回まで token refresh (Review C-3)
|
|
96
|
+
|
|
97
|
+
// timers
|
|
98
|
+
this.keepaliveTimer = null;
|
|
99
|
+
this.pongTimer = null;
|
|
100
|
+
this.connectTimer = null;
|
|
101
|
+
this.reconnectTimer = null;
|
|
102
|
+
this.sleepDetectorTimer = null;
|
|
103
|
+
|
|
104
|
+
// 初回 connect() の promise/resolver (Review C-2)
|
|
105
|
+
this._connectPromise = null;
|
|
106
|
+
this._initialConnectResolve = null;
|
|
107
|
+
this._initialConnectReject = null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
log(...args) {
|
|
111
|
+
if (this.cfg.debug) console.error("[hub3]", ...args);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------- public API ----------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* WS 接続を確立。失敗時は reject。成功後の切断は auto-reconnect される。
|
|
118
|
+
*/
|
|
119
|
+
async connect() {
|
|
120
|
+
if (this.status === STATUS.OPEN) return;
|
|
121
|
+
// 既に接続中なら同じ promise を返す (Review C-2: 二重上書き防止)
|
|
122
|
+
if (this._connectPromise) return this._connectPromise;
|
|
123
|
+
|
|
124
|
+
this.closedByUser = false;
|
|
125
|
+
this.retryCount = 0;
|
|
126
|
+
this._refreshedThisCycle = false;
|
|
127
|
+
this._startSleepDetector();
|
|
128
|
+
|
|
129
|
+
this._connectPromise = new Promise((resolve, reject) => {
|
|
130
|
+
this._initialConnectResolve = resolve;
|
|
131
|
+
this._initialConnectReject = reject;
|
|
132
|
+
this._initWebSocket();
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
await this._connectPromise;
|
|
136
|
+
} finally {
|
|
137
|
+
this._connectPromise = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 明示的にクローズ。auto-reconnect は抑止される。 */
|
|
142
|
+
close() {
|
|
143
|
+
this.closedByUser = true;
|
|
144
|
+
this.status = STATUS.CLOSING;
|
|
145
|
+
this._clearAllTimers();
|
|
146
|
+
this._stopSleepDetector();
|
|
147
|
+
if (this.ws) {
|
|
148
|
+
try {
|
|
149
|
+
this.ws.removeAllListeners("close");
|
|
150
|
+
this.ws.removeAllListeners("error");
|
|
151
|
+
this.ws.close();
|
|
152
|
+
} catch { /* ignore */ }
|
|
153
|
+
}
|
|
154
|
+
this.ws = null;
|
|
155
|
+
this.status = STATUS.DISCONNECTED;
|
|
156
|
+
this._rejectAllPending(closedErr("websocket closed by user"));
|
|
157
|
+
this.messageQueue = [];
|
|
158
|
+
// 初回 connect() 中だった場合は明示的に reject (Review C-2: leak 防止)
|
|
159
|
+
if (this._initialConnectReject) {
|
|
160
|
+
const rej = this._initialConnectReject;
|
|
161
|
+
this._initialConnectResolve = null;
|
|
162
|
+
this._initialConnectReject = null;
|
|
163
|
+
try { rej(closedErr("closed before initial connect resolved")); } catch { /* ignore */ }
|
|
164
|
+
}
|
|
165
|
+
// _connectPromise も null 化 (2nd-pass C-2: rejected promise が次回 connect() で
|
|
166
|
+
// 再利用される race を防ぐ)。
|
|
167
|
+
this._connectPromise = null;
|
|
168
|
+
this.subscribers.clear(); // 全 subscriber も解除 (Review L-6)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* リクエスト送信。応答 (action+op 一致) を1個待つ。FIFO。
|
|
173
|
+
* 未接続中は messageQueue に積まれ、接続復帰後 flush される。
|
|
174
|
+
*/
|
|
175
|
+
request(payload, timeoutMs = 10_000) {
|
|
176
|
+
const key = `${payload.action}:${payload.op || ""}`;
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const resolver = (msg) => {
|
|
179
|
+
clearTimeout(to);
|
|
180
|
+
if (msg instanceof Error) reject(msg);
|
|
181
|
+
else resolve(msg);
|
|
182
|
+
};
|
|
183
|
+
const to = setTimeout(() => {
|
|
184
|
+
this._unregisterPending(key, resolver);
|
|
185
|
+
reject(timeoutErr(`request timeout: ${key}`));
|
|
186
|
+
}, timeoutMs);
|
|
187
|
+
this._registerPending(key, resolver);
|
|
188
|
+
this._sendOrQueue(payload);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 応答を期待しない fire-and-forget 送信。 */
|
|
193
|
+
send(payload) {
|
|
194
|
+
this._sendOrQueue(payload);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
onMessage(fn) {
|
|
198
|
+
this.listeners.push(fn);
|
|
199
|
+
// unsubscribe を返す (Review L-6: 長時間プロセスの leak 防止)
|
|
200
|
+
return () => {
|
|
201
|
+
const i = this.listeners.indexOf(fn);
|
|
202
|
+
if (i >= 0) this.listeners.splice(i, 1);
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 特定 action+op の永続購読。biz3 の `pubDeviceStateChange` などの async push を受ける。
|
|
208
|
+
*
|
|
209
|
+
* @param {string} key `"<action>:<op>"` 形式。op が無い場合は `"<action>:"`
|
|
210
|
+
* @param {(msg:any)=>void} fn
|
|
211
|
+
* @returns {()=>void} unsubscribe
|
|
212
|
+
*/
|
|
213
|
+
subscribe(key, fn) {
|
|
214
|
+
let set = this.subscribers.get(key);
|
|
215
|
+
if (!set) { set = new Set(); this.subscribers.set(key, set); }
|
|
216
|
+
set.add(fn);
|
|
217
|
+
return () => {
|
|
218
|
+
const s = this.subscribers.get(key);
|
|
219
|
+
if (!s) return;
|
|
220
|
+
s.delete(fn);
|
|
221
|
+
if (s.size === 0) this.subscribers.delete(key);
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* keepalive 1往復で接続を実検証する。timeout で reject。
|
|
227
|
+
* 注: biz3 の keepalive ack は `success` ではなく `connectionId` を返す
|
|
228
|
+
* (WebSocketManager.ts:72-83)。なので応答が**届いたこと自体**を生存判定とし、
|
|
229
|
+
* success フィールドの有無には依存しない (旧実装は !!resp.success で常に false の恐れ)。
|
|
230
|
+
*/
|
|
231
|
+
async ping(timeoutMs = PONG_TIMEOUT_MS) {
|
|
232
|
+
const resp = await this.request({ action: KEEPALIVE_ACTION }, timeoutMs);
|
|
233
|
+
return !!resp; // 応答受信 = 生存。request は timeout で reject する。
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 接続状態 (デバッグ・テスト用)。 */
|
|
237
|
+
getStatus() {
|
|
238
|
+
return this.status;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------- internal: WS lifecycle ----------
|
|
242
|
+
|
|
243
|
+
_initWebSocket() {
|
|
244
|
+
this.status = STATUS.CONNECTING;
|
|
245
|
+
const url = `${this.cfg.wsUrl}?token=${encodeURIComponent(this.idToken)}&lang=${encodeURIComponent(this.cfg.lang)}`;
|
|
246
|
+
this.log("connecting", this.cfg.wsUrl);
|
|
247
|
+
|
|
248
|
+
if (this.ws) {
|
|
249
|
+
try {
|
|
250
|
+
this.ws.removeAllListeners();
|
|
251
|
+
this.ws.close();
|
|
252
|
+
} catch { /* ignore */ }
|
|
253
|
+
this.ws = null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this.ws = new WebSocket(url);
|
|
257
|
+
|
|
258
|
+
this.connectTimer = setTimeout(() => {
|
|
259
|
+
this.connectTimer = null;
|
|
260
|
+
if (this.ws && this.status !== STATUS.OPEN) {
|
|
261
|
+
this.log("connect timeout — closing");
|
|
262
|
+
try { this.ws.close(); } catch { /* ignore */ }
|
|
263
|
+
}
|
|
264
|
+
}, CONNECT_TIMEOUT_MS);
|
|
265
|
+
|
|
266
|
+
this.ws.once("open", () => this._onOpen());
|
|
267
|
+
this.ws.on("message", (raw) => this._onMessage(raw));
|
|
268
|
+
this.ws.on("close", (code, reason) => this._onClose(code, reason));
|
|
269
|
+
this.ws.on("error", (err) => this._onError(err));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_onOpen() {
|
|
273
|
+
const isReconnect = this._everConnected;
|
|
274
|
+
this._everConnected = true;
|
|
275
|
+
this.status = STATUS.OPEN;
|
|
276
|
+
this.log("connected");
|
|
277
|
+
if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; }
|
|
278
|
+
this.retryCount = 0;
|
|
279
|
+
this._refreshedThisCycle = false; // 接続成功で token refresh 履歴をリセット (Review C-3)
|
|
280
|
+
this.lastActiveTime = Date.now();
|
|
281
|
+
this._startKeepalive();
|
|
282
|
+
this._flushMessageQueue();
|
|
283
|
+
|
|
284
|
+
if (this._initialConnectResolve) {
|
|
285
|
+
const r = this._initialConnectResolve;
|
|
286
|
+
this._initialConnectResolve = null;
|
|
287
|
+
this._initialConnectReject = null;
|
|
288
|
+
r();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 再接続時: 購読者は subscribe frame の再送が要る (サーバは新接続を覚えていない)。
|
|
292
|
+
if (isReconnect && this.onReopen) {
|
|
293
|
+
try { this.onReopen(); } catch (e) { this.log("onReopen error", e?.message); }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
_onClose(code, reason) {
|
|
298
|
+
this.log("closed", code, reason?.toString());
|
|
299
|
+
this._clearKeepalive();
|
|
300
|
+
if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; }
|
|
301
|
+
const wasOpen = this.status === STATUS.OPEN;
|
|
302
|
+
this.status = STATUS.DISCONNECTED;
|
|
303
|
+
this.ws = null;
|
|
304
|
+
|
|
305
|
+
// 接続中の pending を全部 reject (応答先 ws が消えたため)
|
|
306
|
+
this._rejectAllPending(closedErr("websocket closed"));
|
|
307
|
+
|
|
308
|
+
if (this.closedByUser) return;
|
|
309
|
+
|
|
310
|
+
// 初回 connect 中の close → connect() を reject、以降 send が積み続けないよう
|
|
311
|
+
// closedByUser=true 相当の振る舞いにする (Review M-7)
|
|
312
|
+
if (!wasOpen && this._initialConnectReject) {
|
|
313
|
+
const rej = this._initialConnectReject;
|
|
314
|
+
this._initialConnectResolve = null;
|
|
315
|
+
this._initialConnectReject = null;
|
|
316
|
+
this.closedByUser = true;
|
|
317
|
+
this.messageQueue = [];
|
|
318
|
+
rej(closedErr(`websocket closed before open (code=${code})`));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (this.cfg.autoReconnect) {
|
|
323
|
+
this._handleReconnect();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
_onError(err) {
|
|
328
|
+
this.log("error", err?.message || err);
|
|
329
|
+
// error 直後に close も来るので、reconnect 判断は close 側で行う
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------- internal: reconnect ----------
|
|
333
|
+
|
|
334
|
+
/** 受動的再接続 (close を契機)。exponential backoff。 */
|
|
335
|
+
_handleReconnect() {
|
|
336
|
+
if (this.closedByUser) return;
|
|
337
|
+
if (this.reconnectTimer) return; // 既にスケジュール済み
|
|
338
|
+
if (this.ws && this.status === STATUS.CONNECTING) return;
|
|
339
|
+
|
|
340
|
+
const delay = Math.min(
|
|
341
|
+
MIN_RECONNECT_DELAY_MS * Math.pow(RECONNECT_BACKOFF_BASE, this.retryCount),
|
|
342
|
+
MAX_RECONNECT_DELAY_MS,
|
|
343
|
+
);
|
|
344
|
+
this.log(`reconnect scheduled (attempt ${this.retryCount + 1}, delay=${Math.floor(delay)}ms)`);
|
|
345
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
346
|
+
this.reconnectTimer = null;
|
|
347
|
+
this.retryCount++;
|
|
348
|
+
// 1 connect サイクルに 1 回だけ token refresh を試す (Review C-3: 無限ループ防止)
|
|
349
|
+
if (
|
|
350
|
+
this.retryCount === MAX_RETRIES_BEFORE_TOKEN_CHECK
|
|
351
|
+
&& this.onTokenRefreshNeeded
|
|
352
|
+
&& !this._refreshedThisCycle
|
|
353
|
+
) {
|
|
354
|
+
this._refreshedThisCycle = true;
|
|
355
|
+
this.log("retry threshold reached — requesting token refresh");
|
|
356
|
+
try {
|
|
357
|
+
const fresh = await this.onTokenRefreshNeeded(this.idToken);
|
|
358
|
+
if (fresh && fresh !== this.idToken) {
|
|
359
|
+
this.idToken = fresh;
|
|
360
|
+
this.retryCount = 0;
|
|
361
|
+
this.log("token refreshed");
|
|
362
|
+
} else {
|
|
363
|
+
this.log("token refresh returned no new token — continuing backoff");
|
|
364
|
+
}
|
|
365
|
+
} catch (e) {
|
|
366
|
+
this.log("token refresh callback threw:", e?.message || e);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
this._initWebSocket();
|
|
370
|
+
}, delay);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 能動的再接続 (pong timeout / sleep wake / idle 検知)。delay なし。 */
|
|
374
|
+
_reconnect() {
|
|
375
|
+
if (this.closedByUser) return;
|
|
376
|
+
this.log("active reconnect");
|
|
377
|
+
this._clearAllTimers();
|
|
378
|
+
if (this.ws) {
|
|
379
|
+
try {
|
|
380
|
+
this.ws.removeAllListeners();
|
|
381
|
+
this.ws.close();
|
|
382
|
+
} catch { /* ignore */ }
|
|
383
|
+
this.ws = null;
|
|
384
|
+
}
|
|
385
|
+
this.status = STATUS.DISCONNECTED;
|
|
386
|
+
this.retryCount = 0;
|
|
387
|
+
// 能動再接続後の新サイクルでも token refresh が動くようリセット (2nd-pass C-3)
|
|
388
|
+
this._refreshedThisCycle = false;
|
|
389
|
+
this._initWebSocket();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------- internal: message routing ----------
|
|
393
|
+
|
|
394
|
+
_onMessage(raw) {
|
|
395
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
|
396
|
+
let msg;
|
|
397
|
+
try { msg = JSON.parse(text); }
|
|
398
|
+
catch {
|
|
399
|
+
this.log("non-JSON message:", text.slice(0, 200));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
this.log("recv:", text.length > 200 ? text.slice(0, 200) + "..." : text);
|
|
403
|
+
this.lastActiveTime = Date.now();
|
|
404
|
+
|
|
405
|
+
// keepalive ack: success フィールド有無問わず pong timer をクリア (Review H-1:
|
|
406
|
+
// サーバが success 無しで返した場合でも正常通信として扱う)
|
|
407
|
+
if (msg.action === KEEPALIVE_ACTION) {
|
|
408
|
+
if (this.pongTimer) { clearTimeout(this.pongTimer); this.pongTimer = null; }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// FIFO で 1 resolver 解決
|
|
412
|
+
const key = `${msg.action}:${msg.op || ""}`;
|
|
413
|
+
const queue = this.pending.get(key);
|
|
414
|
+
if (queue && queue.length > 0) {
|
|
415
|
+
const resolver = queue.shift();
|
|
416
|
+
if (queue.length === 0) this.pending.delete(key);
|
|
417
|
+
try { resolver(msg); } catch (e) { this.log("resolver threw:", e); }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 永続購読 fan-out。Set を snapshot してから iterate (Review H-6:
|
|
421
|
+
// ハンドラ内で unsub される可能性があるためイテレータ汚染を避ける)
|
|
422
|
+
const subs = this.subscribers.get(key);
|
|
423
|
+
if (subs && subs.size > 0) {
|
|
424
|
+
for (const fn of [...subs]) {
|
|
425
|
+
try { fn(msg); } catch (e) { this.log("subscriber threw:", e); }
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
for (const l of this.listeners) {
|
|
430
|
+
try { l(msg); } catch (e) { this.log("listener err", e); }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
_registerPending(key, resolver) {
|
|
435
|
+
let queue = this.pending.get(key);
|
|
436
|
+
if (!queue) { queue = []; this.pending.set(key, queue); }
|
|
437
|
+
queue.push(resolver);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
_unregisterPending(key, resolver) {
|
|
441
|
+
const queue = this.pending.get(key);
|
|
442
|
+
if (!queue) return;
|
|
443
|
+
const i = queue.indexOf(resolver);
|
|
444
|
+
if (i >= 0) queue.splice(i, 1);
|
|
445
|
+
if (queue.length === 0) this.pending.delete(key);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
_rejectAllPending(err) {
|
|
449
|
+
for (const [, queue] of this.pending) {
|
|
450
|
+
for (const r of queue) {
|
|
451
|
+
try { r(err); } catch { /* ignore */ }
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
this.pending.clear();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---------- internal: send / queue ----------
|
|
458
|
+
|
|
459
|
+
_sendOrQueue(payload) {
|
|
460
|
+
if (this.ws && this.status === STATUS.OPEN) {
|
|
461
|
+
this.log("send:", JSON.stringify(payload));
|
|
462
|
+
try {
|
|
463
|
+
this.ws.send(JSON.stringify(payload));
|
|
464
|
+
} catch (e) {
|
|
465
|
+
this.log("send failed, queueing:", e?.message || e);
|
|
466
|
+
this.messageQueue.push({ payload, enqueuedAt: Date.now() });
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
this.log("queued (not open):", JSON.stringify(payload));
|
|
470
|
+
this.messageQueue.push({ payload, enqueuedAt: Date.now() });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
_flushMessageQueue() {
|
|
475
|
+
const now = Date.now();
|
|
476
|
+
// 古過ぎる payload を drop (Review H-3: lock の CMAC sign は 256s 粒度なので
|
|
477
|
+
// 60s 超えたら危険。pending の resolver も timeout に任せる)
|
|
478
|
+
let dropped = 0;
|
|
479
|
+
while (this.messageQueue.length > 0 && now - this.messageQueue[0].enqueuedAt > QUEUE_ENTRY_MAX_AGE_MS) {
|
|
480
|
+
this.messageQueue.shift();
|
|
481
|
+
dropped++;
|
|
482
|
+
}
|
|
483
|
+
if (dropped > 0) this.log(`flush: dropped ${dropped} stale queued payload(s)`);
|
|
484
|
+
while (this.messageQueue.length > 0 && this.ws && this.status === STATUS.OPEN) {
|
|
485
|
+
const entry = this.messageQueue.shift();
|
|
486
|
+
try {
|
|
487
|
+
this.log("flush:", JSON.stringify(entry.payload));
|
|
488
|
+
this.ws.send(JSON.stringify(entry.payload));
|
|
489
|
+
} catch (e) {
|
|
490
|
+
this.log("flush failed, re-queueing:", e?.message || e);
|
|
491
|
+
this.messageQueue.unshift(entry);
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---------- internal: keepalive + idle ----------
|
|
498
|
+
|
|
499
|
+
_startKeepalive() {
|
|
500
|
+
this._clearKeepalive();
|
|
501
|
+
const tick = () => {
|
|
502
|
+
if (!this.ws || this.status !== STATUS.OPEN) return;
|
|
503
|
+
|
|
504
|
+
// idle 検知: 1.5 × heartbeat 以上音沙汰なし → 半開接続疑い
|
|
505
|
+
const idle = Date.now() - this.lastActiveTime;
|
|
506
|
+
if (idle > KEEPALIVE_INTERVAL_MS * IDLE_TIME_MULTIPLIER) {
|
|
507
|
+
this.log(`idle ${Math.floor(idle / 1000)}s — reconnecting`);
|
|
508
|
+
this._reconnect();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
this._triggerHeartbeatCheck();
|
|
513
|
+
};
|
|
514
|
+
this.keepaliveTimer = setInterval(tick, KEEPALIVE_INTERVAL_MS);
|
|
515
|
+
this._triggerHeartbeatCheck(); // 接続直後にも 1 回
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
_triggerHeartbeatCheck() {
|
|
519
|
+
if (this.pongTimer) { clearTimeout(this.pongTimer); this.pongTimer = null; }
|
|
520
|
+
try {
|
|
521
|
+
this.ws.send(JSON.stringify({ action: KEEPALIVE_ACTION }));
|
|
522
|
+
} catch (e) {
|
|
523
|
+
this.log("keepalive send err:", e?.message || e);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
this.pongTimer = setTimeout(() => {
|
|
527
|
+
this.log("pong timeout — active reconnect");
|
|
528
|
+
this._reconnect();
|
|
529
|
+
}, PONG_TIMEOUT_MS);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
_clearKeepalive() {
|
|
533
|
+
if (this.keepaliveTimer) { clearInterval(this.keepaliveTimer); this.keepaliveTimer = null; }
|
|
534
|
+
if (this.pongTimer) { clearTimeout(this.pongTimer); this.pongTimer = null; }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---------- internal: sleep detector ----------
|
|
538
|
+
|
|
539
|
+
_startSleepDetector() {
|
|
540
|
+
this._stopSleepDetector();
|
|
541
|
+
this.lastTickTime = Date.now();
|
|
542
|
+
this.sleepDetectorTimer = setInterval(() => {
|
|
543
|
+
const now = Date.now();
|
|
544
|
+
const gap = now - this.lastTickTime;
|
|
545
|
+
this.lastTickTime = now;
|
|
546
|
+
if (gap > SLEEP_THRESHOLD_MS) {
|
|
547
|
+
this.log(`sleep/wake detected (gap ${gap}ms) — checking connection`);
|
|
548
|
+
this._wakeUpConnection();
|
|
549
|
+
}
|
|
550
|
+
}, SLEEP_CHECK_INTERVAL_MS);
|
|
551
|
+
this.sleepDetectorTimer.unref?.(); // Node プロセス終了を妨げない
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
_stopSleepDetector() {
|
|
555
|
+
if (this.sleepDetectorTimer) { clearInterval(this.sleepDetectorTimer); this.sleepDetectorTimer = null; }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
_wakeUpConnection() {
|
|
559
|
+
if (this.closedByUser) return;
|
|
560
|
+
const idle = Date.now() - this.lastActiveTime;
|
|
561
|
+
if (!this.ws || this.status !== STATUS.OPEN || idle > KEEPALIVE_INTERVAL_MS * IDLE_TIME_MULTIPLIER) {
|
|
562
|
+
this._reconnect();
|
|
563
|
+
} else {
|
|
564
|
+
this._triggerHeartbeatCheck();
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---------- internal: misc ----------
|
|
569
|
+
|
|
570
|
+
_clearAllTimers() {
|
|
571
|
+
this._clearKeepalive();
|
|
572
|
+
if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; }
|
|
573
|
+
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ---------- protocol helpers ----------
|
|
578
|
+
// Ported from biz3 (vendor: references_web/src/api/useRemoteCtrl.js)。
|
|
579
|
+
// hook の useCallback 内部のフレーム組み立て部分だけを抽出して plain function 化。
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* SESAME Hub3 から IR を発射する。
|
|
583
|
+
*
|
|
584
|
+
* @param {Hub3WsClient} client
|
|
585
|
+
* @param {{
|
|
586
|
+
* deviceId: string, // Hub3 UUID (大文字)
|
|
587
|
+
* irDeviceUUID: string, // remote.uuid
|
|
588
|
+
* irType: number, // remote.type (例: 49152)
|
|
589
|
+
* command: string, // 自己学習なら keyUUID、プリセットなら 16byte hex
|
|
590
|
+
* operation: "learnEmit"|"remoteEmit",
|
|
591
|
+
* companyID: string,
|
|
592
|
+
* }} params
|
|
593
|
+
*/
|
|
594
|
+
export async function sendIR(client, params) {
|
|
595
|
+
const frame = {
|
|
596
|
+
action: ACTION_TYPES.BIZ3_IR_REMOTE,
|
|
597
|
+
op: "sendIR",
|
|
598
|
+
deviceId: params.deviceId,
|
|
599
|
+
command: params.command,
|
|
600
|
+
operation: params.operation,
|
|
601
|
+
irType: params.irType,
|
|
602
|
+
companyID: params.companyID,
|
|
603
|
+
irDeviceUUID: params.irDeviceUUID,
|
|
604
|
+
};
|
|
605
|
+
const resp = await client.request(frame, 10_000);
|
|
606
|
+
if (!resp.success) {
|
|
607
|
+
throw new Error(`sendIR failed: ${resp.message || JSON.stringify(resp)}`);
|
|
608
|
+
}
|
|
609
|
+
return resp;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* 指定 IR デバイス (リモコン) の登録キー一覧を取得。
|
|
614
|
+
*
|
|
615
|
+
* 注: このエンドポイントだけ Hub3 UUID のフィールド名が `deviceId` ではなく
|
|
616
|
+
* `hub3DeviceId`。これは公式 biz3 の意図的な命名差 (useRemoteCtrl.js:820-826)。
|
|
617
|
+
*
|
|
618
|
+
* @param {Hub3WsClient} client
|
|
619
|
+
* @param {{deviceId:string, irDeviceUUID:string, companyID:string}} params
|
|
620
|
+
*/
|
|
621
|
+
export async function getIRCodes(client, params) {
|
|
622
|
+
const frame = {
|
|
623
|
+
action: ACTION_TYPES.BIZ3_IR_REMOTE,
|
|
624
|
+
op: "getIRCodes",
|
|
625
|
+
hub3DeviceId: params.deviceId,
|
|
626
|
+
remoteId: params.irDeviceUUID,
|
|
627
|
+
companyID: params.companyID,
|
|
628
|
+
};
|
|
629
|
+
const resp = await client.request(frame, 10_000);
|
|
630
|
+
if (!resp.success) {
|
|
631
|
+
throw new Error(`getIRCodes failed: ${resp.message || JSON.stringify(resp)}`);
|
|
632
|
+
}
|
|
633
|
+
return resp.data || [];
|
|
634
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// モジュール横断の小さな共有ユーティリティ。
|
|
2
|
+
// WS op の応答 success 判定はほぼ全モジュールで重複していたので 1 箇所に集約する。
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WS op の応答 `resp` を検査し、失敗していれば例外を投げる。成功なら resp を返す。
|
|
6
|
+
*
|
|
7
|
+
* biz3 の応答には 2 系統あり、本ライブラリも両方を扱う:
|
|
8
|
+
* - lenient (既定): `success` フィールドが**明示的に false** の時だけ失敗扱い。
|
|
9
|
+
* `success` を持たない応答 (data だけ返る op、push 集約の完了通知など) は成功とみなす。
|
|
10
|
+
* - strict: `success === true` を要求し、欠落していても失敗扱い
|
|
11
|
+
* (常に success を返すと分かっている op 用)。
|
|
12
|
+
*
|
|
13
|
+
* @template T
|
|
14
|
+
* @param {T} resp WS 応答メッセージ
|
|
15
|
+
* @param {string} op 失敗時メッセージに使う op ラベル
|
|
16
|
+
* @param {{strict?:boolean}} [opts]
|
|
17
|
+
* @returns {T} 成功時はそのまま resp を返す (呼び出し側で resp.data 等を取り出せる)
|
|
18
|
+
* @throws {Error} 失敗時 (`<op> failed: <message|JSON>`)
|
|
19
|
+
*/
|
|
20
|
+
export function assertSuccess(resp, op, { strict = false } = {}) {
|
|
21
|
+
const failed = strict ? !resp?.success : !resp || resp.success === false;
|
|
22
|
+
if (failed) {
|
|
23
|
+
throw new Error(`${op} failed: ${resp?.message || JSON.stringify(resp)}`);
|
|
24
|
+
}
|
|
25
|
+
return resp;
|
|
26
|
+
}
|