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,208 @@
1
+ // sesame serve の薄い公式 JS クライアント (Node 18+, 依存ゼロ)。
2
+ // 別プロセスで動いている serve デーモンに繋ぐ用途。
3
+ //
4
+ // import { SesameClient } from "./sesame-client.mjs";
5
+ // const c = SesameClient.unix(); // 既定 UDS パス (POSIX。sesame serve で起動)
6
+ // console.log(await c.unlock("front"));
7
+ // await c.subscribe(["lockState"], (topic, p) => console.log("EVENT", topic, p));
8
+ //
9
+ // const h = SesameClient.http("http://127.0.0.1:8080"); // token は serve.token から自動
10
+ // const w = await SesameClient.ws("ws://127.0.0.1:8081"); // Windows でも全二重 (要 Node22+ or ws)
11
+ //
12
+ // 失敗は SesameError(message, kind) を throw (kind: not_authenticated / connection_lost / timeout)。
13
+ // subscribe は **常に await** すること (接続/認証エラーを取りこぼさないため)。
14
+
15
+ import net from "node:net";
16
+ import readline from "node:readline";
17
+ import os from "node:os";
18
+ import { join } from "node:path";
19
+ import { readFileSync } from "node:fs";
20
+
21
+ function defaultSocketPath() {
22
+ const base = process.env.XDG_CONFIG_HOME || join(os.homedir(), ".config");
23
+ return join(base, "sesame-hub3", "sesame.sock");
24
+ }
25
+ function defaultTokenPath() {
26
+ const base = process.env.XDG_CONFIG_HOME || join(os.homedir(), ".config");
27
+ return join(base, "sesame-hub3", "serve.token");
28
+ }
29
+ function defaultToken() {
30
+ try { return readFileSync(defaultTokenPath(), "utf8").trim(); } catch { return null; }
31
+ }
32
+
33
+ export class SesameError extends Error {
34
+ constructor(message, kind, code) { super(message); this.name = "SesameError"; this.kind = kind; this.code = code; }
35
+ }
36
+
37
+ export class SesameClient {
38
+ constructor(transport) { this._t = transport; }
39
+
40
+ static unix(path = defaultSocketPath()) {
41
+ if (process.platform === "win32") {
42
+ throw new SesameError("Unix socket は POSIX 専用です。Windows では SesameClient.http() か .ws() を使ってください", "not_implemented");
43
+ }
44
+ return new SesameClient(new StreamTransport(path));
45
+ }
46
+ static http(base = "http://127.0.0.1:8080", token = defaultToken()) {
47
+ return new SesameClient(new HttpTransport(base.replace(/\/$/, ""), token));
48
+ }
49
+ /** WebSocket クライアント。`ws` パッケージ(ヘッダ認証可)を優先し、無ければ global WebSocket (await 必須)。 */
50
+ static async ws(url = "ws://127.0.0.1:8081", token = defaultToken()) {
51
+ // ws パッケージは upgrade で Authorization ヘッダを送れる (token を URL に載せず済む)。
52
+ // 無ければ global WebSocket (ブラウザ/Node22+) にフォールバックし URL ?token= を使う。
53
+ const pkg = await import("ws").then((m) => m.WebSocket).catch(() => null);
54
+ const WS = pkg || globalThis.WebSocket;
55
+ if (!WS) throw new SesameError("WebSocket が無い。Node 18+ で `npm i ws`、または Node 22+ が必要です", "not_implemented");
56
+ const t = new WsTransport(WS, url, token, /* useHeader */ !!pkg);
57
+ await t.ready();
58
+ return new SesameClient(t);
59
+ }
60
+
61
+ async call(method, params = {}) {
62
+ const resp = await this._t.request({ jsonrpc: "2.0", method, params }); // id は transport が採番
63
+ if (resp.error) throw new SesameError(resp.error.message, resp.error.data?.kind, resp.error.code);
64
+ return resp.result;
65
+ }
66
+ /** topics を購読。常に await すること (接続/認証/不正 topic エラーが throw で返る)。 */
67
+ async subscribe(topics, onEvent) {
68
+ const resp = await this._t.subscribe(topics, onEvent);
69
+ // UDS/WS は購読要求の応答 (msg) を返す。error があれば握り潰さず throw (不正 topic 等)。
70
+ if (resp && resp.error) throw new SesameError(resp.error.message, resp.error.data?.kind, resp.error.code);
71
+ return resp?.result;
72
+ }
73
+ async discover() { return this.call("rpc.discover"); }
74
+
75
+ unlock(name, kw = {}) { return this.call("lock.unlock", name ? { name, ...kw } : kw); }
76
+ lock(name, kw = {}) { return this.call("lock.lock", name ? { name, ...kw } : kw); }
77
+ status() { return this.call("status"); }
78
+ devices() { return this.call("devices.list"); }
79
+ close() { this._t.close(); }
80
+ }
81
+
82
+ /** 受信行を id/event で振り分ける共通処理 (UDS/WS で共有)。 */
83
+ function routeMessage(self, line) {
84
+ let msg;
85
+ try { msg = JSON.parse(line); } catch { return; } // 壊れた行で落ちない
86
+ if ("id" in msg && self._pending.has(msg.id)) {
87
+ const { resolve } = self._pending.get(msg.id); self._pending.delete(msg.id); resolve(msg);
88
+ } else if (typeof msg.method === "string" && msg.method.startsWith("event.")) {
89
+ self._onEvent?.(msg.method.slice("event.".length), msg.params);
90
+ }
91
+ }
92
+
93
+ class StreamTransport {
94
+ constructor(path) {
95
+ this._ids = 0; this._pending = new Map(); this._onEvent = null; this._fatal = null;
96
+ this._sock = net.connect(path);
97
+ this._sock.on("error", (e) => {
98
+ const msg = (e.code === "ENOENT" || e.code === "ECONNREFUSED")
99
+ ? `sesame serve が起動していません (socket: ${path})。別ターミナルで \`sesame serve\` を実行してください`
100
+ : `socket エラー: ${e.message}`;
101
+ this._fatal = new SesameError(msg, "connection_lost");
102
+ for (const { reject } of this._pending.values()) reject(this._fatal);
103
+ this._pending.clear();
104
+ });
105
+ const rl = readline.createInterface({ input: this._sock });
106
+ rl.on("error", () => { /* socket 'error' 側で処理 */ });
107
+ rl.on("line", (line) => { if (line.trim()) routeMessage(this, line); });
108
+ }
109
+ request(msg) {
110
+ if (this._fatal) return Promise.reject(this._fatal);
111
+ const id = ++this._ids;
112
+ return new Promise((resolve, reject) => {
113
+ const to = setTimeout(() => { this._pending.delete(id); reject(new SesameError("request timed out", "timeout")); }, 20000);
114
+ this._pending.set(id, { resolve: (m) => { clearTimeout(to); resolve(m); }, reject: (e) => { clearTimeout(to); reject(e); } });
115
+ this._sock.write(JSON.stringify({ ...msg, id }) + "\n");
116
+ });
117
+ }
118
+ subscribe(topics, onEvent) { this._onEvent = onEvent; return this.request({ jsonrpc: "2.0", method: "events.subscribe", params: { topics } }); }
119
+ close() { this._sock.destroy(); }
120
+ }
121
+
122
+ class WsTransport {
123
+ constructor(WS, url, token, useHeader) {
124
+ this._ids = 0; this._pending = new Map(); this._onEvent = null; this._fatal = null;
125
+ // ヘッダ送信可 (ws パッケージ) なら Authorization ヘッダ、不可 (ブラウザ) なら URL ?token=。
126
+ this._ws = useHeader && token
127
+ ? new WS(url, { headers: { authorization: `Bearer ${token}` } })
128
+ : new WS(url + (url.includes("?") ? "&" : "?") + (token ? `token=${token}` : ""));
129
+ this._open = new Promise((resolve, reject) => {
130
+ const fail = (e) => { this._fatal = e; for (const { reject: rj } of this._pending.values()) rj(e); this._pending.clear(); reject(e); };
131
+ this._ws.addEventListener("open", () => resolve());
132
+ // 握手 401 (verifyClient 拒否) は error として届く。message に 401 を含めば認証失敗。
133
+ this._ws.addEventListener("error", (ev) => {
134
+ const m = String(ev?.message || ev?.error?.message || "");
135
+ if (/401|403|unauthorized/i.test(m)) fail(new SesameError("unauthorized (token 不一致/未指定)", "not_authenticated", 401));
136
+ else fail(new SesameError(`ws 接続失敗 (${url})。sesame serve --ws を起動しましたか?`, "connection_lost"));
137
+ });
138
+ // 念のため close 1008 も認証失敗として扱う (open が先勝ちした場合の保険)。
139
+ this._ws.addEventListener("close", (ev) => { if (ev.code === 1008) fail(new SesameError("unauthorized (token)", "not_authenticated", 1008)); });
140
+ });
141
+ this._open.catch(() => {}); // unhandled rejection 抑止 (ready() で受ける)
142
+ this._ws.addEventListener("message", (ev) => routeMessage(this, typeof ev.data === "string" ? ev.data : ev.data.toString()));
143
+ }
144
+ ready() { return this._open; }
145
+ async request(msg) {
146
+ if (this._fatal) return Promise.reject(this._fatal);
147
+ await this._open;
148
+ if (this._fatal) return Promise.reject(this._fatal);
149
+ const id = ++this._ids;
150
+ return new Promise((resolve, reject) => {
151
+ const to = setTimeout(() => { this._pending.delete(id); reject(new SesameError("request timed out", "timeout")); }, 20000);
152
+ this._pending.set(id, { resolve: (m) => { clearTimeout(to); resolve(m); }, reject });
153
+ this._ws.send(JSON.stringify({ ...msg, id }));
154
+ });
155
+ }
156
+ subscribe(topics, onEvent) { this._onEvent = onEvent; return this.request({ jsonrpc: "2.0", method: "events.subscribe", params: { topics } }); }
157
+ close() { try { this._ws.close(); } catch { /* ignore */ } }
158
+ }
159
+
160
+ class HttpTransport {
161
+ constructor(base, token) { this._base = base; this._token = token; this._ids = 0; }
162
+ _headers() { return this._token ? { "content-type": "application/json", authorization: `Bearer ${this._token}` } : { "content-type": "application/json" }; }
163
+ _unauthorized() {
164
+ return this._token
165
+ ? new SesameError("unauthorized (token 不一致)", "not_authenticated", 401)
166
+ : new SesameError(`token が見つかりません。\`sesame serve --http\` で起動すると ${defaultTokenPath()} に保存されます`, "not_authenticated", 401);
167
+ }
168
+ async request(msg) {
169
+ let r;
170
+ try {
171
+ r = await fetch(`${this._base}/rpc`, { method: "POST", headers: this._headers(), body: JSON.stringify({ ...msg, id: ++this._ids }) });
172
+ } catch (e) {
173
+ throw new SesameError(`接続失敗: ${e.message}。\`sesame serve --http\` を起動しましたか?`, "connection_lost");
174
+ }
175
+ if (r.status === 401) throw this._unauthorized();
176
+ return r.status === 204 ? { id: this._ids, result: null } : r.json();
177
+ }
178
+ async subscribe(topics, onEvent) {
179
+ const url = `${this._base}/events?topics=${topics.join(",")}` + (this._token ? `&token=${this._token}` : "");
180
+ let res;
181
+ try { res = await fetch(url, { headers: this._headers() }); }
182
+ catch (e) { throw new SesameError(`events 接続失敗: ${e.message}`, "connection_lost"); }
183
+ if (res.status === 401) throw this._unauthorized();
184
+ if (res.status >= 400) { // 400 = 不正 topic 等。黙ってストリームを張らず明示エラーに。
185
+ let detail = ""; try { detail = JSON.stringify(await res.json()); } catch { /* ignore */ }
186
+ throw new SesameError(`events 購読失敗 (HTTP ${res.status}): ${detail}`, "bad_params", res.status);
187
+ }
188
+ const reader = res.body.getReader();
189
+ const dec = new TextDecoder();
190
+ let buf = "";
191
+ (async () => {
192
+ try {
193
+ for (;;) {
194
+ const { value, done } = await reader.read();
195
+ if (done) break;
196
+ buf += dec.decode(value, { stream: true });
197
+ let nl;
198
+ while ((nl = buf.indexOf("\n\n")) >= 0) {
199
+ const block = buf.slice(0, nl); buf = buf.slice(nl + 2);
200
+ const line = block.split("\n").find((l) => l.startsWith("data: "));
201
+ if (line) { let m; try { m = JSON.parse(line.slice(6)); } catch { continue; } if (m.method?.startsWith("event.")) onEvent(m.method.slice(6), m.params); }
202
+ }
203
+ }
204
+ } catch (e) { console.error("[sesame] subscribe error:", e.message); }
205
+ })();
206
+ }
207
+ close() {}
208
+ }
@@ -0,0 +1,5 @@
1
+ # メタデータは setup.cfg に置く (古い setuptools でも正しく読めるため。理由は setup.cfg 冒頭参照)。
2
+ # ここはビルドバックエンドの宣言のみ。
3
+ [build-system]
4
+ requires = ["setuptools>=40.8.0"]
5
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,323 @@
1
+ """sesame serve の薄い公式 Python クライアント (標準ライブラリのみ・依存ゼロ)。
2
+
3
+ 事前に CLI 側でログイン: sesame login <email>
4
+
5
+ pip install ./clients/python # どこからでも import 可能に
6
+ from sesame_client import SesameClient
7
+
8
+ c = SesameClient.unix() # 既定 UDS パスを自動解決 (sesame serve で起動)
9
+ print(c.status())
10
+ print(c.unlock("front")) # = c.call("lock.unlock", name="front")
11
+
12
+ # HTTP: SesameClient.http("http://127.0.0.1:8080") (token は serve.token から自動)
13
+ # 埋め込み: SesameClient.stdio()
14
+
15
+ # イベント購読 (UDS/stdio/HTTP 共通)。受信し続けるには wait() でメインを生かす。
16
+ c.subscribe(["lockState"], lambda topic, payload: print("EVENT", topic, payload))
17
+ c.wait()
18
+
19
+ 失敗は SesameError(message, kind) を raise (kind: not_authenticated / connection_lost / timeout …)。
20
+ エラーメッセージは「次に何をすべきか」を含む (例: デーモン未起動なら起動コマンドを案内)。
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import itertools
25
+ import json
26
+ import os
27
+ import socket
28
+ import subprocess
29
+ import sys
30
+ import threading
31
+ import time
32
+ import urllib.request
33
+ from typing import Any, Callable, Optional
34
+
35
+
36
+ def _default_socket_path() -> str:
37
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
38
+ return os.path.join(base, "sesame-hub3", "sesame.sock")
39
+
40
+
41
+ def _default_token_path() -> str:
42
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
43
+ return os.path.join(base, "sesame-hub3", "serve.token")
44
+
45
+
46
+ class SesameError(RuntimeError):
47
+ def __init__(self, message: str, kind: Optional[str] = None, code: Optional[int] = None):
48
+ super().__init__(message)
49
+ self.message, self.kind, self.code = message, kind, code
50
+
51
+ def __str__(self):
52
+ extra = f" [{self.kind}]" if self.kind else ""
53
+ return f"{self.message}{extra}"
54
+
55
+
56
+ class SesameClient:
57
+ """JSON-RPC over UDS / stdio / HTTP. 直接 new せず unix()/stdio()/http() を使う。"""
58
+
59
+ def __init__(self, transport):
60
+ self._t = transport # id 採番は transport が一元管理 (call と subscribe で衝突しないよう)
61
+
62
+ # ---- ファクトリ ----
63
+ @classmethod
64
+ def unix(cls, path: Optional[str] = None) -> "SesameClient":
65
+ path = path or _default_socket_path()
66
+ if not hasattr(socket, "AF_UNIX"):
67
+ raise SesameError("Unix socket は POSIX 専用です。Windows では SesameClient.http() か .stdio() を使ってください",
68
+ kind="not_implemented")
69
+ return cls(_StreamTransport(_connect_unix(path)))
70
+
71
+ @classmethod
72
+ def stdio(cls, cmd=("sesame", "serve", "--stdio")) -> "SesameClient":
73
+ return cls(_StdioTransport(cmd))
74
+
75
+ @classmethod
76
+ def http(cls, base: str = "http://127.0.0.1:8080", token: Optional[str] = None) -> "SesameClient":
77
+ if token is None:
78
+ try:
79
+ with open(_default_token_path()) as f:
80
+ token = f.read().strip()
81
+ except OSError:
82
+ token = None # 後続 401 で具体的に案内する
83
+ return cls(_HttpTransport(base.rstrip("/"), token))
84
+
85
+ # ---- 基本 API ----
86
+ def call(self, method: str, **params) -> Any:
87
+ # id は transport が採番する (call と subscribe で id 空間を共有させ衝突を防ぐ)。
88
+ resp = self._t.request({"jsonrpc": "2.0", "method": method, "params": params})
89
+ if "error" in resp:
90
+ e = resp["error"]
91
+ raise SesameError(e.get("message", "error"), (e.get("data") or {}).get("kind"), e.get("code"))
92
+ return resp.get("result")
93
+
94
+ def subscribe(self, topics, on_event: Callable[[str, Any], None]) -> None:
95
+ """topics を購読し、各イベントで on_event(topic, payload) を呼ぶ (バックグラウンド)。
96
+ 受信し続けるにはメインスレッドを生かすこと → wait() が使える。
97
+ 接続/認証/不正 topic エラーは握り潰さず SesameError を raise する (JS クライアントと対称)。"""
98
+ resp = self._t.subscribe(list(topics), on_event)
99
+ # UDS/stdio は購読要求の応答 (dict) を返す。error があれば raise (不正 topic 等)。
100
+ # HTTP は初回接続を同期確立し 401/400 をその場で raise 済み (resp は None)。
101
+ if isinstance(resp, dict) and "error" in resp:
102
+ e = resp["error"]
103
+ raise SesameError(e.get("message", "error"), (e.get("data") or {}).get("kind"), e.get("code"))
104
+
105
+ def wait(self) -> None:
106
+ """購読を生かしたままブロックする (Ctrl-C で抜ける)。"""
107
+ try:
108
+ while True:
109
+ time.sleep(3600)
110
+ except KeyboardInterrupt:
111
+ pass
112
+
113
+ def discover(self) -> Any:
114
+ return self.call("rpc.discover")
115
+
116
+ def discover_names(self):
117
+ return [m["name"] for m in self.discover()["methods"]]
118
+
119
+ # ---- よく使うショートカット ----
120
+ def status(self):
121
+ return self.call("status")
122
+
123
+ def unlock(self, name=None, **kw):
124
+ return self.call("lock.unlock", **({"name": name} if name else {}), **kw)
125
+
126
+ def lock(self, name=None, **kw):
127
+ return self.call("lock.lock", **({"name": name} if name else {}), **kw)
128
+
129
+ def toggle(self, name=None, **kw):
130
+ return self.call("lock.toggle", **({"name": name} if name else {}), **kw)
131
+
132
+ def devices(self):
133
+ return self.call("devices.list")
134
+
135
+ def close(self):
136
+ self._t.close()
137
+
138
+ def __enter__(self):
139
+ return self
140
+
141
+ def __exit__(self, *exc):
142
+ self.close()
143
+
144
+
145
+ # ---------------- transports ----------------
146
+
147
+ def _connect_unix(path: str):
148
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
149
+ try:
150
+ s.connect(path)
151
+ except (FileNotFoundError, ConnectionRefusedError):
152
+ s.close()
153
+ raise SesameError(
154
+ f"sesame serve が起動していません (socket: {path})。別ターミナルで `sesame serve` を実行してください",
155
+ kind="connection_lost") from None
156
+ except OSError as e:
157
+ s.close()
158
+ raise SesameError(f"socket 接続失敗: {e}", kind="connection_lost") from e
159
+ return s
160
+
161
+
162
+ class _StreamTransport:
163
+ """UDS など双方向ストリーム共通。応答とイベントを id/method で振り分ける。"""
164
+
165
+ def __init__(self, sock):
166
+ self._sock = sock
167
+ self._wfile = sock.makefile("w")
168
+ self._rfile = sock.makefile("r")
169
+ self._pending: dict[int, dict] = {}
170
+ self._ids = itertools.count(1) # call と subscribe で共有する id 空間
171
+ self._on_event: Optional[Callable[[str, Any], None]] = None
172
+ self._lock = threading.Lock()
173
+ threading.Thread(target=self._reader, daemon=True).start()
174
+
175
+ def _reader(self):
176
+ for line in self._rfile:
177
+ line = line.strip()
178
+ if not line:
179
+ continue
180
+ msg = json.loads(line)
181
+ slot = None
182
+ if "id" in msg:
183
+ with self._lock: # pending の参照/取り出しは登録と同一 lock で保護
184
+ slot = self._pending.pop(msg["id"], None)
185
+ if slot is not None:
186
+ slot["msg"] = msg
187
+ slot["ev"].set()
188
+ elif isinstance(msg.get("method"), str) and msg["method"].startswith("event."):
189
+ if self._on_event:
190
+ self._on_event(msg["method"][len("event."):], msg.get("params"))
191
+
192
+ def request(self, msg: dict) -> dict:
193
+ mid = next(self._ids) # ここで一意 id を採番 (caller は id を持たない)
194
+ msg = {**msg, "id": mid}
195
+ slot = {"ev": threading.Event(), "msg": None}
196
+ with self._lock: # pending 登録と write を同一 lock 内で (reader との競合を防ぐ)
197
+ self._pending[mid] = slot
198
+ self._wfile.write(json.dumps(msg) + "\n")
199
+ self._wfile.flush()
200
+ if not slot["ev"].wait(timeout=20):
201
+ with self._lock:
202
+ self._pending.pop(mid, None)
203
+ raise SesameError("request timed out", kind="timeout")
204
+ return slot["msg"]
205
+
206
+ def subscribe(self, topics, on_event):
207
+ self._on_event = on_event
208
+ return self.request({"jsonrpc": "2.0", "method": "events.subscribe", "params": {"topics": topics}})
209
+
210
+ def close(self):
211
+ try:
212
+ self._sock.close()
213
+ except OSError:
214
+ pass
215
+
216
+
217
+ class _StdioTransport(_StreamTransport):
218
+ def __init__(self, cmd):
219
+ try:
220
+ self._proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
221
+ stderr=subprocess.PIPE, text=True, bufsize=1)
222
+ except FileNotFoundError:
223
+ raise SesameError(
224
+ f"`{cmd[0]}` が見つかりません。`npm link` (sesame-kit 内で実行) で PATH を通すか、"
225
+ f"cmd=['node','bin/sesame.js','serve','--stdio'] を渡してください",
226
+ kind="connection_lost") from None
227
+ # stderr を読む儀式は不要 (早期入力は OS パイプが buffer。request は timeout 付き)。
228
+ # 起動直後に死んだ場合だけ検知して分かりやすく報告する。
229
+ if self._proc.poll() is not None:
230
+ err = (self._proc.stderr.read() or "").strip()
231
+ raise SesameError(f"sesame serve が起動直後に終了しました: {err}", kind="connection_lost")
232
+ self._wfile = self._proc.stdin
233
+ self._rfile = self._proc.stdout
234
+ self._pending = {}
235
+ self._ids = itertools.count(1)
236
+ self._on_event = None
237
+ self._lock = threading.Lock()
238
+ threading.Thread(target=self._reader, daemon=True).start()
239
+
240
+ def close(self):
241
+ try:
242
+ self._proc.stdin.close()
243
+ self._proc.terminate()
244
+ except Exception:
245
+ pass
246
+
247
+
248
+ class _HttpTransport:
249
+ def __init__(self, base: str, token: Optional[str]):
250
+ self._base, self._token = base, token
251
+ self._ids = itertools.count(1)
252
+
253
+ def _headers(self):
254
+ h = {"content-type": "application/json"}
255
+ if self._token:
256
+ h["authorization"] = f"Bearer {self._token}"
257
+ return h
258
+
259
+ def _unauthorized(self):
260
+ if not self._token:
261
+ return SesameError(
262
+ f"token が見つかりません。`sesame serve --http` で起動すると {_default_token_path()} に保存されます "
263
+ f"(または http(token=...) で明示指定)", kind="not_authenticated", code=401)
264
+ return SesameError("unauthorized (token 不一致)", kind="not_authenticated", code=401)
265
+
266
+ def request(self, msg: dict) -> dict:
267
+ msg = {**msg, "id": next(self._ids)} # id 必須 (無いと通知扱いで応答が返らない)
268
+ data = json.dumps(msg).encode()
269
+ req = urllib.request.Request(f"{self._base}/rpc", data=data, headers=self._headers(), method="POST")
270
+ try:
271
+ with urllib.request.urlopen(req, timeout=20) as r:
272
+ body = r.read()
273
+ return json.loads(body) if body else {"id": msg["id"], "result": None}
274
+ except urllib.error.HTTPError as e:
275
+ if e.code == 401:
276
+ raise self._unauthorized() from None
277
+ raise SesameError(f"HTTP {e.code}: {e.reason}", kind="internal", code=e.code) from None
278
+ except urllib.error.URLError as e:
279
+ raise SesameError(f"接続失敗: {e.reason}。`sesame serve --http` を起動しましたか?",
280
+ kind="connection_lost") from None
281
+
282
+ def subscribe(self, topics, on_event):
283
+ q = ",".join(topics)
284
+ url = f"{self._base}/events?topics={q}"
285
+ if self._token:
286
+ url += f"&token={self._token}"
287
+ # 初回接続を**同期で**確立し、401/400 (不正 topic) をその場で raise (JS と対称。
288
+ # バックグラウンドで黙って失敗していた旧挙動を是正)。
289
+ req = urllib.request.Request(url, headers=self._headers())
290
+ try:
291
+ r = urllib.request.urlopen(req)
292
+ except urllib.error.HTTPError as e:
293
+ if e.code == 401:
294
+ raise self._unauthorized() from None
295
+ raise SesameError(f"events 購読失敗 (HTTP {e.code}): {e.reason}", kind="bad_params", code=e.code) from None
296
+ except urllib.error.URLError as e:
297
+ raise SesameError(f"events 接続失敗: {e.reason}。`sesame serve --http` を起動しましたか?",
298
+ kind="connection_lost") from None
299
+
300
+ def run():
301
+ try:
302
+ with r:
303
+ for raw in r:
304
+ line = raw.decode().strip()
305
+ if line.startswith("data: "):
306
+ msg = json.loads(line[len("data: "):])
307
+ m = msg.get("method", "")
308
+ if m.startswith("event."):
309
+ on_event(m[len("event."):], msg.get("params"))
310
+ except Exception as exc: # 確立後のストリーム中断は握り潰さず stderr に
311
+ print(f"[sesame] subscribe stream error: {exc}", file=sys.stderr)
312
+
313
+ threading.Thread(target=run, daemon=True).start()
314
+
315
+ def close(self):
316
+ pass
317
+
318
+
319
+ if __name__ == "__main__":
320
+ mode = sys.argv[1] if len(sys.argv) > 1 else "unix"
321
+ c = SesameClient.http() if mode == "http" else SesameClient.unix()
322
+ print("status:", c.status())
323
+ print("methods:", len(c.discover_names()))
@@ -0,0 +1,11 @@
1
+ # 宣言的メタデータ (setuptools 30.3+/2017 以降が読む)。PEP 621 の pyproject [project] は
2
+ # setuptools>=61 が必須で、古い環境では name/version を黙って無視し UNKNOWN-0.0.0 の空 wheel に
3
+ # なる (初学者が古い Python で詰まる)。単一モジュールの薄いクライアントは互換性を優先し setup.cfg にする。
4
+ [metadata]
5
+ name = sesame-client
6
+ version = 0.1.0
7
+ description = Thin zero-dependency Python client for `sesame serve` (JSON-RPC over UDS/stdio/HTTP).
8
+
9
+ [options]
10
+ py_modules = sesame_client
11
+ python_requires = >=3.8