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/config.js
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
// config.json の読み書き + ドメインモデル。
|
|
2
|
+
// 単一ファイルで Hub3 群 / リモコン群 / デフォルト指定を保持する。
|
|
3
|
+
//
|
|
4
|
+
// 形式 (例):
|
|
5
|
+
// {
|
|
6
|
+
// "companyID": "ch_CandyhouseMobile",
|
|
7
|
+
// "wsUrl": "wss://...",
|
|
8
|
+
// "lang": "ja",
|
|
9
|
+
// "default": { "remote": "ac" },
|
|
10
|
+
// "hub3s": { "<name>": { "deviceId": "...", "name?": "..." } },
|
|
11
|
+
// "remotes": { "<name>": {
|
|
12
|
+
// "hub3": "<hub3-name>",
|
|
13
|
+
// "irDeviceUUID": "...",
|
|
14
|
+
// "irType": 49152,
|
|
15
|
+
// "irOperation": "learnEmit",
|
|
16
|
+
// "alias?": "...",
|
|
17
|
+
// "keys": { "<キー名>": "<keyUUID>" }
|
|
18
|
+
// } }
|
|
19
|
+
// }
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname } from "node:path";
|
|
23
|
+
import { configPaths } from "./paths.js";
|
|
24
|
+
import { DEFAULT_IR_TYPE } from "./crypto.js";
|
|
25
|
+
|
|
26
|
+
// WS ステージは `/public` が公式値:
|
|
27
|
+
// - biz3 現行ソース (env_config.js:2) = `/public`
|
|
28
|
+
// - 公式 BLE 実装の解析でも「公式は /public」と確認済み
|
|
29
|
+
// 旧既定の `/production` は web 実装由来の値で、web コード移植の流れで紛れ込んだもの
|
|
30
|
+
// (auth.js は consumer client を保ったのに、ここのエンドポイント保持を忘れていた)。公式は /public。
|
|
31
|
+
const DEFAULT_WS_URL =
|
|
32
|
+
"wss://82q6nuplv0.execute-api.ap-northeast-1.amazonaws.com/public";
|
|
33
|
+
// 禁止エンドポイント (web 由来の誤値)。config に焼き付いていたら /public へ強制し使わせない。
|
|
34
|
+
const LEGACY_WS_URL =
|
|
35
|
+
"wss://82q6nuplv0.execute-api.ap-northeast-1.amazonaws.com/production";
|
|
36
|
+
const DEFAULT_LANG = "ja";
|
|
37
|
+
const DEFAULT_COMPANY_ID = "ch_CandyhouseMobile";
|
|
38
|
+
|
|
39
|
+
function emptyConfig() {
|
|
40
|
+
return {
|
|
41
|
+
companyID: DEFAULT_COMPANY_ID,
|
|
42
|
+
wsUrl: DEFAULT_WS_URL,
|
|
43
|
+
lang: DEFAULT_LANG,
|
|
44
|
+
default: { remote: null, lock: null },
|
|
45
|
+
// 単一の真実: SESAME デバイス全部 (lock/bot/bike/hub3/...) を device レコード丸ごと格納する。
|
|
46
|
+
// kind は deviceModel から導出。型ごとにコレクションを分けない (分割+cherry-pick が
|
|
47
|
+
// model/secretKey の取りこぼしバグの温床だった)。
|
|
48
|
+
devices: {},
|
|
49
|
+
remotes: {}, // IR リモコンは device ではない子エンティティ (親 hub3 + irType + 学習 keys)
|
|
50
|
+
apiKeyId: null, // biz3 dev console で発行する REST WebAPI 用キー
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 永続化する正準キー (locks/hub3s は派生 view なので保存しない)。
|
|
55
|
+
// これは意図的なハードホワイトリスト: ここに無いトップレベルキーは save() で落とす。
|
|
56
|
+
// 将来フィールドを足すときはこの配列にも必ず追加すること (追加し忘れると黙って消える)。
|
|
57
|
+
const PERSISTED_KEYS = ["companyID", "wsUrl", "lang", "default", "devices", "remotes", "apiKeyId"];
|
|
58
|
+
|
|
59
|
+
// device レコードのうち config ローカルにだけ存在する注釈キー (サーバ応答には無い)。
|
|
60
|
+
// sync 更新時にサーバ由来フィールドで丸ごと置き換えても、これらは引き継ぐ。
|
|
61
|
+
const LOCAL_ONLY_KEYS = ["category"];
|
|
62
|
+
|
|
63
|
+
/** device レコードから lock view 用エントリ (旧 shape: deviceUUID/secretKey/model/alias)。 */
|
|
64
|
+
function lockView(rec) {
|
|
65
|
+
return { deviceUUID: rec.deviceUUID, secretKey: rec.secretKey, model: rec.deviceModel || null, alias: rec.deviceName || null };
|
|
66
|
+
}
|
|
67
|
+
/** device レコードから hub3 view 用エントリ (旧 shape: deviceId/name + model/secretKey も保持)。 */
|
|
68
|
+
function hub3View(rec, name) {
|
|
69
|
+
return { deviceId: rec.deviceUUID, name: rec.deviceName || name, model: rec.deviceModel || "hub_3", secretKey: rec.secretKey || null };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class ConfigStore {
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} configPath 絶対パス
|
|
75
|
+
*/
|
|
76
|
+
constructor(configPath) {
|
|
77
|
+
if (!configPath) throw new Error("configPath required");
|
|
78
|
+
this.configPath = configPath;
|
|
79
|
+
this.data = null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static fromConfigDir(configDir) {
|
|
83
|
+
return new ConfigStore(configPaths(configDir).config);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
exists() { return existsSync(this.configPath); }
|
|
87
|
+
|
|
88
|
+
/** ファイル不在時はメモリ上で空オブジェクトを返す (保存はしない)。 */
|
|
89
|
+
load() {
|
|
90
|
+
if (this.data) return this.data;
|
|
91
|
+
if (!existsSync(this.configPath)) {
|
|
92
|
+
this.data = emptyConfig();
|
|
93
|
+
this._reproject();
|
|
94
|
+
return this.data;
|
|
95
|
+
}
|
|
96
|
+
const raw = JSON.parse(readFileSync(this.configPath, "utf8"));
|
|
97
|
+
// デフォルト値で穴埋め
|
|
98
|
+
this.data = { ...emptyConfig(), ...raw };
|
|
99
|
+
if (!this.data.default) this.data.default = { remote: null, lock: null };
|
|
100
|
+
if (this.data.default.lock === undefined) this.data.default.lock = null;
|
|
101
|
+
if (!this.data.devices) this.data.devices = {};
|
|
102
|
+
if (!this.data.remotes) this.data.remotes = {};
|
|
103
|
+
|
|
104
|
+
// 安全ガード: /production は接続経路として絶対に使わせない。どこから入った値でも
|
|
105
|
+
// (古い既定・手書き) /public へ強制し、ファイルからも物理的に消す。後方互換ではなく
|
|
106
|
+
// 「禁止エンドポイントを焼き付けさせない」防御。
|
|
107
|
+
let forced = false;
|
|
108
|
+
if (this.data.wsUrl === LEGACY_WS_URL) { this.data.wsUrl = DEFAULT_WS_URL; forced = true; }
|
|
109
|
+
|
|
110
|
+
this._reproject(); // devices から locks/hub3s の派生 view を作る
|
|
111
|
+
if (forced) { try { this.save(); } catch { /* 読み取り専用環境では in-memory のみ */ } }
|
|
112
|
+
return this.data;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** devices{} から locks{}/hub3s{} の派生 view (旧 shape) を都度組み立てる。reader 互換用。 */
|
|
116
|
+
_reproject() {
|
|
117
|
+
const cfg = this.data;
|
|
118
|
+
cfg.locks = {};
|
|
119
|
+
cfg.hub3s = {};
|
|
120
|
+
for (const [name, rec] of Object.entries(cfg.devices || {})) {
|
|
121
|
+
const cat = effectiveCategory(rec);
|
|
122
|
+
if (cat === "lock") cfg.locks[name] = lockView(rec);
|
|
123
|
+
else if (cat === "hub3") cfg.hub3s[name] = hub3View(rec, name);
|
|
124
|
+
// それ以外 (Touch/Face/Sensor 等) は view に出さない (操作対象でない)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
save() {
|
|
129
|
+
if (!this.data) throw new Error("nothing to save (call load() first)");
|
|
130
|
+
this._reproject(); // 書き込み前に view を最新化 (読み手が直後に参照しても整合)
|
|
131
|
+
// 永続化は正準キーのみ (派生 view の locks/hub3s は書かない)
|
|
132
|
+
const persist = {};
|
|
133
|
+
for (const k of PERSISTED_KEYS) if (this.data[k] !== undefined) persist[k] = this.data[k];
|
|
134
|
+
// config.json には ロックの secretKey (32hex 平文) が入るので tokens.json 同様
|
|
135
|
+
// mode 0600 で保存する (Review M-5)。
|
|
136
|
+
// 注: POSIX 専用。Windows では mode は read-only flag になる程度。
|
|
137
|
+
// 既存ディレクトリの mode は変更されない (新規作成時のみ反映) ので、
|
|
138
|
+
// 旧バージョンで作られた場合は手動で `chmod 700 ~/.config/sesame-hub3` が必要。
|
|
139
|
+
mkdirSync(dirname(this.configPath), { recursive: true, mode: 0o700 });
|
|
140
|
+
writeFileSync(this.configPath, JSON.stringify(persist, null, 2) + "\n", { mode: 0o600 });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 空スケルトンを書き出す。既存があれば触らない。 */
|
|
144
|
+
init() {
|
|
145
|
+
if (existsSync(this.configPath)) return false;
|
|
146
|
+
this.data = emptyConfig();
|
|
147
|
+
this.save();
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- ドメイン操作 ----
|
|
152
|
+
|
|
153
|
+
/** name 省略時は default.remote、無ければ remotes が 1 つだけならそれ。 */
|
|
154
|
+
resolveRemote(name) {
|
|
155
|
+
const cfg = this.load();
|
|
156
|
+
const remotes = cfg.remotes || {};
|
|
157
|
+
const names = Object.keys(remotes);
|
|
158
|
+
const chosen =
|
|
159
|
+
name ||
|
|
160
|
+
cfg.default?.remote ||
|
|
161
|
+
(names.length === 1 ? names[0] : null);
|
|
162
|
+
if (!chosen) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`No remote specified and no default. 設定済み: [${names.join(", ") || "(none)"}]`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const remote = remotes[chosen];
|
|
168
|
+
if (!remote) {
|
|
169
|
+
throw new Error(`Unknown remote "${chosen}". 設定済み: [${names.join(", ") || "(none)"}]`);
|
|
170
|
+
}
|
|
171
|
+
const hub3Name = remote.hub3;
|
|
172
|
+
const hub3 = cfg.hub3s?.[hub3Name];
|
|
173
|
+
if (!hub3) {
|
|
174
|
+
throw new Error(`Remote "${chosen}" は hub3 "${hub3Name}" を参照しますが未登録です。`);
|
|
175
|
+
}
|
|
176
|
+
return { name: chosen, remote, hub3Name, hub3 };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
addHub3(name, hub3) {
|
|
180
|
+
const cfg = this.load();
|
|
181
|
+
if (!name) throw new Error("hub3 name required");
|
|
182
|
+
if (!hub3?.deviceId) throw new Error("hub3.deviceId required");
|
|
183
|
+
cfg.devices[name] = {
|
|
184
|
+
deviceUUID: hub3.deviceId,
|
|
185
|
+
deviceName: hub3.name || name,
|
|
186
|
+
deviceModel: hub3.model || "hub_3",
|
|
187
|
+
secretKey: hub3.secretKey || null,
|
|
188
|
+
category: "hub3", // 明示追加 = Hub3 確定
|
|
189
|
+
};
|
|
190
|
+
this.save(); // save() が _reproject して cfg.hub3s view を更新する
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
addRemote(name, remote) {
|
|
194
|
+
const cfg = this.load();
|
|
195
|
+
if (!name) throw new Error("remote name required");
|
|
196
|
+
if (!remote?.hub3) throw new Error("remote.hub3 required");
|
|
197
|
+
if (!cfg.hub3s[remote.hub3]) {
|
|
198
|
+
throw new Error(`hub3 "${remote.hub3}" 未登録。先に hub3 add を実行してください。`);
|
|
199
|
+
}
|
|
200
|
+
cfg.remotes[name] = {
|
|
201
|
+
hub3: remote.hub3,
|
|
202
|
+
irDeviceUUID: remote.irDeviceUUID,
|
|
203
|
+
irType: Number(remote.irType),
|
|
204
|
+
irOperation: remote.irOperation || "learnEmit",
|
|
205
|
+
alias: remote.alias || null,
|
|
206
|
+
keys: remote.keys || {},
|
|
207
|
+
};
|
|
208
|
+
// 初回登録時はデフォルトに設定
|
|
209
|
+
if (!cfg.default.remote) cfg.default.remote = name;
|
|
210
|
+
this.save();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
setDefaultRemote(name) {
|
|
214
|
+
const cfg = this.load();
|
|
215
|
+
if (!cfg.remotes[name]) throw new Error(`Unknown remote "${name}"`);
|
|
216
|
+
cfg.default.remote = name;
|
|
217
|
+
this.save();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
updateRemoteKeys(name, keys) {
|
|
221
|
+
const cfg = this.load();
|
|
222
|
+
const r = cfg.remotes[name];
|
|
223
|
+
if (!r) throw new Error(`Unknown remote "${name}"`);
|
|
224
|
+
r.keys = keys;
|
|
225
|
+
this.save();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- lock ----
|
|
229
|
+
|
|
230
|
+
/** name 省略時は default.lock、無ければ locks が 1 つだけならそれ。 */
|
|
231
|
+
resolveLock(name) {
|
|
232
|
+
const cfg = this.load();
|
|
233
|
+
const locks = cfg.locks || {};
|
|
234
|
+
const names = Object.keys(locks);
|
|
235
|
+
const chosen = name || cfg.default?.lock || (names.length === 1 ? names[0] : null);
|
|
236
|
+
if (!chosen) {
|
|
237
|
+
throw new Error(`No lock specified and no default. 設定済み: [${names.join(", ") || "(none)"}]`);
|
|
238
|
+
}
|
|
239
|
+
const lock = locks[chosen];
|
|
240
|
+
if (!lock) throw new Error(`Unknown lock "${chosen}". 設定済み: [${names.join(", ") || "(none)"}]`);
|
|
241
|
+
return { name: chosen, lock };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
addLock(name, lock) {
|
|
245
|
+
const cfg = this.load();
|
|
246
|
+
if (!name) throw new Error("lock name required");
|
|
247
|
+
if (!lock?.deviceUUID) throw new Error("lock.deviceUUID required");
|
|
248
|
+
if (!lock?.secretKey) throw new Error("lock.secretKey required");
|
|
249
|
+
cfg.devices[name] = {
|
|
250
|
+
deviceUUID: lock.deviceUUID,
|
|
251
|
+
secretKey: lock.secretKey,
|
|
252
|
+
deviceModel: lock.model || null, // 省略時 null (kindForModel(null)→lock5 なので操作は可)
|
|
253
|
+
deviceName: lock.alias || null,
|
|
254
|
+
category: "lock", // 明示追加 = ロック確定 (model が未知/未指定でも view に出す)
|
|
255
|
+
};
|
|
256
|
+
if (!cfg.default.lock) cfg.default.lock = name;
|
|
257
|
+
this.save();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
setDefaultLock(name) {
|
|
261
|
+
const cfg = this.load();
|
|
262
|
+
if (!cfg.locks[name]) throw new Error(`Unknown lock "${name}"`);
|
|
263
|
+
cfg.default.lock = name;
|
|
264
|
+
this.save();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
removeLock(name) {
|
|
268
|
+
const cfg = this.load();
|
|
269
|
+
if (!cfg.locks[name]) throw new Error(`Unknown lock "${name}"`);
|
|
270
|
+
delete cfg.devices[name]; // devices が真実。view (cfg.locks) は save()→_reproject で更新
|
|
271
|
+
if (cfg.default.lock === name) cfg.default.lock = null;
|
|
272
|
+
this.save();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- devices → config 同期 (ドメイン操作) ----
|
|
276
|
+
// device レコードを **取捨選択せず丸ごと** devices{} に格納する共通コア。型ごとの差は
|
|
277
|
+
// accept (受理条件) と prune の保護だけで、保存フィールドの cherry-pick はしない
|
|
278
|
+
// (hub3 で model/secretKey を選び忘れて lock5 に化けた類のバグを構造的に防ぐ)。
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {Array} deviceList
|
|
282
|
+
* @param {{ accept:(d:object)=>boolean, category:"lock"|"hub3", prune?:boolean,
|
|
283
|
+
* onFirstAdd?:(name:string)=>void, pruneProtect?:(name:string)=>boolean }} opts
|
|
284
|
+
* accept 受理条件 (取り込む incoming device の判定)
|
|
285
|
+
* category この sync が司る view。prune はこの view に属する device だけを対象にする
|
|
286
|
+
* @returns {{added:string[], updated:string[], removed:string[]}}
|
|
287
|
+
*/
|
|
288
|
+
_syncDevices(deviceList, { accept, category, prune = false, onFirstAdd = null, pruneProtect = null }) {
|
|
289
|
+
const cfg = this.load();
|
|
290
|
+
const result = { added: [], updated: [], removed: [] };
|
|
291
|
+
const seen = new Set();
|
|
292
|
+
|
|
293
|
+
for (const d of deviceList || []) {
|
|
294
|
+
if (!accept(d)) continue;
|
|
295
|
+
seen.add(normalizeUuid(d.deviceUUID));
|
|
296
|
+
const rec = sanitizeDeviceRecord(d); // 巨大な stateInfo 以外は全フィールド保存
|
|
297
|
+
|
|
298
|
+
const entry = Object.entries(cfg.devices).find(
|
|
299
|
+
([, r]) => normalizeUuid(r.deviceUUID) === normalizeUuid(d.deviceUUID),
|
|
300
|
+
);
|
|
301
|
+
if (entry) {
|
|
302
|
+
const [name, existing] = entry;
|
|
303
|
+
// サーバ応答 (rec) を真実としてフィールドを丸ごと置き換える: サーバ側で消えた
|
|
304
|
+
// フィールドは追従して消す。ただしローカル注釈 (category) だけは引き継ぐ。
|
|
305
|
+
const merged = { ...rec };
|
|
306
|
+
for (const k of LOCAL_ONLY_KEYS) if (existing[k] !== undefined) merged[k] = existing[k];
|
|
307
|
+
// 変更判定はキー順に依存しない正準形で比較 (手動追加→初回 sync で順序差だけの誤検知を防ぐ)。
|
|
308
|
+
if (canonicalize(merged) !== canonicalize(existing)) {
|
|
309
|
+
cfg.devices[name] = merged;
|
|
310
|
+
result.updated.push(name);
|
|
311
|
+
}
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const name = uniqueName(cfg.devices, baseName(d.deviceName, d.deviceUUID));
|
|
316
|
+
cfg.devices[name] = rec;
|
|
317
|
+
if (onFirstAdd) onFirstAdd(name);
|
|
318
|
+
result.added.push(name);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (prune) {
|
|
322
|
+
// prune 対象はこの sync が司る view (category) に属する device だけ。判定は accept(model 依存)
|
|
323
|
+
// ではなく view と同じ effectiveCategory で行う。これにより手動追加 (model 未指定で accept を
|
|
324
|
+
// 通らないロック等) も対称に prune でき、locks の sync が hub3 を消すこともない。
|
|
325
|
+
for (const [name, r] of Object.entries(cfg.devices)) {
|
|
326
|
+
if (effectiveCategory(r) !== category) continue;
|
|
327
|
+
if (seen.has(normalizeUuid(r.deviceUUID))) continue;
|
|
328
|
+
if (pruneProtect && pruneProtect(name)) continue;
|
|
329
|
+
delete cfg.devices[name];
|
|
330
|
+
if (cfg.default.lock === name) cfg.default.lock = null;
|
|
331
|
+
result.removed.push(name);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
this.save();
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* `devices` (getCompanyDevice 等) の結果からロックを取り込む (devices{} に丸ごと格納)。
|
|
341
|
+
* @param {Array} deviceList
|
|
342
|
+
* @param {{prune?:boolean}} [opts]
|
|
343
|
+
* @returns {{added:string[], updated:string[], removed:string[]}}
|
|
344
|
+
*/
|
|
345
|
+
syncLocksFromDevices(deviceList, { prune = false } = {}) {
|
|
346
|
+
return this._syncDevices(deviceList, {
|
|
347
|
+
accept: (d) => isLockModel(d.deviceModel) && !!d.deviceUUID && !!d.secretKey,
|
|
348
|
+
category: "lock",
|
|
349
|
+
prune,
|
|
350
|
+
onFirstAdd: (name) => { if (!this.data.default.lock) this.data.default.lock = name; },
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* `devices` の結果から Hub3 を取り込む (deviceModel が hub_3 / hub_3_lte。devices{} に丸ごと格納)。
|
|
356
|
+
* @param {Array} deviceList
|
|
357
|
+
* @param {{prune?:boolean}} [opts]
|
|
358
|
+
* @returns {{added:string[], updated:string[], removed:string[]}}
|
|
359
|
+
*/
|
|
360
|
+
syncHub3sFromDevices(deviceList, { prune = false } = {}) {
|
|
361
|
+
return this._syncDevices(deviceList, {
|
|
362
|
+
accept: (d) => isHub3Model(d.deviceModel) && !!d.deviceUUID,
|
|
363
|
+
category: "hub3",
|
|
364
|
+
prune,
|
|
365
|
+
pruneProtect: (name) => Object.values(this.data.remotes).some((r) => r.hub3 === name),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* `devices` の応答だけからリモコンを取り込む (引数 irType 不要)。
|
|
371
|
+
*
|
|
372
|
+
* 各 Hub3 デバイスは `stateInfo.remoteList` に配下リモコンを
|
|
373
|
+
* `{uuid, type, alias?}` 付きで持っているので、それを直接展開する。
|
|
374
|
+
* 先に hub3s が登録済みである必要がある (syncHub3sFromDevices を先に呼ぶ)。
|
|
375
|
+
*
|
|
376
|
+
* @param {Array} deviceList getCompanyDevice / getUserDevice の応答
|
|
377
|
+
* @returns {{added:string[], updated:string[]}}
|
|
378
|
+
*/
|
|
379
|
+
syncRemotesFromDevices(deviceList) {
|
|
380
|
+
const cfg = this.load();
|
|
381
|
+
const result = { added: [], updated: [] };
|
|
382
|
+
|
|
383
|
+
// deviceUUID → config 上の hub3 名 の逆引き
|
|
384
|
+
const hub3ByUuid = new Map();
|
|
385
|
+
for (const [name, h] of Object.entries(cfg.hub3s)) {
|
|
386
|
+
hub3ByUuid.set(normalizeUuid(h.deviceId), name);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const d of deviceList || []) {
|
|
390
|
+
if (!isHub3Model(d.deviceModel)) continue;
|
|
391
|
+
const hub3Name = hub3ByUuid.get(normalizeUuid(d.deviceUUID));
|
|
392
|
+
if (!hub3Name) continue; // この Hub3 が未登録ならスキップ (先に hub3 sync)
|
|
393
|
+
const remoteList = d.stateInfo?.remoteList || [];
|
|
394
|
+
|
|
395
|
+
for (const r of remoteList) {
|
|
396
|
+
const irDeviceUUID = r.uuid || r.irDeviceUUID;
|
|
397
|
+
if (!irDeviceUUID) continue;
|
|
398
|
+
const irType = Number(r.type ?? r.irType);
|
|
399
|
+
const alias = r.alias || r.name || null;
|
|
400
|
+
|
|
401
|
+
const entry = Object.entries(cfg.remotes).find(
|
|
402
|
+
([, rm]) => normalizeUuid(rm.irDeviceUUID) === normalizeUuid(irDeviceUUID),
|
|
403
|
+
);
|
|
404
|
+
if (entry) {
|
|
405
|
+
const [existingName, rm] = entry;
|
|
406
|
+
let changed = false;
|
|
407
|
+
if (Number.isFinite(irType) && rm.irType !== irType) { rm.irType = irType; changed = true; }
|
|
408
|
+
if (alias && rm.alias !== alias) { rm.alias = alias; changed = true; }
|
|
409
|
+
if (rm.hub3 !== hub3Name) { rm.hub3 = hub3Name; changed = true; }
|
|
410
|
+
if (changed) result.updated.push(existingName);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const name = uniqueName(cfg.remotes, baseName(alias, irDeviceUUID));
|
|
415
|
+
cfg.remotes[name] = {
|
|
416
|
+
hub3: hub3Name,
|
|
417
|
+
irDeviceUUID,
|
|
418
|
+
irType: Number.isFinite(irType) ? irType : DEFAULT_IR_TYPE,
|
|
419
|
+
irOperation: "learnEmit",
|
|
420
|
+
alias,
|
|
421
|
+
keys: {},
|
|
422
|
+
};
|
|
423
|
+
if (!cfg.default.remote) cfg.default.remote = name;
|
|
424
|
+
result.added.push(name);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
this.save();
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* server 側 (getRemoteList) のリモコン一覧から remote 定義を取り込む (上級/代替経路)。
|
|
434
|
+
* 通常は syncRemotesFromDevices で足りる。company 横断の一覧が欲しい場合のみ。
|
|
435
|
+
* @param {Array} remoteList getRemoteList の応答 (irDeviceUUID/uuid, type, alias/name 等)
|
|
436
|
+
* @param {string} hub3Name これらのリモコンが属する Hub3 の config 名
|
|
437
|
+
* @returns {{added:string[], updated:string[]}}
|
|
438
|
+
*/
|
|
439
|
+
syncRemotesFromServer(remoteList, hub3Name) {
|
|
440
|
+
const cfg = this.load();
|
|
441
|
+
if (!cfg.hub3s[hub3Name]) {
|
|
442
|
+
throw new Error(`hub3 "${hub3Name}" 未登録。先に hub3 sync-from-devices か hub3 add を実行してください。`);
|
|
443
|
+
}
|
|
444
|
+
const result = { added: [], updated: [] };
|
|
445
|
+
|
|
446
|
+
for (const r of remoteList || []) {
|
|
447
|
+
const irDeviceUUID = r.irDeviceUUID || r.uuid;
|
|
448
|
+
if (!irDeviceUUID) continue;
|
|
449
|
+
const irType = Number(r.type ?? r.irType);
|
|
450
|
+
const alias = r.alias || r.name || null;
|
|
451
|
+
|
|
452
|
+
const entry = Object.entries(cfg.remotes).find(
|
|
453
|
+
([, rm]) => normalizeUuid(rm.irDeviceUUID) === normalizeUuid(irDeviceUUID),
|
|
454
|
+
);
|
|
455
|
+
if (entry) {
|
|
456
|
+
const [existingName, rm] = entry;
|
|
457
|
+
let changed = false;
|
|
458
|
+
if (Number.isFinite(irType) && rm.irType !== irType) { rm.irType = irType; changed = true; }
|
|
459
|
+
if (alias && rm.alias !== alias) { rm.alias = alias; changed = true; }
|
|
460
|
+
if (changed) result.updated.push(existingName);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const name = uniqueName(cfg.remotes, baseName(alias, irDeviceUUID));
|
|
465
|
+
cfg.remotes[name] = {
|
|
466
|
+
hub3: hub3Name,
|
|
467
|
+
irDeviceUUID,
|
|
468
|
+
irType: Number.isFinite(irType) ? irType : DEFAULT_IR_TYPE,
|
|
469
|
+
irOperation: r.irOperation || "learnEmit",
|
|
470
|
+
alias,
|
|
471
|
+
keys: {},
|
|
472
|
+
};
|
|
473
|
+
if (!cfg.default.remote) cfg.default.remote = name;
|
|
474
|
+
result.added.push(name);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
this.save();
|
|
478
|
+
return result;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---- model 分類 / 命名ヘルパ (config の同期で共有) ----
|
|
483
|
+
|
|
484
|
+
// biz3 の lockModelDevices ホワイトリスト (gUtils.js:279-294) と完全一致させる。
|
|
485
|
+
// 旧実装は prefix マッチ (sesame_/wm_2/ssmbot_/bot_/bike_) で判定していたが、これは誤り:
|
|
486
|
+
// - sesame_face* / ssm_touch* (顔認証・タッチ = isSesameAccessControlDevice, gUtils.js:261)
|
|
487
|
+
// を誤ってロック扱い、wm_2/bike_1 を誤判定、BLE_Connector_1 を取りこぼしていた。
|
|
488
|
+
// biz3 は「ロック」と「認証機」を別カテゴリとして明示的にリスト管理している。
|
|
489
|
+
const LOCK_MODELS = new Set([
|
|
490
|
+
"sesame_2", "sesame_4", "sesame_5", "sesame_5_pro", "sesame_5_us",
|
|
491
|
+
"bot_2", "bot_3", "ssmbot_1",
|
|
492
|
+
"sesame_6", "sesame_6_pro", "sesame_6_pro_slidingdoor",
|
|
493
|
+
"BLE_Connector_1", "bike_2", "bike_3",
|
|
494
|
+
]);
|
|
495
|
+
|
|
496
|
+
/** ロック系 model か (biz3 lockModelDevices と完全一致, gUtils.js:279-294)。 */
|
|
497
|
+
export function isLockModel(model) {
|
|
498
|
+
return LOCK_MODELS.has(model);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Hub3 系 model か (hub_3 / hub_3_lte)。 */
|
|
502
|
+
export function isHub3Model(model) {
|
|
503
|
+
return model === "hub_3" || model === "hub_3_lte";
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* device レコードを派生 view (lock / hub3) のどちらに出すかの既定分類。
|
|
508
|
+
* device に明示 category が記録されていない場合 (sync 由来など) の fallback。
|
|
509
|
+
* - hub_3/hub_3_lte → "hub3"
|
|
510
|
+
* - lockModelDevices ホワイトリスト → "lock"
|
|
511
|
+
* - model 未指定 (null) → "lock" (機種不明の手動/旧ロック。kindForModel(null)→lock5 と整合)
|
|
512
|
+
* - それ以外 (Touch/Face/Sensor/未知文字列) → null (どの操作 view にも出さない)
|
|
513
|
+
* @param {string|null|undefined} model
|
|
514
|
+
* @returns {"lock"|"hub3"|null}
|
|
515
|
+
*/
|
|
516
|
+
function categoryForModel(model) {
|
|
517
|
+
if (isHub3Model(model)) return "hub3";
|
|
518
|
+
if (isLockModel(model)) return "lock";
|
|
519
|
+
if (model == null) return "lock";
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* device レコードがどの操作 view (lock/hub3) に属するか。明示 category (手動 addLock/addHub3 や
|
|
525
|
+
* 移行で記録) を真実とし、無ければ model から導出する。model 文字列だけでは分類できないケース
|
|
526
|
+
* (機種未指定の手動ロック、未知の model 文字列) を取りこぼさないための単一の判定点。
|
|
527
|
+
* _reproject (view 生成) と prune (対象選定) で共有する。
|
|
528
|
+
* @param {{category?:string, deviceModel?:string|null}} rec
|
|
529
|
+
* @returns {"lock"|"hub3"|null}
|
|
530
|
+
*/
|
|
531
|
+
function effectiveCategory(rec) {
|
|
532
|
+
return rec.category || categoryForModel(rec.deviceModel);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* キー順に依存しない正準 JSON 文字列。オブジェクトのキーを再帰的にソートして直列化する。
|
|
537
|
+
* sync の変更判定で「値は同じだがキー順だけ違う」誤検知を防ぐために使う。
|
|
538
|
+
* @param {*} value
|
|
539
|
+
* @returns {string}
|
|
540
|
+
*/
|
|
541
|
+
function canonicalize(value) {
|
|
542
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
543
|
+
if (Array.isArray(value)) return "[" + value.map(canonicalize).join(",") + "]";
|
|
544
|
+
return "{" + Object.keys(value).sort()
|
|
545
|
+
.map((k) => JSON.stringify(k) + ":" + canonicalize(value[k]))
|
|
546
|
+
.join(",") + "}";
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function normalizeUuid(s) {
|
|
550
|
+
return typeof s === "string" ? s.replace(/-/g, "").toLowerCase() : "";
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* device レコードを config 保存用に整える。フィールドは取捨選択せず**ほぼ丸ごと**残すが、
|
|
555
|
+
* 巨大なネスト (stateInfo の IR remoteList 等) だけは除外する (remotes 側で扱う・config 肥大回避)。
|
|
556
|
+
*/
|
|
557
|
+
function sanitizeDeviceRecord(d) {
|
|
558
|
+
const { stateInfo, ...rest } = d; // eslint-disable-line no-unused-vars
|
|
559
|
+
return { ...rest };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** デバイス名 (or UUID) から config キーの素を作る。 */
|
|
563
|
+
function baseName(displayName, uuid) {
|
|
564
|
+
const src = (displayName || uuid || "device").toString();
|
|
565
|
+
const slug = src.trim().replace(/\s+/g, "_").toLowerCase();
|
|
566
|
+
return slug || "device";
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** existing オブジェクトのキーと衝突しないユニーク名を返す (name, name-2, name-3...)。 */
|
|
570
|
+
function uniqueName(existing, base) {
|
|
571
|
+
if (!existing[base]) return base;
|
|
572
|
+
let i = 2;
|
|
573
|
+
while (existing[`${base}-${i}`]) i++;
|
|
574
|
+
return `${base}-${i}`;
|
|
575
|
+
}
|