sesame-kit 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +26 -0
  2. package/LICENSE.biz3 +21 -0
  3. package/README.ja.md +225 -0
  4. package/README.md +222 -0
  5. package/bin/sesame.js +8 -0
  6. package/clients/js/sesame-client.mjs +208 -0
  7. package/clients/python/pyproject.toml +5 -0
  8. package/clients/python/sesame_client.py +323 -0
  9. package/clients/python/setup.cfg +11 -0
  10. package/docs/architecture.ja.md +132 -0
  11. package/docs/architecture.md +105 -0
  12. package/docs/commands.ja.md +316 -0
  13. package/docs/commands.md +308 -0
  14. package/docs/library.ja.md +152 -0
  15. package/docs/library.md +152 -0
  16. package/docs/migration.ja.md +13 -0
  17. package/docs/migration.md +13 -0
  18. package/package.json +114 -0
  19. package/src/access.js +375 -0
  20. package/src/account.js +36 -0
  21. package/src/auth.js +248 -0
  22. package/src/ble/devicemodel.js +164 -0
  23. package/src/ble/index.js +185 -0
  24. package/src/ble/protocol.js +319 -0
  25. package/src/ble/session.js +235 -0
  26. package/src/ble/transport.js +279 -0
  27. package/src/cli/access.js +373 -0
  28. package/src/cli/company.js +104 -0
  29. package/src/cli/iot.js +400 -0
  30. package/src/cli/org.js +788 -0
  31. package/src/cli/presetir.js +188 -0
  32. package/src/cli/schedule.js +83 -0
  33. package/src/cli/serve.js +308 -0
  34. package/src/cli.js +1815 -0
  35. package/src/client.js +957 -0
  36. package/src/company.js +147 -0
  37. package/src/config.js +575 -0
  38. package/src/crypto.js +162 -0
  39. package/src/devices.js +228 -0
  40. package/src/index.js +55 -0
  41. package/src/iot.js +513 -0
  42. package/src/ir.js +341 -0
  43. package/src/itemcodes.js +29 -0
  44. package/src/lock.js +194 -0
  45. package/src/org.js +803 -0
  46. package/src/paths.js +30 -0
  47. package/src/presetir.js +525 -0
  48. package/src/prompts.js +74 -0
  49. package/src/schedule.js +108 -0
  50. package/src/serve/daemon.js +251 -0
  51. package/src/serve/framing/grpc.js +145 -0
  52. package/src/serve/framing/http.js +144 -0
  53. package/src/serve/framing/ndjson.js +75 -0
  54. package/src/serve/framing/socket.js +73 -0
  55. package/src/serve/framing/stdio.js +28 -0
  56. package/src/serve/framing/token.js +36 -0
  57. package/src/serve/framing/ws.js +56 -0
  58. package/src/serve/grpc-methods.generated.json +378 -0
  59. package/src/serve/jsonrpc.js +164 -0
  60. package/src/serve/registry.js +226 -0
  61. package/src/serve/rpc-params.generated.json +1746 -0
  62. package/src/serve/sesame.proto +470 -0
  63. package/src/session-ui.js +181 -0
  64. package/src/sharekey.js +130 -0
  65. package/src/tokens.js +53 -0
  66. package/src/transport.js +634 -0
  67. package/src/util.js +26 -0
  68. package/types/access.d.ts +193 -0
  69. package/types/access.d.ts.map +1 -0
  70. package/types/account.d.ts +13 -0
  71. package/types/account.d.ts.map +1 -0
  72. package/types/auth.d.ts +80 -0
  73. package/types/auth.d.ts.map +1 -0
  74. package/types/ble/devicemodel.d.ts +212 -0
  75. package/types/ble/devicemodel.d.ts.map +1 -0
  76. package/types/ble/index.d.ts +160 -0
  77. package/types/ble/index.d.ts.map +1 -0
  78. package/types/ble/protocol.d.ts +201 -0
  79. package/types/ble/protocol.d.ts.map +1 -0
  80. package/types/ble/session.d.ts +129 -0
  81. package/types/ble/session.d.ts.map +1 -0
  82. package/types/ble/transport.d.ts +67 -0
  83. package/types/ble/transport.d.ts.map +1 -0
  84. package/types/cli/access.d.ts +6 -0
  85. package/types/cli/access.d.ts.map +1 -0
  86. package/types/cli/company.d.ts +6 -0
  87. package/types/cli/company.d.ts.map +1 -0
  88. package/types/cli/iot.d.ts +6 -0
  89. package/types/cli/iot.d.ts.map +1 -0
  90. package/types/cli/org.d.ts +6 -0
  91. package/types/cli/org.d.ts.map +1 -0
  92. package/types/cli/presetir.d.ts +6 -0
  93. package/types/cli/presetir.d.ts.map +1 -0
  94. package/types/cli/schedule.d.ts +6 -0
  95. package/types/cli/schedule.d.ts.map +1 -0
  96. package/types/cli/serve.d.ts +2 -0
  97. package/types/cli/serve.d.ts.map +1 -0
  98. package/types/cli.d.ts +2 -0
  99. package/types/cli.d.ts.map +1 -0
  100. package/types/client.d.ts +463 -0
  101. package/types/client.d.ts.map +1 -0
  102. package/types/company.d.ts +94 -0
  103. package/types/company.d.ts.map +1 -0
  104. package/types/config.d.ts +111 -0
  105. package/types/config.d.ts.map +1 -0
  106. package/types/crypto.d.ts +61 -0
  107. package/types/crypto.d.ts.map +1 -0
  108. package/types/devices.d.ts +116 -0
  109. package/types/devices.d.ts.map +1 -0
  110. package/types/index.d.ts +23 -0
  111. package/types/index.d.ts.map +1 -0
  112. package/types/iot.d.ts +312 -0
  113. package/types/iot.d.ts.map +1 -0
  114. package/types/ir.d.ts +147 -0
  115. package/types/ir.d.ts.map +1 -0
  116. package/types/itemcodes.d.ts +21 -0
  117. package/types/itemcodes.d.ts.map +1 -0
  118. package/types/lock.d.ts +89 -0
  119. package/types/lock.d.ts.map +1 -0
  120. package/types/org.d.ts +468 -0
  121. package/types/org.d.ts.map +1 -0
  122. package/types/paths.d.ts +10 -0
  123. package/types/paths.d.ts.map +1 -0
  124. package/types/presetir.d.ts +286 -0
  125. package/types/presetir.d.ts.map +1 -0
  126. package/types/prompts.d.ts +39 -0
  127. package/types/prompts.d.ts.map +1 -0
  128. package/types/schedule.d.ts +71 -0
  129. package/types/schedule.d.ts.map +1 -0
  130. package/types/serve/daemon.d.ts +133 -0
  131. package/types/serve/daemon.d.ts.map +1 -0
  132. package/types/serve/framing/grpc.d.ts +14 -0
  133. package/types/serve/framing/grpc.d.ts.map +1 -0
  134. package/types/serve/framing/http.d.ts +14 -0
  135. package/types/serve/framing/http.d.ts.map +1 -0
  136. package/types/serve/framing/ndjson.d.ts +19 -0
  137. package/types/serve/framing/ndjson.d.ts.map +1 -0
  138. package/types/serve/framing/socket.d.ts +14 -0
  139. package/types/serve/framing/socket.d.ts.map +1 -0
  140. package/types/serve/framing/stdio.d.ts +11 -0
  141. package/types/serve/framing/stdio.d.ts.map +1 -0
  142. package/types/serve/framing/token.d.ts +11 -0
  143. package/types/serve/framing/token.d.ts.map +1 -0
  144. package/types/serve/framing/ws.d.ts +13 -0
  145. package/types/serve/framing/ws.d.ts.map +1 -0
  146. package/types/serve/jsonrpc.d.ts +118 -0
  147. package/types/serve/jsonrpc.d.ts.map +1 -0
  148. package/types/serve/registry.d.ts +41 -0
  149. package/types/serve/registry.d.ts.map +1 -0
  150. package/types/session-ui.d.ts +36 -0
  151. package/types/session-ui.d.ts.map +1 -0
  152. package/types/sharekey.d.ts +35 -0
  153. package/types/sharekey.d.ts.map +1 -0
  154. package/types/tokens.d.ts +20 -0
  155. package/types/tokens.d.ts.map +1 -0
  156. package/types/transport.d.ts +138 -0
  157. package/types/transport.d.ts.map +1 -0
  158. package/types/util.d.ts +20 -0
  159. package/types/util.d.ts.map +1 -0
  160. package/vendor/biz3/README.md +37 -0
  161. package/vendor/biz3/constants/cmdCode.d.ts +48 -0
  162. package/vendor/biz3/constants/cmdCode.d.ts.map +1 -0
  163. package/vendor/biz3/constants/cmdCode.js +92 -0
  164. package/vendor/biz3/constants/messageConstants.d.ts +28 -0
  165. package/vendor/biz3/constants/messageConstants.d.ts.map +1 -0
  166. package/vendor/biz3/constants/messageConstants.js +30 -0
  167. package/vendor/biz3/constants/sesameDeviceModel.d.ts +75 -0
  168. package/vendor/biz3/constants/sesameDeviceModel.d.ts.map +1 -0
  169. package/vendor/biz3/constants/sesameDeviceModel.js +77 -0
  170. package/vendor/biz3/package.json +5 -0
