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
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// SESAME BLE プロトコル — 純 JS のコア (OS 非依存・ゼロ追加依存)。
|
|
2
|
+
//
|
|
3
|
+
// 移植元 (1:1、行番号は調査仕様書準拠):
|
|
4
|
+
// - ESP32 C 実装: references_esp32/main/sesame/{ssm.c, ssm_cmd.c}, utils/{c_ccm.c, aes-cbc-cmac.c}
|
|
5
|
+
// - Android SDK : references_android/.../ble/{SesameProtocols.kt, SesameBleReceiver.kt},
|
|
6
|
+
// ble/os3/base/{CHSesameOS3.kt, SesameOS3BleCipher.kt}, open/devices/base/CHSesameOS3LockBase.kt
|
|
7
|
+
//
|
|
8
|
+
// 対象は SesameOS3 (SESAME 5 / 5 Pro / Touch 等)。OS2 (SESAME 3/4/Bot) は対象外 (鍵導出・nonce 長が別)。
|
|
9
|
+
//
|
|
10
|
+
// この層は「接続後のバイト列」だけを扱う純関数群。BLE 無線 I/O (scan/connect/write/notify) は
|
|
11
|
+
// transport アダプタの責務で、ここには一切含めない (= どの OS / ライブラリでも動く)。
|
|
12
|
+
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import { Buffer } from "node:buffer";
|
|
15
|
+
import { aesCmac } from "node-aes-cmac";
|
|
16
|
+
import { ITEM_CODES } from "../itemcodes.js";
|
|
17
|
+
|
|
18
|
+
// ---------- 定数 ----------
|
|
19
|
+
|
|
20
|
+
/** GATT (blecent.c:13-15 / SesameProtocols.kt:80-83)。 */
|
|
21
|
+
export const GATT = Object.freeze({
|
|
22
|
+
SERVICE: "fd81",
|
|
23
|
+
WRITE_CHAR: "16860002-a5ae-9856-b6d3-dbb4c676993e", // RX (app→device, Write Without Response)
|
|
24
|
+
NOTIFY_CHAR: "16860003-a5ae-9856-b6d3-dbb4c676993e", // TX (device→app, notify)
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** advertise の company ID (LE 5A 05 = 0x055A)。blecent.c:132 */
|
|
28
|
+
export const COMPANY_ID = 0x055a;
|
|
29
|
+
|
|
30
|
+
/** op_code (candy.h:66-69 / SesameProtocols.kt:55-57)。受信で意味を持つのは response/publish。 */
|
|
31
|
+
export const OP = Object.freeze({
|
|
32
|
+
CREATE: 0x01, READ: 0x02, UPDATE: 0x03, DELETE: 0x04,
|
|
33
|
+
SYNC: 0x05, ASYNC: 0x06, RESPONSE: 0x07, PUBLISH: 0x08,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** item_code。クラウドと共通の正準ソース (src/itemcodes.js) を参照する (重複定義を避ける)。 */
|
|
37
|
+
export const ITEM = ITEM_CODES;
|
|
38
|
+
|
|
39
|
+
/** セグメントの parsing type (candy.h:44-46 / SesameBleReceiver.kt:5)。ヘッダ = (type<<1) | startBit。 */
|
|
40
|
+
export const SEG = Object.freeze({ APPEND_ONLY: 0, PLAINTEXT: 1, CIPHERTEXT: 2 });
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* SESAME OS3 デバイスがコマンド応答 (response 0x07) の先頭バイトで返す結果コード。
|
|
44
|
+
* 出典: 公式 SesameSDK `enum SesameResultCode: UInt8`
|
|
45
|
+
* (references_ios/Sources/SesameSDK/Ble/CHDeviceProtocol.swift:195)。
|
|
46
|
+
* これは **デバイス層 (SesameOS3) の taxonomy** で BLE/WM2 で共通。クラウド (biz3) 経路は
|
|
47
|
+
* この code を surface しないため、利用できるのは BLE 直接経路のみ。
|
|
48
|
+
*/
|
|
49
|
+
export const RESULT = Object.freeze({
|
|
50
|
+
0: "success", 1: "invalidFormat", 2: "notSupported", 3: "resultStorageFail",
|
|
51
|
+
4: "invalidSig", 5: "notFound", 6: "unknown", 7: "busy", 8: "invalidParam", 9: "invalidAction",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** 結果コード → 名前 (未知は unknown(N))。 */
|
|
55
|
+
export function resultName(code) {
|
|
56
|
+
return RESULT[code] || `unknown(${code})`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CCM_TAG_LEN = 4; // candy.h:42
|
|
60
|
+
const MAX_CHUNK_DATA = 19; // 20B パケット - ヘッダ1B (ssm.c:112-127)
|
|
61
|
+
|
|
62
|
+
// ---------- セッション鍵 / login ----------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 既存 secretKey と initial token から CCM セッション鍵 (16B) を導出する。
|
|
66
|
+
* token16 = AES-128-CMAC(secretKey, randomToken) (ssm_cmd.c:43 / CHSesameOS3LockBase.kt:109)
|
|
67
|
+
*
|
|
68
|
+
* @param {string|Buffer} secretKey 16B (32hex)
|
|
69
|
+
* @param {Buffer} token 4B (initial publish のランダム値)
|
|
70
|
+
* @returns {Buffer} 16B セッション鍵
|
|
71
|
+
*/
|
|
72
|
+
export function deriveSessionKey(secretKey, token) {
|
|
73
|
+
const key = Buffer.isBuffer(secretKey) ? secretKey : Buffer.from(secretKey, "hex");
|
|
74
|
+
if (key.length !== 16) throw new Error(`secretKey must be 16 bytes (got ${key.length})`);
|
|
75
|
+
if (!Buffer.isBuffer(token) || token.length !== 4) throw new Error("token must be a 4-byte Buffer");
|
|
76
|
+
const mac = aesCmac(key, token, { returnAsBuffer: true });
|
|
77
|
+
return Buffer.isBuffer(mac) ? mac : Buffer.from(mac, "hex");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* login コマンドの平文ペイロード = [LOGIN(2)] ++ token16[0:4] (ssm_cmd.c:44-45 / CHSesameOS3LockBase.kt:118-120)。
|
|
82
|
+
* PLAINTEXT セグメントで送る。
|
|
83
|
+
* @param {Buffer} token16 deriveSessionKey の戻り
|
|
84
|
+
* @returns {Buffer} 5B
|
|
85
|
+
*/
|
|
86
|
+
export function loginPayload(token16) {
|
|
87
|
+
return Buffer.concat([Buffer.from([ITEM.LOGIN]), token16.subarray(0, 4)]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------- AES-128-CCM (c_ccm.c, ssm.c:80-82/100-104) ----------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* CCM nonce (13B) = count(8B LE) ++ 0x00 ++ token(4B)。
|
|
94
|
+
* (ssm.h:17-21 / SesameOS3BleCipher.kt:13 + sault=0x00++token)
|
|
95
|
+
*/
|
|
96
|
+
function ccmNonce(count, token4) {
|
|
97
|
+
const c = Buffer.alloc(8);
|
|
98
|
+
c.writeBigUInt64LE(BigInt(count));
|
|
99
|
+
return Buffer.concat([c, Buffer.from([0x00]), token4]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const CCM_AAD = Buffer.from([0x00]); // ssm.c:8
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* コマンド平文を CCM 暗号化し、末尾に 4B tag を付けて返す。
|
|
106
|
+
* @param {Buffer} token16 セッション鍵
|
|
107
|
+
* @param {number|bigint} count 送信カウンタ (送信ごと +1)
|
|
108
|
+
* @param {Buffer} token4 initial token
|
|
109
|
+
* @param {Buffer} plaintext 暗号化前フレーム ([item, ...data])
|
|
110
|
+
* @returns {Buffer} ciphertext ++ tag(4B)
|
|
111
|
+
*/
|
|
112
|
+
export function ccmEncrypt(token16, count, token4, plaintext) {
|
|
113
|
+
const iv = ccmNonce(count, token4);
|
|
114
|
+
const c = crypto.createCipheriv("aes-128-ccm", token16, iv, { authTagLength: CCM_TAG_LEN });
|
|
115
|
+
c.setAAD(CCM_AAD, { plaintextLength: plaintext.length });
|
|
116
|
+
const ct = Buffer.concat([c.update(plaintext), c.final()]);
|
|
117
|
+
return Buffer.concat([ct, c.getAuthTag()]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* CCM 復号。入力は ciphertext ++ tag(4B)。tag 不一致なら throw。
|
|
122
|
+
* @param {Buffer} token16
|
|
123
|
+
* @param {number|bigint} count 受信カウンタ (受信ごと +1)
|
|
124
|
+
* @param {Buffer} token4
|
|
125
|
+
* @param {Buffer} ctWithTag ciphertext ++ tag(4B)
|
|
126
|
+
* @returns {Buffer} 復号平文
|
|
127
|
+
*/
|
|
128
|
+
export function ccmDecrypt(token16, count, token4, ctWithTag) {
|
|
129
|
+
if (ctWithTag.length < CCM_TAG_LEN) throw new Error("ciphertext too short (no tag)");
|
|
130
|
+
const iv = ccmNonce(count, token4);
|
|
131
|
+
const ct = ctWithTag.subarray(0, ctWithTag.length - CCM_TAG_LEN);
|
|
132
|
+
const tag = ctWithTag.subarray(ctWithTag.length - CCM_TAG_LEN);
|
|
133
|
+
const d = crypto.createDecipheriv("aes-128-ccm", token16, iv, { authTagLength: CCM_TAG_LEN });
|
|
134
|
+
d.setAAD(CCM_AAD, { plaintextLength: ct.length });
|
|
135
|
+
d.setAuthTag(tag);
|
|
136
|
+
return Buffer.concat([d.update(ct), d.final()]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------- セグメント分割 / 結合 (ssm.c:70-128 / SesameBleReceiver.kt) ----------
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 1 メッセージ (平文 or 暗号文+tag) を 20B パケット列に分割する。
|
|
143
|
+
* 先頭パケットのみ start bit、最終パケットで parsing type を立てる (中間は APPEND_ONLY)。
|
|
144
|
+
* @param {Buffer} payload 送るバイト列 (平文ならフレーム、暗号なら ct++tag)
|
|
145
|
+
* @param {number} parsingType SEG.PLAINTEXT | SEG.CIPHERTEXT
|
|
146
|
+
* @returns {Buffer[]} 各 ≤20B
|
|
147
|
+
*/
|
|
148
|
+
export function splitSegments(payload, parsingType) {
|
|
149
|
+
const packets = [];
|
|
150
|
+
let offset = 0;
|
|
151
|
+
let first = true;
|
|
152
|
+
// payload 長 0 でも 1 パケット (ヘッダのみ) を送る必要がある場合に対応 (do-while 的)
|
|
153
|
+
do {
|
|
154
|
+
const remain = payload.length - offset;
|
|
155
|
+
const isLast = remain <= MAX_CHUNK_DATA;
|
|
156
|
+
const dataLen = isLast ? remain : MAX_CHUNK_DATA;
|
|
157
|
+
let header = isLast ? (parsingType << 1) : (SEG.APPEND_ONLY << 1);
|
|
158
|
+
if (first) header |= 1;
|
|
159
|
+
packets.push(Buffer.concat([Buffer.from([header]), payload.subarray(offset, offset + dataLen)]));
|
|
160
|
+
offset += dataLen;
|
|
161
|
+
first = false;
|
|
162
|
+
} while (offset < payload.length);
|
|
163
|
+
return packets;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 受信セグメントを結合するアセンブラ。feed() で 1 パケットずつ与え、メッセージ完結時に
|
|
168
|
+
* { type, data } を返す (未完なら null)。start bit でバッファをリセット。
|
|
169
|
+
*/
|
|
170
|
+
export class SegmentAssembler {
|
|
171
|
+
constructor() { this._buf = []; }
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {Buffer} packet notify で届いた 1 パケット
|
|
175
|
+
* @returns {{type:number, data:Buffer}|null} 完結時のみ {type, data}
|
|
176
|
+
*/
|
|
177
|
+
feed(packet) {
|
|
178
|
+
if (!packet || packet.length < 1) return null;
|
|
179
|
+
const header = packet[0];
|
|
180
|
+
if (header & 1) this._buf = []; // start bit → リセット (ssm.c:71-73)
|
|
181
|
+
this._buf.push(packet.subarray(1));
|
|
182
|
+
const type = header >> 1;
|
|
183
|
+
if (type === SEG.APPEND_ONLY) return null; // 継続 (ssm.c:76-78)
|
|
184
|
+
const data = Buffer.concat(this._buf);
|
|
185
|
+
this._buf = [];
|
|
186
|
+
return { type, data };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------- フレーム build / parse (ssm.c:85-94 / CHSesameOS3.kt:142-150) ----------
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 送信フレーム = [item_code] ++ data。op_code は送信時付与しない (CHSesameOS3.kt:495-499)。
|
|
194
|
+
* @param {number} itemCode
|
|
195
|
+
* @param {Buffer} [data]
|
|
196
|
+
* @returns {Buffer}
|
|
197
|
+
*/
|
|
198
|
+
export function buildSendFrame(itemCode, data = Buffer.alloc(0)) {
|
|
199
|
+
return Buffer.concat([Buffer.from([itemCode]), data]);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 受信フレーム (復号後) = [op_code][item_code][body...] を分解。
|
|
204
|
+
* response(7) は body=[resultCode][payload...]、publish(8) は body=[payload...] (呼び出し側で解釈)。
|
|
205
|
+
* @param {Buffer} buf
|
|
206
|
+
* @returns {{opCode:number, itemCode:number, body:Buffer}}
|
|
207
|
+
*/
|
|
208
|
+
export function parseRecvFrame(buf) {
|
|
209
|
+
if (buf.length < 2) throw new Error("frame too short");
|
|
210
|
+
return { opCode: buf[0], itemCode: buf[1], body: buf.subarray(2) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------- 各コマンドの data 生成 ----------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* lock/unlock の data = `[0x00, 0x0E] ++ historyTag`、先頭 20B に切詰め (CHDBModel.kt:37-57)。
|
|
217
|
+
* 先頭 2B `0x000E` (BE) は tag type = "Android user BLE UUID" (SesameProtocols.kt:70)。
|
|
218
|
+
*
|
|
219
|
+
* tag 省略時は type のみ (`[0x00,0x0E]`) を送る = SDK の `historytag=null` パスと同じ。
|
|
220
|
+
* tag を渡す場合は **Buffer (バイト列) を渡すこと**。type が UUID を示すため、任意 utf8 文字列を
|
|
221
|
+
* 入れると型と中身が不整合になる (操作ログ用途であり実害は小さいが、SDK 準拠なら bytes)。
|
|
222
|
+
*
|
|
223
|
+
* @param {Buffer|Uint8Array} [tag] 操作ログ用タグ (バイト列)。省略可。
|
|
224
|
+
* @returns {Buffer}
|
|
225
|
+
*/
|
|
226
|
+
export function historyTagBLE(tag) {
|
|
227
|
+
let tagBuf;
|
|
228
|
+
if (tag == null) tagBuf = Buffer.alloc(0);
|
|
229
|
+
else if (Buffer.isBuffer(tag) || tag instanceof Uint8Array) tagBuf = Buffer.from(tag);
|
|
230
|
+
else throw new Error("historyTagBLE: tag は Buffer/Uint8Array で渡してください (type 0x000E は UUID バイト列を想定)");
|
|
231
|
+
return Buffer.concat([Buffer.from([0x00, 0x0e]), tagBuf]).subarray(0, 20);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* autolock の data = 2B LE 秒数 (delay.toShort().toReverseBytes()、CHSesame5Device.kt:96-105)。0=無効。
|
|
236
|
+
* @param {number} seconds 0..65535
|
|
237
|
+
* @returns {Buffer} 2B
|
|
238
|
+
*/
|
|
239
|
+
export function autolockData(seconds) {
|
|
240
|
+
if (!Number.isInteger(seconds) || seconds < 0 || seconds > 0xffff) {
|
|
241
|
+
throw new Error("seconds must be an integer 0..65535 (0 = disable)");
|
|
242
|
+
}
|
|
243
|
+
const b = Buffer.alloc(2);
|
|
244
|
+
b.writeUInt16LE(seconds);
|
|
245
|
+
return b;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------- mechStatus 解析 (ssm.h:29-40, ssm.c:33-39) ----------
|
|
249
|
+
|
|
250
|
+
/** ロック状態。SESAME 5 (OS3) は施錠範囲フラグの有無の 2 値 (中間 "moved" は無い)。 */
|
|
251
|
+
export const MECH_STATE = Object.freeze({ LOCKED: "locked", UNLOCKED: "unlocked" });
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* mech_status を OS3 デバイスの種別に応じて解析する。
|
|
255
|
+
*
|
|
256
|
+
* SDK は publish payload の **長さ** で具象 MechStatus クラスを選ぶ (CHSesame5Device.kt:213-218,
|
|
257
|
+
* CHSesameBot2Device.kt:245-248)。それに倣い長さで分岐する:
|
|
258
|
+
*
|
|
259
|
+
* 7B = CHSesame5MechStatus (Sesame5/6 系ロック)
|
|
260
|
+
* data[0..1]: 電池電圧 ADC 生値 (LE。換算式は本体に無くサーバ側 → ここでは batteryRaw として返すのみ)
|
|
261
|
+
* data[2..3]: target (i16 LE、-32768 は「未設定」→ null)
|
|
262
|
+
* data[4..5]: position (i16 LE)
|
|
263
|
+
* data[6] : flags — bit1 isInLockRange / bit3 critical / bit4 stop / bit5 batteryCritical
|
|
264
|
+
* 3B = CHSesameBot2MechStatus / CHSesameBike2MechStatus (Bot2/Bot3/Bike2/Bike3)
|
|
265
|
+
* data[0..1]: 電池電圧 ADC 生値 (LE)
|
|
266
|
+
* data[2] : flags — bit1 isInLockRange / bit2 stop
|
|
267
|
+
* position/target の概念なし (null)
|
|
268
|
+
*
|
|
269
|
+
* 施錠/解錠は **isInLockRange の有無のみ** で判定する。OS3 に unlock-range ビットも中間 (moved) も無い
|
|
270
|
+
* (CHSesame5.kt:24-32 / CHSesameBot2.kt:123-126: isInUnlockRange = !isInLockRange)。
|
|
271
|
+
*
|
|
272
|
+
* @param {Buffer} buf 3B (bot/bike) または 7B 以上 (lock)
|
|
273
|
+
* @returns {{state:string, isInLockRange:boolean, target:number|null, position:number|null,
|
|
274
|
+
* isStop:boolean, isCritical:boolean, isBatteryCritical:boolean, batteryRaw:number, flags:number}}
|
|
275
|
+
*/
|
|
276
|
+
export function parseMechStatus(buf) {
|
|
277
|
+
if (!Buffer.isBuffer(buf)) throw new Error("mechStatus must be a Buffer");
|
|
278
|
+
if (buf.length === 3) return parseMechStatusBot(buf);
|
|
279
|
+
if (buf.length >= 7) return parseMechStatusLock(buf);
|
|
280
|
+
throw new Error(`mechStatus は 3B (bot/bike) か 7B 以上 (lock) を想定 (got ${buf.length}B)`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 7B: CHSesame5MechStatus 準拠 (Sesame5/6)。 */
|
|
284
|
+
function parseMechStatusLock(buf) {
|
|
285
|
+
const batteryRaw = buf.readUInt16LE(0);
|
|
286
|
+
const target = buf.readInt16LE(2);
|
|
287
|
+
const position = buf.readInt16LE(4);
|
|
288
|
+
const flags = buf[6];
|
|
289
|
+
const isInLockRange = !!(flags & 0b0000_0010); // flags and 2
|
|
290
|
+
return {
|
|
291
|
+
state: isInLockRange ? MECH_STATE.LOCKED : MECH_STATE.UNLOCKED,
|
|
292
|
+
isInLockRange,
|
|
293
|
+
target: target === -32768 ? null : target,
|
|
294
|
+
position,
|
|
295
|
+
isCritical: !!(flags & 0b0000_1000), // flags and 8
|
|
296
|
+
isStop: !!(flags & 0b0001_0000), // flags and 16
|
|
297
|
+
isBatteryCritical: !!(flags & 0b0010_0000), // flags and 32
|
|
298
|
+
batteryRaw,
|
|
299
|
+
flags,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 3B: CHSesameBot2MechStatus / CHSesameBike2MechStatus 準拠 (Bot2/Bot3/Bike2/Bike3)。 */
|
|
304
|
+
function parseMechStatusBot(buf) {
|
|
305
|
+
const batteryRaw = buf.readUInt16LE(0);
|
|
306
|
+
const flags = buf[2];
|
|
307
|
+
const isInLockRange = !!(flags & 0b0000_0010); // flags and 2
|
|
308
|
+
return {
|
|
309
|
+
state: isInLockRange ? MECH_STATE.LOCKED : MECH_STATE.UNLOCKED,
|
|
310
|
+
isInLockRange,
|
|
311
|
+
target: null,
|
|
312
|
+
position: null,
|
|
313
|
+
isCritical: false,
|
|
314
|
+
isStop: !!(flags & 0b0000_0100), // flags and 4
|
|
315
|
+
isBatteryCritical: false,
|
|
316
|
+
batteryRaw,
|
|
317
|
+
flags,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// SESAME BLE セッション状態機械 (OS 非依存)。
|
|
2
|
+
//
|
|
3
|
+
// 接続後のバイト列のやり取りだけを担い、無線 I/O は注入された transport に委譲する
|
|
4
|
+
// (= mock transport でハードウェア無しにテスト可能)。
|
|
5
|
+
//
|
|
6
|
+
// フロー (調査仕様 §C/D/E/F、ssm.c / CHSesameOS3LockBase.kt 準拠):
|
|
7
|
+
// connect → transport.connect(onPacket)
|
|
8
|
+
// → device が publish(8)+initial(14)+token4 を送る
|
|
9
|
+
// → counters=0、session鍵=CMAC(secretKey, token)、login(2) を PLAINTEXT で送信
|
|
10
|
+
// → device が response(7)+login(2)+resultCode を返す → resultCode==0 で接続完了
|
|
11
|
+
// request(item, data) → frame を CCM 暗号化 (encCount++) → セグメント送信
|
|
12
|
+
// → response(7)+item を待って {resultCode, payload} で解決
|
|
13
|
+
// publish(8)+mechStatus(81) は onStatus リスナへ。
|
|
14
|
+
|
|
15
|
+
import { Buffer } from "node:buffer";
|
|
16
|
+
import {
|
|
17
|
+
deriveSessionKey, loginPayload, ccmEncrypt, ccmDecrypt,
|
|
18
|
+
splitSegments, SegmentAssembler, buildSendFrame, parseRecvFrame,
|
|
19
|
+
parseMechStatus, OP, ITEM, SEG, resultName,
|
|
20
|
+
} from "./protocol.js";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
23
|
+
const LOGIN_TIMEOUT_MS = 8_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* BLE デバイスが非 0 の resultCode を返したときのエラー。
|
|
27
|
+
* `resultName` (notFound/busy/invalidSig…) で機械的に分岐できる (SesameResultCode 由来)。
|
|
28
|
+
*/
|
|
29
|
+
export class BleResultError extends Error {
|
|
30
|
+
/** @param {"login"|"command"} phase @param {number} resultCode @param {number|null} itemCode */
|
|
31
|
+
constructor(phase, resultCode, itemCode = null) {
|
|
32
|
+
const name = resultName(resultCode);
|
|
33
|
+
// 紛らわしいコードに一言ヒント (エラーそのものに載るので別 docs を読む必要がない)。
|
|
34
|
+
const hint = { invalidSig: " (secretKey 不一致?)", notFound: " (op 非対応 or 内部リソース無し)", busy: " (デバイスが他操作中)" }[name] || "";
|
|
35
|
+
super(`BLE ${phase} failed: ${name}${hint} (resultCode=${resultCode}${itemCode != null ? `, item=${itemCode}` : ""})`);
|
|
36
|
+
this.name = "BleResultError";
|
|
37
|
+
this.resultCode = resultCode;
|
|
38
|
+
this.resultName = name;
|
|
39
|
+
this.itemCode = itemCode;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {object} BleTransport BLE 無線 I/O アダプタ (transport.js のアダプタが満たす契約)。
|
|
45
|
+
* @property {(onPacket:(packet:Buffer)=>void)=>Promise<void>} connect 接続+notify購読。各 notify を onPacket へ。
|
|
46
|
+
* @property {(bytes:Buffer)=>void|Promise<void>} write Write Without Response。
|
|
47
|
+
* @property {()=>void|Promise<void>} disconnect 切断。
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
export class SesameBleSession {
|
|
51
|
+
/**
|
|
52
|
+
* @param {{transport:BleTransport, secretKey:string|Buffer, debug?:boolean,
|
|
53
|
+
* defaultTimeoutMs?:number}} opts
|
|
54
|
+
*/
|
|
55
|
+
constructor({ transport, secretKey, debug = false, defaultTimeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
56
|
+
if (!transport) throw new Error("transport required");
|
|
57
|
+
if (!secretKey) throw new Error("secretKey required");
|
|
58
|
+
this._transport = transport;
|
|
59
|
+
this._secretKey = Buffer.isBuffer(secretKey) ? secretKey : Buffer.from(secretKey, "hex");
|
|
60
|
+
this._debug = debug;
|
|
61
|
+
this._defaultTimeoutMs = defaultTimeoutMs;
|
|
62
|
+
|
|
63
|
+
this._asm = new SegmentAssembler();
|
|
64
|
+
this._token = null; // 4B initial token
|
|
65
|
+
this._key = null; // 16B session key
|
|
66
|
+
this._encCount = 0;
|
|
67
|
+
this._decCount = 0;
|
|
68
|
+
this._loggedIn = false;
|
|
69
|
+
|
|
70
|
+
/** @type {Map<number, Array<{resolve:Function, reject:Function, timer:any}>>} item → FIFO */
|
|
71
|
+
this._pending = new Map();
|
|
72
|
+
this._statusListeners = new Set();
|
|
73
|
+
this._publishListeners = new Set();
|
|
74
|
+
this._lastStatus = null;
|
|
75
|
+
this._loginWaiter = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_log(...a) { if (this._debug) console.error("[ble]", ...a); }
|
|
79
|
+
|
|
80
|
+
/** 最後に受信した mechStatus (parseMechStatus の結果)。未受信なら null。 */
|
|
81
|
+
get lastStatus() { return this._lastStatus; }
|
|
82
|
+
get isLoggedIn() { return this._loggedIn; }
|
|
83
|
+
|
|
84
|
+
/** mechStatus publish を購読。戻り値 unsubscribe。 */
|
|
85
|
+
onStatus(fn) { this._statusListeners.add(fn); return () => this._statusListeners.delete(fn); }
|
|
86
|
+
/** 任意 publish を購読 ({opCode,itemCode,body})。戻り値 unsubscribe。 */
|
|
87
|
+
onPublish(fn) { this._publishListeners.add(fn); return () => this._publishListeners.delete(fn); }
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 接続して login まで完了させる。
|
|
91
|
+
* @returns {Promise<void>} login 成功で resolve
|
|
92
|
+
*/
|
|
93
|
+
async connect() {
|
|
94
|
+
const loginPromise = new Promise((resolve, reject) => {
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
this._loginWaiter = null;
|
|
97
|
+
reject(new Error("BLE login timeout (initial/login 応答なし)"));
|
|
98
|
+
}, LOGIN_TIMEOUT_MS);
|
|
99
|
+
this._loginWaiter = { resolve, reject, timer };
|
|
100
|
+
});
|
|
101
|
+
await this._transport.connect((packet) => this._onPacket(packet));
|
|
102
|
+
return loginPromise;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async disconnect() {
|
|
106
|
+
// pending を全て reject してリーク防止
|
|
107
|
+
for (const [, queue] of this._pending) {
|
|
108
|
+
for (const p of queue) { clearTimeout(p.timer); p.reject(new Error("BLE disconnected")); }
|
|
109
|
+
}
|
|
110
|
+
this._pending.clear();
|
|
111
|
+
if (this._loginWaiter) { clearTimeout(this._loginWaiter.timer); this._loginWaiter = null; }
|
|
112
|
+
await this._transport.disconnect();
|
|
113
|
+
this._loggedIn = false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 暗号化コマンドを送り、response(7)+item を待って返す。
|
|
118
|
+
* @param {number} itemCode
|
|
119
|
+
* @param {Buffer} [data]
|
|
120
|
+
* @param {{timeoutMs?:number}} [opts]
|
|
121
|
+
* @returns {Promise<{resultCode:number, payload:Buffer}>}
|
|
122
|
+
*/
|
|
123
|
+
request(itemCode, data = Buffer.alloc(0), { timeoutMs } = {}) {
|
|
124
|
+
if (!this._loggedIn) return Promise.reject(new Error("not logged in (connect() を先に)"));
|
|
125
|
+
const to = timeoutMs ?? this._defaultTimeoutMs;
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
this._dequeue(itemCode, entry);
|
|
129
|
+
reject(new Error(`BLE request timeout (item=${itemCode})`));
|
|
130
|
+
}, to);
|
|
131
|
+
const entry = { resolve, reject, timer };
|
|
132
|
+
if (!this._pending.has(itemCode)) this._pending.set(itemCode, []);
|
|
133
|
+
this._pending.get(itemCode).push(entry);
|
|
134
|
+
// 送信は subscribe 登録後に (race 防止)
|
|
135
|
+
this._sendCipher(buildSendFrame(itemCode, data));
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 暗号化なしで item+data を送る (login 等のハンドシェイク用低レベル)。 */
|
|
140
|
+
_sendPlain(frame) {
|
|
141
|
+
for (const seg of splitSegments(frame, SEG.PLAINTEXT)) this._transport.write(seg);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** CCM 暗号化して送る (encCount++)。 */
|
|
145
|
+
_sendCipher(frame) {
|
|
146
|
+
const ct = ccmEncrypt(this._key, this._encCount, this._token, frame);
|
|
147
|
+
this._encCount += 1;
|
|
148
|
+
for (const seg of splitSegments(ct, SEG.CIPHERTEXT)) this._transport.write(seg);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
_dequeue(itemCode, entry) {
|
|
152
|
+
const queue = this._pending.get(itemCode);
|
|
153
|
+
if (!queue) return;
|
|
154
|
+
const i = queue.indexOf(entry);
|
|
155
|
+
if (i >= 0) queue.splice(i, 1);
|
|
156
|
+
if (queue.length === 0) this._pending.delete(itemCode);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------- 受信 ----------
|
|
160
|
+
|
|
161
|
+
_onPacket(packet) {
|
|
162
|
+
let assembled;
|
|
163
|
+
try { assembled = this._asm.feed(Buffer.isBuffer(packet) ? packet : Buffer.from(packet)); }
|
|
164
|
+
catch (e) { this._log("assemble error", e); return; }
|
|
165
|
+
if (!assembled) return; // 未完
|
|
166
|
+
|
|
167
|
+
let frame;
|
|
168
|
+
if (assembled.type === SEG.CIPHERTEXT) {
|
|
169
|
+
try {
|
|
170
|
+
frame = ccmDecrypt(this._key, this._decCount, this._token, assembled.data);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
// 復号失敗 = カウンタずれ or 破損。デバイス側が当該メッセージで enc カウンタを進めたかは
|
|
173
|
+
// 判別不能なため _decCount は進めない (進めると正常時にずれる)。以降ずれ続けるが、これは
|
|
174
|
+
// 異常系であり、回復は再接続 (initial で両カウンタ 0 リセット) に委ねる。
|
|
175
|
+
this._log("decrypt failed (count desync / corruption)", e?.message);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this._decCount += 1;
|
|
179
|
+
} else {
|
|
180
|
+
frame = assembled.data; // PLAINTEXT (initial / login 応答が平文の場合)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let parsed;
|
|
184
|
+
try { parsed = parseRecvFrame(frame); }
|
|
185
|
+
catch (e) { this._log("parse error", e?.message); return; }
|
|
186
|
+
const { opCode, itemCode, body } = parsed;
|
|
187
|
+
this._log("recv", { opCode, itemCode, len: body.length });
|
|
188
|
+
|
|
189
|
+
if (opCode === OP.PUBLISH) {
|
|
190
|
+
if (itemCode === ITEM.INITIAL) { this._handleInitial(body); return; }
|
|
191
|
+
if (itemCode === ITEM.MECH_STATUS) {
|
|
192
|
+
try { this._lastStatus = parseMechStatus(body); } catch { /* ignore */ }
|
|
193
|
+
for (const fn of [...this._statusListeners]) { try { fn(this._lastStatus); } catch { /* ignore */ } }
|
|
194
|
+
}
|
|
195
|
+
for (const fn of [...this._publishListeners]) { try { fn({ opCode, itemCode, body }); } catch { /* ignore */ } }
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (opCode === OP.RESPONSE) {
|
|
200
|
+
const resultCode = body.length > 0 ? body[0] : 0;
|
|
201
|
+
const payload = body.subarray(1);
|
|
202
|
+
if (itemCode === ITEM.LOGIN) { this._handleLoginResponse(resultCode); return; }
|
|
203
|
+
this._resolvePending(itemCode, resultCode, payload);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_handleInitial(token) {
|
|
208
|
+
if (!token || token.length < 4) { this._log("initial token too short"); return; }
|
|
209
|
+
this._token = Buffer.from(token.subarray(0, 4));
|
|
210
|
+
this._encCount = 0;
|
|
211
|
+
this._decCount = 0;
|
|
212
|
+
this._key = deriveSessionKey(this._secretKey, this._token);
|
|
213
|
+
this._log("initial token received, sending login");
|
|
214
|
+
this._sendPlain(loginPayload(this._key)); // login は PLAINTEXT
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_handleLoginResponse(resultCode) {
|
|
218
|
+
if (!this._loginWaiter) return;
|
|
219
|
+
const w = this._loginWaiter;
|
|
220
|
+
this._loginWaiter = null;
|
|
221
|
+
clearTimeout(w.timer);
|
|
222
|
+
if (resultCode === 0) { this._loggedIn = true; w.resolve(); }
|
|
223
|
+
else w.reject(new BleResultError("login", resultCode));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_resolvePending(itemCode, resultCode, payload) {
|
|
227
|
+
const queue = this._pending.get(itemCode);
|
|
228
|
+
if (!queue || queue.length === 0) return; // 対応する request なし (unsolicited)
|
|
229
|
+
const entry = queue.shift();
|
|
230
|
+
if (queue.length === 0) this._pending.delete(itemCode);
|
|
231
|
+
clearTimeout(entry.timer);
|
|
232
|
+
if (resultCode === 0) entry.resolve({ resultCode, payload });
|
|
233
|
+
else entry.reject(new BleResultError("command", resultCode, itemCode));
|
|
234
|
+
}
|
|
235
|
+
}
|