@@ -0,0 +1,188 @@
1
+ // `sesame preset-ir …` コマンド群。
2
+ //
3
+ // 本体ロジックは src/presetir.js (HXDCommandProcessor 移植 + emitAir/emitButton/sendIR)。
4
+ // ここは commander への配線と入出力整形のみを担う。
5
+ //
6
+ // プリセット IR は biz3 の 2 層構造を再現する:
7
+ // 1. HXDCommandProcessor がローカルで 16 byte の HEX command を生成 (純ビルダ)。
8
+ // 2. その HEX を既存 sendIR (op:'sendIR', operation:'remoteEmit') で Hub3 に発射。
9
+ // presetIR 固有の新 WS op は無い (sendIR は learnIR 等と完全共通)。
10
+ //
11
+ // ⚠️ irType (= remote.type) は実値をそのまま渡す:
12
+ // 0xC000=エアコン, 0x8000=扇風機, 0xE000=ライト, 0x2000=TV (本体 IR_TYPE 参照)。
13
+ // air は irType を本体側で IR_TYPE.AIR に固定するため CLI からは渡さない。
14
+ // button/send は --irtype で実値を渡す。
15
+ //
16
+ // ⚠️ getAirKey の keyMap トラップ (本体 JSDoc 参照): air の状態 (温度/モード/風速/風向/電源)
17
+ // は buildAirCommand が buf[4..10] に直接書き込むため key 値は発射動作にほぼ影響しない。
18
+ // 本 CLI は keyType を渡さず本体既定 (0x01) に委ねる。
19
+ //
20
+ // hub.presetir.* で呼べるのは NAMESPACE_OPS = [sendIR, emitAir, emitButton] のみ。
21
+ // 純ビルダ (buildAirCommandHex 等) は namespace に無い (本 CLI では未使用)。
22
+ //
23
+ // ctx 契約 (cli.js makeCtx が供給):
24
+ // ctx.withHub(fn) : connect → fn(hub, {opts}) → close。hub.presetir.* は
25
+ // companyID/subUUID を自動注入する namespace (companyID 注入済みなので
26
+ // companyID 必須の emitAir/emitButton/sendIR もそのまま呼べる)。
27
+ // ctx.out(json, humanFn, jsonObj) : --json 時は jsonObj、それ以外は humanFn()。
28
+ // ctx.die(msg, code) / ctx.canPrompt() / ctx.prompts (selectFromList 等)。
29
+
30
+ import { isHub3Model } from "../config.js";
31
+
32
+ /**
33
+ * commander の option coerce 用 parseInt ラッパ。
34
+ * 未指定 (undefined/null) は undefined を返す。値はあるが数値でない場合 ('--code abc' 等) は
35
+ * throw して「未指定」と区別する (放置すると undefined になり『必要です』と誤誘導されるため)。
36
+ * throw された Error は run() の parseAsync catch が die(1) で表示する。
37
+ */
38
+ function toInt(v) {
39
+ if (v == null) return undefined;
40
+ const n = parseInt(v, 10);
41
+ if (Number.isNaN(n)) {
42
+ throw new Error(`数値を指定してください (受け取った値: ${JSON.stringify(v)})`);
43
+ }
44
+ return n;
45
+ }
46
+
47
+ /**
48
+ * --device 未指定時の Hub3 UUID 解決。
49
+ * 対話可能なら listDevices() の Hub3 (deviceModel が hub_3/hub_3_lte) から選択、
50
+ * 不可なら die(...,2) で必須を案内する (schedule.js と同じ作法)。
51
+ * @param {object} hub
52
+ * @param {object} ctx
53
+ * @param {string|undefined} device
54
+ * @returns {Promise<string|undefined>} Hub3 deviceUUID
55
+ */
56
+ async function resolveDeviceId(hub, ctx, device) {
57
+ if (device) return device;
58
+ if (!ctx.canPrompt()) {
59
+ ctx.die("--device <hub3uuid> が必要です (非対話モード)", 2);
60
+ return undefined;
61
+ }
62
+ const list = await hub.listDevices();
63
+ const hub3s = (Array.isArray(list) ? list : []).filter((d) => isHub3Model(d.deviceModel));
64
+ if (hub3s.length === 0) {
65
+ ctx.die("利用可能な Hub3 が見つかりません。", 1);
66
+ return undefined;
67
+ }
68
+ const picked = await ctx.prompts.selectFromList(
69
+ "発射する Hub3 を選択",
70
+ hub3s,
71
+ (d) => `${d.deviceName || "(no-name)"} ${d.deviceUUID}`,
72
+ );
73
+ return picked?.deviceUUID;
74
+ }
75
+
76
+ /**
77
+ * @param {import("commander").Command} program
78
+ * @param {object} ctx cli.js makeCtx() が供給する共有コンテキスト
79
+ */
80
+ export function registerPresetIrCommands(program, ctx) {
81
+ const presetir = program
82
+ .command("preset-ir")
83
+ .description("プリセット IR リモコン発射 (HXD command 生成 + remoteEmit)");
84
+
85
+ // sesame preset-ir air --device <hub3uuid> --code <n> [--power --temp <c> --mode <n> --fan <n> --wind <n> --swing]
86
+ presetir
87
+ .command("air")
88
+ .description("エアコン状態を発射 (emitAir)。指定したものだけ渡し、残りは本体既定に委ねる")
89
+ .option("--device <hub3uuid>", "Hub3 deviceUUID (省略時は対話選択 / 非対話は必須)")
90
+ .option("--code <n>", "プリセット remote.code (数値)", toInt)
91
+ .option("--power", "電源 ON (省略時 OFF)")
92
+ .option("--temp <c>", "温度 (UI 値, 例 25)", toInt)
93
+ .option("--mode <n>", "mode index 0-4 {0:自動,1:制冷,2:除湿,3:送風,4:制熱}", toInt)
94
+ .option("--fan <n>", "fanSpeed index 0-3 {0:自動,1:低,2:中,3:高}", toInt)
95
+ .option("--wind <n>", "windDirection index 0-2 {0:上,1:中,2:下}", toInt)
96
+ .option("--swing", "自動風向 ON (省略時 OFF)")
97
+ .action((opts) =>
98
+ ctx.withHub(async (hub, { opts: gopts }) => {
99
+ const deviceId = await resolveDeviceId(hub, ctx, opts.device);
100
+ if (!deviceId) return;
101
+ if (opts.code == null) {
102
+ ctx.die("--code <n> が必要です (プリセット remote.code)", 2);
103
+ return;
104
+ }
105
+ // 指定されたものだけ params に載せ、エアコン状態の既定は本体に委ねる。
106
+ const params = { deviceId, code: opts.code };
107
+ if (opts.power) params.power = true;
108
+ if (opts.temp != null) params.temperature = opts.temp;
109
+ if (opts.mode != null) params.mode = opts.mode;
110
+ if (opts.fan != null) params.fanSpeed = opts.fan;
111
+ if (opts.wind != null) params.windDirection = opts.wind;
112
+ if (opts.swing) params.autoSwing = true;
113
+
114
+ const { command, response } = await hub.presetir.emitAir(params);
115
+ ctx.out(gopts.json, () => {
116
+ console.log(`OK: emitted air command to ${deviceId}`);
117
+ console.log(` command: ${command}`);
118
+ }, { ok: true, deviceId, command, response });
119
+ }),
120
+ );
121
+
122
+ // sesame preset-ir button --device <hub3uuid> --code <n> --button <type> --irtype <n>
123
+ presetir
124
+ .command("button")
125
+ .description("非エアコン (TV/ライト/扇風機) のボタン押下を発射 (emitButton)")
126
+ .option("--device <hub3uuid>", "Hub3 deviceUUID (省略時は対話選択 / 非対話は必須)")
127
+ .option("--code <n>", "プリセット remote.code (数値)", toInt)
128
+ .option("--button <type>", "ボタン種別 (例 POWER_STATUS_ON, VOLUME_UP, FAN_SPEED)")
129
+ .option("--irtype <n>", "remote.type 実値 (TV:8192/0x2000, FAN:32768/0x8000, LIGHT:57344/0xE000)", toInt)
130
+ .action((opts) =>
131
+ ctx.withHub(async (hub, { opts: gopts }) => {
132
+ const deviceId = await resolveDeviceId(hub, ctx, opts.device);
133
+ if (!deviceId) return;
134
+ if (opts.code == null) {
135
+ ctx.die("--code <n> が必要です (プリセット remote.code)", 2);
136
+ return;
137
+ }
138
+ if (opts.irtype == null) {
139
+ ctx.die("--irtype <n> が必要です (remote.type 実値)", 2);
140
+ return;
141
+ }
142
+ if (!opts.button) {
143
+ ctx.die("--button <type> が必要です (ボタン種別)", 2);
144
+ return;
145
+ }
146
+ const { command, response } = await hub.presetir.emitButton({
147
+ deviceId,
148
+ code: opts.code,
149
+ irType: opts.irtype,
150
+ buttonType: opts.button,
151
+ });
152
+ ctx.out(gopts.json, () => {
153
+ console.log(`OK: emitted button command to ${deviceId}`);
154
+ console.log(` command: ${command}`);
155
+ }, { ok: true, deviceId, command, response });
156
+ }),
157
+ );
158
+
159
+ // sesame preset-ir send --device <hub3uuid> --command <hex> --irtype <n>
160
+ presetir
161
+ .command("send")
162
+ .description("生成済み HEX command をそのまま発射 (sendIR)。低レベル用途")
163
+ .option("--device <hub3uuid>", "Hub3 deviceUUID (省略時は対話選択 / 非対話は必須)")
164
+ .option("--command <hex>", "発射する HEX command 文字列")
165
+ .option("--irtype <n>", "remote.type 実値 (AIR:49152/0xC000, TV:8192/0x2000 等)", toInt)
166
+ .action((opts) =>
167
+ ctx.withHub(async (hub, { opts: gopts }) => {
168
+ const deviceId = await resolveDeviceId(hub, ctx, opts.device);
169
+ if (!deviceId) return;
170
+ if (!opts.command) {
171
+ ctx.die("--command <hex> が必要です (HEX command 文字列)", 2);
172
+ return;
173
+ }
174
+ if (opts.irtype == null) {
175
+ ctx.die("--irtype <n> が必要です (remote.type 実値)", 2);
176
+ return;
177
+ }
178
+ const response = await hub.presetir.sendIR({
179
+ deviceId,
180
+ command: opts.command,
181
+ irType: opts.irtype,
182
+ });
183
+ ctx.out(gopts.json, () => {
184
+ console.log(`OK: sent IR command to ${deviceId}`);
185
+ }, { ok: true, deviceId, command: opts.command, irType: opts.irtype, response });
186
+ }),
187
+ );
188
+ }
@@ -0,0 +1,83 @@
1
+ // `sesame schedule …` コマンド群。
2
+ //
3
+ // 本体ロジックは src/schedule.js (biz3Schedule: getScheduleList / cancelSchedule)。
4
+ // ここは commander への配線と入出力整形のみを担う。
5
+ //
6
+ // ctx 契約 (cli.js makeCtx が供給):
7
+ // ctx.withHub(fn) : connect → fn(hub, {opts}) → close。hub.schedule.* は
8
+ // companyID/subUUID を自動注入する namespace。
9
+ // ctx.withAccount(fn) : withHub + 事前に refreshAccount() で実 companyID/subUUID 保証。
10
+ // ctx.out(json, humanFn, jsonObj) : --json 時は jsonObj、それ以外は humanFn()。
11
+ // ctx.die(msg, code) : エラー表示して exit。
12
+ // ctx.canPrompt() : TTY かつ --json なし。
13
+ // ctx.prompts : { selectFromList, promptText, confirm, promptLine }。
14
+
15
+ /**
16
+ * @param {import("commander").Command} program
17
+ * @param {object} ctx cli.js makeCtx() が供給する共有コンテキスト
18
+ */
19
+ export function registerScheduleCommands(program, ctx) {
20
+ const schedule = program.command("schedule").description("予約実行スケジュール (biz3Schedule: 一覧 / 取消)");
21
+
22
+ // sesame schedule ls
23
+ schedule.command("ls")
24
+ .description("登録済み予約スケジュール一覧 (getScheduleList)")
25
+ .action(() =>
26
+ ctx.withHub(async (hub, { opts }) => {
27
+ const items = await hub.schedule.getScheduleList();
28
+ ctx.out(opts.json, () => {
29
+ if (!Array.isArray(items) || items.length === 0) {
30
+ console.log("(no schedules)");
31
+ return;
32
+ }
33
+ console.log(`Found ${items.length} schedule(s):`);
34
+ for (const s of items) {
35
+ const id = s.scheduleId ?? "(no-id)";
36
+ const when = s.displayTime ?? "(no-time)";
37
+ const act = s.action ?? "?";
38
+ const dev = s.deviceName ? ` [${s.deviceName}]` : "";
39
+ console.log(` ${id}\t${when}\t${act}${dev}`);
40
+ }
41
+ }, { ok: true, count: Array.isArray(items) ? items.length : 0, schedules: items });
42
+ }),
43
+ );
44
+
45
+ // sesame schedule cancel [scheduleId]
46
+ schedule.command("cancel [scheduleId]")
47
+ .description("予約スケジュールを 1 件取消 (引数省略時は一覧から対話選択)")
48
+ .action((scheduleId) =>
49
+ ctx.withHub(async (hub, { opts }) => {
50
+ // ID 未指定 & 対話可能なら一覧から選択させる。
51
+ if (!scheduleId && ctx.canPrompt()) {
52
+ const items = await hub.schedule.getScheduleList();
53
+ if (!Array.isArray(items) || items.length === 0) {
54
+ // 空一覧は異常ではない。ls と同じく out で正常メッセージを返す
55
+ // (die だと process.exit で withHub の finally close() を飛ばす)。
56
+ ctx.out(opts.json, () => console.log("(no schedules to cancel)"), { ok: true, count: 0 });
57
+ return;
58
+ }
59
+ const picked = await ctx.prompts.selectFromList(
60
+ "取消する予約を選択",
61
+ items,
62
+ (s) => `${s.scheduleId} ${s.displayTime ?? ""} ${s.action ?? ""}`,
63
+ );
64
+ // 対話選択を中断した場合 (picked なし) は「非対話モード」案内ではなく中止扱い。
65
+ if (!picked?.scheduleId) {
66
+ console.error("キャンセルしました。");
67
+ return;
68
+ }
69
+ scheduleId = picked.scheduleId;
70
+ }
71
+ if (!scheduleId) {
72
+ ctx.die("scheduleId が必要です: sesame schedule cancel <scheduleId> (非対話モード)", 2);
73
+ return;
74
+ }
75
+ const resp = await hub.schedule.cancelSchedule({ scheduleId });
76
+ ctx.out(opts.json, () => {
77
+ // 本体 cancelSchedule は ack=成功とみなす設計で、成功 data 構造は未確認
78
+ // (src/schedule.js 参照)。断定を避け ack ベースの表現にする。
79
+ console.log(`OK: cancel request acknowledged for schedule ${scheduleId}`);
80
+ }, { ok: true, scheduleId, response: resp });
81
+ }),
82
+ );
83
+ }
@@ -0,0 +1,308 @@
1
+ // `sesame serve` — 常駐 JSON-RPC バックエンド。
2
+ // 全機能 (lock/ir/iot/org/company/access/schedule/presetir + イベント) を、
3
+ // stdio / Unix socket / HTTP(+SSE) / WebSocket / gRPC のどれからでも他言語に公開する。
4
+ //
5
+ // 認証はデーモンに載せない (CLI 専用)。デーモンは既存トークン前提で起動し、
6
+ // 未認証/クラウド不通でも死なず degraded で待ち受ける。
7
+
8
+ import net from "node:net";
9
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync, chmodSync, existsSync } from "node:fs";
10
+ import { resolve, dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { SesameHub3 } from "../client.js";
13
+ import { configPaths } from "../paths.js";
14
+ import { Daemon } from "../serve/daemon.js";
15
+ import { startStdioFraming } from "../serve/framing/stdio.js";
16
+ import { startSocketFraming } from "../serve/framing/socket.js";
17
+ import { startHttpFraming } from "../serve/framing/http.js";
18
+ import { startWsFraming } from "../serve/framing/ws.js";
19
+ import { startGrpcFraming } from "../serve/framing/grpc.js";
20
+ import { generateToken } from "../serve/framing/token.js";
21
+
22
+ const DEF = { http: 8080, ws: 8081, grpc: 50051 };
23
+
24
+ function pkgVersion() {
25
+ try {
26
+ const p = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
27
+ return JSON.parse(readFileSync(p, "utf8")).version || "0.0.0";
28
+ } catch { return "0.0.0"; }
29
+ }
30
+
31
+ /** opts の任意ポート値 (true=既定 / "N"=指定) を数値へ。無効なら null。 */
32
+ function portOf(v, def) {
33
+ if (v === undefined || v === false) return null;
34
+ if (v === true) return def;
35
+ const n = parseInt(v, 10);
36
+ if (!Number.isFinite(n) || n < 0 || n > 65535) {
37
+ throw new Error(`invalid port: ${v}`); // 黙って既定にせず明示エラー
38
+ }
39
+ return n;
40
+ }
41
+
42
+ /** テスト用スタブ hub (env-gated)。実クラウドに繋がず契約だけ検証できるようにする。 */
43
+ function makeStubHub() {
44
+ let duFn = null;
45
+ return {
46
+ connected: true, subUUID: "stub-sub", config: { devices: {} },
47
+ async connect() {}, async close() {},
48
+ onDeviceUpdate: (_i, fn) => { duFn = fn; return () => { duFn = null; }; },
49
+ _emit: (m) => duFn && duFn(m),
50
+ async getLoginUser() { return { stub: true }; },
51
+ async listDevices() { return []; },
52
+ async unlock(name) { return { ok: true, name }; },
53
+ async unlockDevice({ deviceUUID }) { return { ok: true, deviceUUID }; },
54
+ };
55
+ }
56
+
57
+ async function buildHub(program) {
58
+ // テストスタブは本番では絶対に使わない (NODE_ENV=production では無効化)。
59
+ if (process.env.SESAME_SERVE_TEST_HUB === "1" && process.env.NODE_ENV !== "production") return makeStubHub();
60
+ const g = program.opts();
61
+ return SesameHub3.fromConfig({ configDir: g.configDir, debug: !!g.debug });
62
+ }
63
+
64
+ export function registerServeCommand(program) {
65
+ program.command("serve")
66
+ .description("常駐 JSON-RPC バックエンド (stdio/UDS/HTTP/WS/gRPC で全機能を他言語へ公開)")
67
+ .option("--stdio", "stdin/stdout で NDJSON JSON-RPC (埋め込み: 親が子プロセスとして spawn)")
68
+ .option("--socket [path]", "Unix domain socket (省略時 ~/.config/sesame-hub3/sesame.sock)")
69
+ .option("--no-socket", "UDS を無効化")
70
+ .option("--http [port]", `HTTP(+SSE) を listen (既定 ${DEF.http})`)
71
+ .option("--ws [port]", `WebSocket を listen (既定 ${DEF.ws})`)
72
+ .option("--grpc [port]", `gRPC を listen (既定 ${DEF.grpc})`)
73
+ .option("--bind <addr>", "TCP バインドアドレス", "127.0.0.1")
74
+ .option("--token <t>", "HTTP/WS/gRPC 用の loopback token (省略時は生成して表示)")
75
+ .addHelpText("after", `
76
+ 迷ったら: 引数なしで起動 (UDS) し、別端末で \`sesame rpc\` を使うのが最速 (JSON を書かずに済む)。
77
+ sesame serve # UDS (既定。最も移植性が高い)
78
+ sesame rpc # → 全メソッドと引数を一覧
79
+ sesame rpc lock.unlock --params '{"name":"front"}'
80
+ sesame rpc --subscribe lockState # → 鍵状態の変化を表示し続ける
81
+
82
+ フレーミングは 1 つ以上選ぶ。公開メソッドはどれでも同一。
83
+ sesame serve --stdio # 埋め込み (Python/Go が子プロセスとして spawn)
84
+ sesame serve --http 8080 # ブラウザ/全言語。http://… をブラウザで開くと使い方が出る
85
+ sesame serve --ws 8081 --grpc 50051 # 全二重 / 型付きスタブ
86
+
87
+ 他言語から繋ぐ接続情報: sesame rpc --paths (socket / token のパスを JSON で)
88
+ 事前に CLI でログインしておくこと: sesame login <email>`)
89
+ .action((opts) => cmdServe(opts, program));
90
+
91
+ // 起動中デーモンへ JSON-RPC を 1 発送る (nc -U + jq 不要に)。
92
+ program.command("rpc [method]")
93
+ .description("起動中の `sesame serve` に JSON-RPC を送る (UDS)。method 省略で全メソッド一覧")
94
+ .option("--params <json>", "params を JSON で渡す (例: '{\"name\":\"front\"}')")
95
+ .option("--socket <path>", "UDS パス (省略時は既定)")
96
+ .option("--subscribe <topics>", "イベント購読 (例: lockState,deviceUpdate)。Ctrl-C で停止")
97
+ .option("--paths", "接続情報 (socket / token のパス) を JSON 出力 (他言語クライアント用)")
98
+ .addHelpText("after", `
99
+ 例:
100
+ sesame rpc # 全メソッドと引数を一覧
101
+ sesame rpc status
102
+ sesame rpc lock.unlock --params '{"name":"front"}'
103
+ sesame rpc --subscribe lockState # 鍵状態の変化を表示し続ける
104
+ sesame rpc --paths # 他言語から繋ぐ接続情報を JSON で`)
105
+ .action((method, opts) => cmdRpc(method, opts, program));
106
+ }
107
+
108
+ /** UDS を保持し events を購読、各イベントを 1 行 JSON で出し続ける (Ctrl-C で終了)。 */
109
+ function rpcSubscribe(socketPath, topics) {
110
+ return new Promise((resolve, reject) => {
111
+ const sock = net.connect(socketPath);
112
+ let buf = "";
113
+ sock.on("connect", () => {
114
+ sock.write(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "events.subscribe", params: { topics } }) + "\n");
115
+ console.error(`[subscribed] ${topics.join(",")} — Ctrl-C で停止`);
116
+ });
117
+ sock.on("data", (d) => {
118
+ buf += d.toString();
119
+ let nl;
120
+ while ((nl = buf.indexOf("\n")) >= 0) {
121
+ const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
122
+ if (!line.trim()) continue;
123
+ let msg; try { msg = JSON.parse(line); } catch { continue; }
124
+ if (typeof msg.method === "string" && msg.method.startsWith("event.")) {
125
+ console.log(JSON.stringify({ topic: msg.method.slice(6), payload: msg.params }));
126
+ }
127
+ }
128
+ });
129
+ sock.on("error", (e) => {
130
+ if (e.code === "ENOENT" || e.code === "ECONNREFUSED") {
131
+ reject(new Error(`sesame serve が起動していません (socket: ${socketPath})。別ターミナルで \`sesame serve\` を実行してください`));
132
+ } else reject(e);
133
+ });
134
+ process.on("SIGINT", () => { sock.destroy(); resolve(); });
135
+ });
136
+ }
137
+
138
+ /** UDS 経由で 1 リクエスト送り result を返す。未起動は分かりやすいエラーに。 */
139
+ function rpcCall(socketPath, method, params, timeoutMs = 15000) {
140
+ return new Promise((resolve, reject) => {
141
+ const sock = net.connect(socketPath);
142
+ let buf = "";
143
+ const to = setTimeout(() => { sock.destroy(); reject(new Error("rpc timeout")); }, timeoutMs);
144
+ sock.on("connect", () => sock.write(JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) + "\n"));
145
+ sock.on("data", (d) => {
146
+ buf += d.toString();
147
+ const nl = buf.indexOf("\n");
148
+ if (nl < 0) return;
149
+ clearTimeout(to); sock.destroy();
150
+ const msg = JSON.parse(buf.slice(0, nl));
151
+ if (msg.error) { const e = new Error(msg.error.message); e.code = msg.error.code; return reject(e); }
152
+ resolve(msg.result);
153
+ });
154
+ sock.on("error", (e) => {
155
+ clearTimeout(to);
156
+ if (e.code === "ENOENT" || e.code === "ECONNREFUSED") {
157
+ reject(new Error(`sesame serve が起動していません (socket: ${socketPath})。別ターミナルで \`sesame serve\` を実行してください`));
158
+ } else reject(e);
159
+ });
160
+ });
161
+ }
162
+
163
+ async function cmdRpc(method, opts, program) {
164
+ const dir = configPaths(program.opts().configDir);
165
+ const socketPath = opts.socket || dir.socket;
166
+
167
+ // --paths: 他言語クライアントが XDG パスを再導出しなくて済むよう接続情報を出す。
168
+ if (opts.paths) {
169
+ const tokenFile = join(dir.dir, "serve.token");
170
+ let token = null;
171
+ try { token = readFileSync(tokenFile, "utf8").trim(); } catch { /* HTTP 未起動なら無し */ }
172
+ console.log(JSON.stringify({ socket: socketPath, tokenFile, token }, null, 2));
173
+ return;
174
+ }
175
+ // --subscribe: イベントを出し続ける (イベントの行き止まり解消)。
176
+ if (opts.subscribe) {
177
+ const topics = opts.subscribe.split(",").map((s) => s.trim()).filter(Boolean);
178
+ await rpcSubscribe(socketPath, topics);
179
+ return;
180
+ }
181
+
182
+ const m = method || "rpc.discover";
183
+ let params = {};
184
+ if (opts.params) {
185
+ try { params = JSON.parse(opts.params); } catch (e) { console.error(`Error: --params が不正な JSON: ${e.message}`); process.exit(2); }
186
+ }
187
+ const result = await rpcCall(socketPath, m, params);
188
+ // 未ログイン/失効を見たら次の一手を案内 (degraded で居座る問題の出口)。
189
+ if (m === "status" && result && result.authState && result.authState !== "ok") {
190
+ console.error(`Hint: 未ログイン/失効です。\`sesame login <email>\` 後にデーモンを再起動してください`);
191
+ }
192
+ if (program.opts().json) { console.log(JSON.stringify(result, null, 2)); return; }
193
+ if (m === "rpc.discover") {
194
+ // 人間向けの表: メソッド名 + 引数 (required はそのまま、任意は [name])。
195
+ for (const meth of result.methods) {
196
+ const ps = (meth.params || []).map((p) => (p.required ? p.name : `[${p.name}]`)).join(" ");
197
+ console.log(`${meth.name.padEnd(28)} ${ps}`);
198
+ }
199
+ console.error(`\n${result.methods.length} methods. 例: sesame rpc lock.unlock --params '{"name":"front"}'`);
200
+ return;
201
+ }
202
+ console.log(JSON.stringify(result, null, 2));
203
+ }
204
+
205
+ async function cmdServe(opts, program) {
206
+ // どのフレーミングを上げるか決定。明示が無ければ UDS のみ。
207
+ const wantStdio = !!opts.stdio;
208
+ const httpPort = portOf(opts.http, DEF.http);
209
+ const wsPort = portOf(opts.ws, DEF.ws);
210
+ const grpcPort = portOf(opts.grpc, DEF.grpc);
211
+ const anyExplicit = wantStdio || opts.socket !== undefined || httpPort != null || wsPort != null || grpcPort != null;
212
+ const wantSocket = opts.socket === false ? false : (opts.socket !== undefined ? true : !anyExplicit);
213
+ const socketPath = typeof opts.socket === "string" ? opts.socket : configPaths(program.opts().configDir).socket;
214
+ const needsToken = httpPort != null || wsPort != null || grpcPort != null;
215
+ const token = needsToken ? (opts.token || generateToken()) : null;
216
+
217
+ const hub = await buildHub(program);
218
+ const daemon = new Daemon({ hub, version: pkgVersion(), debug: !!program.opts().debug });
219
+
220
+ // 人間向けの案内は **stderr** へ (stdio モードでは stdout が RPC チャネル)。
221
+ const note = (...a) => console.error("[serve]", ...a);
222
+
223
+ const handles = [];
224
+ let shuttingDown = false;
225
+ let resolveRun;
226
+ const runUntilShutdown = new Promise((r) => { resolveRun = r; });
227
+
228
+ const shutdown = async (reason) => {
229
+ if (shuttingDown) return;
230
+ shuttingDown = true;
231
+ note(`shutting down (${reason})...`);
232
+ // hub.close() 等が万一ハングしても Ctrl-C 不能にならないよう強制終了の保険。
233
+ const watchdog = setTimeout(() => { note("shutdown watchdog: forcing exit"); process.exit(1); }, 5000);
234
+ watchdog.unref();
235
+ for (const h of handles) { try { await h.stop?.(); } catch { /* ignore */ } }
236
+ await daemon.shutdown();
237
+ clearTimeout(watchdog);
238
+ resolveRun();
239
+ };
240
+
241
+ try {
242
+ if (wantStdio) { handles.push(startStdioFraming(daemon, { onShutdown: () => shutdown("stdin EOF") })); note("stdio framing ready (NDJSON JSON-RPC on stdin/stdout)"); }
243
+ if (wantSocket) {
244
+ const h = await startSocketFraming(daemon, { socketPath }); handles.push(h);
245
+ note(`unix socket: ${h.path}`);
246
+ note(` quick test: printf '{"jsonrpc":"2.0","id":1,"method":"rpc.discover"}\\n' | nc -U ${h.path} | head -c 200`);
247
+ }
248
+ if (httpPort != null) { const h = await startHttpFraming(daemon, { bind: opts.bind, port: httpPort, token }); handles.push(h); note(`http: ${h.url} (ブラウザで開くと使い方。POST /rpc, GET /events)`); }
249
+ if (wsPort != null) {
250
+ const h = await startWsFraming(daemon, { bind: opts.bind, port: wsPort, token }); handles.push(h);
251
+ note(`ws: ${h.url} (認証は Authorization: Bearer。ブラウザのみ ?token=<token>)`);
252
+ note(` quick test: wscat -c "${h.url}?token=<token>" (npm i -g wscat)`);
253
+ }
254
+ if (grpcPort != null) { const h = await startGrpcFraming(daemon, { bind: opts.bind, port: grpcPort, token }); handles.push(h); note(`grpc: ${opts.bind}:${h.port} (型付き。上級者向け。proto: src/serve/sesame.proto)`); }
255
+ } catch (e) {
256
+ await shutdown("startup error");
257
+ throw e; // run() の catch が JSON/人間向けエラーに整形
258
+ }
259
+
260
+ if (needsToken) {
261
+ // token を well-known ファイルにも書く (バックグラウンド起動で stderr を見逃しても拾えるように)。
262
+ const tokenFile = join(configPaths(program.opts().configDir).dir, "serve.token");
263
+ try {
264
+ const dir = dirname(tokenFile);
265
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
266
+ writeFileSync(tokenFile, token + "\n", { mode: 0o600 });
267
+ // writeFileSync/mkdirSync の mode は**新規作成時のみ**効く。既存の緩い権限 (他ツールが
268
+ // 0755 で作った dir、前回別 umask で残した 0644 の token) を確実に締めるため明示 chmod。
269
+ try { chmodSync(tokenFile, 0o600); } catch { /* ignore */ }
270
+ try { if (existsSync(dir)) chmodSync(dir, 0o700); } catch { /* ignore */ }
271
+ handles.push({ stop: () => { try { unlinkSync(tokenFile); } catch { /* ignore */ } } });
272
+ note(`token: ${token}`);
273
+ note(` use as: Authorization: Bearer ${token}`);
274
+ note(` saved to: ${tokenFile}`);
275
+ } catch {
276
+ note(`token: ${token} (Authorization: Bearer <token> 必須)`);
277
+ }
278
+ if (opts.bind && opts.bind !== "127.0.0.1" && opts.bind !== "localhost") {
279
+ note(`WARNING: --bind ${opts.bind} はロック制御をネットワークに公開します。`);
280
+ note(" HTTP/WS/gRPC はいずれも TLS なしの平文です。token もロック制御コマンドも");
281
+ note(" 盗聴・リプレイ可能なので、LAN 公開は VPN / SSH トンネル / TLS リバースプロキシ越しに限定し、");
282
+ note(" ファイアウォールで接続元を絞ること (token があるだけでは平文盗聴に無力)。");
283
+ }
284
+ }
285
+ daemon.start(); // クラウド接続を背景で試行 (失敗しても degraded で継続)
286
+ note("ready. (未ログインなら上の cloud connect 失敗を確認し `sesame login` を実行。Ctrl-C で停止)");
287
+
288
+ // シグナル/致命例外で graceful shutdown。プロセスはここで shutdown まで生き続ける。
289
+ const onSig = (s) => shutdown(s);
290
+ process.once("SIGINT", () => onSig("SIGINT"));
291
+ process.once("SIGTERM", () => onSig("SIGTERM"));
292
+ process.once("SIGHUP", () => onSig("SIGHUP"));
293
+ // uncaughtException は本当に異常 → cleanup して exit。
294
+ process.once("uncaughtException", async (e) => { note("uncaughtException:", e?.message); await shutdown("uncaughtException"); process.exit(1); });
295
+ // unhandledRejection はログのみ (良性の reject 1 個でロック制御を落とさない)。
296
+ // ただし**短時間のバースト**は構造的バグなので exit (無限ログ垂れ流しを防ぐ)。
297
+ // 生涯累積で数えると無期限常駐が良性 reject の蓄積でいつか必ず落ちるため、直近 60s の窓で判定する。
298
+ const rejTimes = [];
299
+ process.on("unhandledRejection", (e) => {
300
+ note("unhandledRejection (ignored):", e?.message || e);
301
+ const now = Date.now();
302
+ rejTimes.push(now);
303
+ while (rejTimes.length && now - rejTimes[0] > 60_000) rejTimes.shift();
304
+ if (rejTimes.length > 50) { note("too many unhandled rejections in 60s — exiting"); process.exit(1); }
305
+ });
306
+
307
+ await runUntilShutdown;
308
+ }