@switchbot/openapi-cli 2.6.3 → 2.7.2

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.
@@ -1,10 +1,14 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { printJson, isJsonMode } from '../utils/output.js';
4
+ import { printJson, isJsonMode, exitWithError } from '../utils/output.js';
5
5
  import { getEffectiveCatalog } from '../devices/catalog.js';
6
6
  import { configFilePath, listProfiles, readProfileMeta } from '../config.js';
7
- import { describeCache } from '../devices/cache.js';
7
+ import { describeCache, resetListCache } from '../devices/cache.js';
8
+ import { DAILY_QUOTA, todayUsage } from '../utils/quota.js';
9
+ import { AGENT_BOOTSTRAP_SCHEMA_VERSION } from './agent-bootstrap.js';
10
+ import { CATALOG_SCHEMA_VERSION } from '../devices/catalog.js';
11
+ import { createSwitchBotMcpServer, listRegisteredTools } from './mcp.js';
8
12
  export const DOCTOR_SCHEMA_VERSION = 1;
9
13
  async function checkCredentials() {
10
14
  const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
@@ -160,15 +164,148 @@ function checkCache() {
160
164
  function checkQuotaFile() {
161
165
  const p = path.join(os.homedir(), '.switchbot', 'quota.json');
162
166
  if (!fs.existsSync(p)) {
163
- return { name: 'quota', status: 'ok', detail: 'no quota file yet (will be created on first call)' };
167
+ return {
168
+ name: 'quota',
169
+ status: 'ok',
170
+ detail: {
171
+ path: p,
172
+ percentUsed: 0,
173
+ remaining: DAILY_QUOTA,
174
+ message: 'no quota file yet (will be created on first call)',
175
+ },
176
+ };
164
177
  }
165
178
  try {
166
179
  const raw = fs.readFileSync(p, 'utf-8');
167
180
  JSON.parse(raw);
168
- return { name: 'quota', status: 'ok', detail: p };
169
181
  }
170
182
  catch {
171
- return { name: 'quota', status: 'warn', detail: `${p} unreadable/malformed — run 'switchbot quota reset'` };
183
+ return {
184
+ name: 'quota',
185
+ status: 'warn',
186
+ detail: { path: p, message: `unreadable/malformed — run 'switchbot quota reset'` },
187
+ };
188
+ }
189
+ // P9: surface headroom so agents can decide when to slow down or pause.
190
+ // Quota resets at local midnight (the quota counter buckets by local
191
+ // date), so project the next reset to the next 00:00:00 local.
192
+ const usage = todayUsage();
193
+ const percentUsed = Math.round((usage.total / DAILY_QUOTA) * 100);
194
+ const now = new Date();
195
+ const reset = new Date(now);
196
+ reset.setHours(24, 0, 0, 0); // next local midnight
197
+ const status = percentUsed > 80 ? 'warn' : 'ok';
198
+ const recommendation = percentUsed > 90
199
+ ? 'over 90% used — consider --no-quota for read-only triage or rescheduling work after the reset'
200
+ : percentUsed > 80
201
+ ? 'over 80% used — avoid bulk operations until the daily reset'
202
+ : 'headroom available';
203
+ return {
204
+ name: 'quota',
205
+ status,
206
+ detail: {
207
+ path: p,
208
+ percentUsed,
209
+ remaining: usage.remaining,
210
+ total: usage.total,
211
+ dailyCap: DAILY_QUOTA,
212
+ projectedResetTime: reset.toISOString(),
213
+ recommendation,
214
+ },
215
+ };
216
+ }
217
+ function checkCatalogSchema() {
218
+ // P9: sentinel against silent drift between the catalog shape and the
219
+ // agent-bootstrap payload. Both constants are exported from their
220
+ // respective modules; if a future refactor changes one without the
221
+ // other, this check fails so consumers (agents) learn before the
222
+ // mismatch corrupts their mental model.
223
+ const match = CATALOG_SCHEMA_VERSION === AGENT_BOOTSTRAP_SCHEMA_VERSION;
224
+ return {
225
+ name: 'catalog-schema',
226
+ status: match ? 'ok' : 'fail',
227
+ detail: {
228
+ catalogSchemaVersion: CATALOG_SCHEMA_VERSION,
229
+ bootstrapExpectsVersion: AGENT_BOOTSTRAP_SCHEMA_VERSION,
230
+ match,
231
+ message: match
232
+ ? 'catalog and agent-bootstrap schemaVersion aligned'
233
+ : 'catalog and agent-bootstrap schemaVersion have drifted — bump in lockstep',
234
+ },
235
+ };
236
+ }
237
+ function checkAudit() {
238
+ // P9: surface recent command failures so agents / ops can spot problems
239
+ // before they page. When --audit-log was never enabled, the file won't
240
+ // exist — report that cleanly rather than as an error.
241
+ const p = path.join(os.homedir(), '.switchbot', 'audit.log');
242
+ if (!fs.existsSync(p)) {
243
+ return {
244
+ name: 'audit',
245
+ status: 'ok',
246
+ detail: {
247
+ path: p,
248
+ enabled: false,
249
+ message: 'audit log not present (enable with --audit-log)',
250
+ },
251
+ };
252
+ }
253
+ try {
254
+ const raw = fs.readFileSync(p, 'utf-8');
255
+ const since = Date.now() - 24 * 60 * 60 * 1000;
256
+ const recent = [];
257
+ let total = 0;
258
+ for (const line of raw.split('\n')) {
259
+ const trimmed = line.trim();
260
+ if (!trimmed)
261
+ continue;
262
+ let rec;
263
+ try {
264
+ rec = JSON.parse(trimmed);
265
+ }
266
+ catch {
267
+ continue;
268
+ }
269
+ if (rec.result !== 'error')
270
+ continue;
271
+ total += 1;
272
+ const ts = rec.t ? Date.parse(rec.t) : NaN;
273
+ if (Number.isFinite(ts) && ts >= since) {
274
+ recent.push({
275
+ t: rec.t,
276
+ command: rec.command ?? '?',
277
+ deviceId: rec.deviceId,
278
+ error: rec.error ?? 'unknown',
279
+ });
280
+ }
281
+ }
282
+ // Cap the report to the 10 most recent so the doctor payload stays
283
+ // bounded even on a log with thousands of errors.
284
+ recent.sort((a, b) => (a.t < b.t ? 1 : -1));
285
+ const clipped = recent.slice(0, 10);
286
+ const status = recent.length > 0 ? 'warn' : 'ok';
287
+ return {
288
+ name: 'audit',
289
+ status,
290
+ detail: {
291
+ path: p,
292
+ enabled: true,
293
+ totalErrors: total,
294
+ errorsLast24h: recent.length,
295
+ recent: clipped,
296
+ },
297
+ };
298
+ }
299
+ catch (err) {
300
+ return {
301
+ name: 'audit',
302
+ status: 'warn',
303
+ detail: {
304
+ path: p,
305
+ enabled: true,
306
+ message: `could not read audit log: ${err instanceof Error ? err.message : String(err)}`,
307
+ },
308
+ };
172
309
  }
173
310
  }
174
311
  function checkNodeVersion() {
@@ -210,10 +347,160 @@ function checkMqtt() {
210
347
  detail: "unavailable — configure credentials first (see credentials check above)",
211
348
  };
212
349
  }
350
+ async function checkMqttProbe() {
351
+ // P10: live-probe the MQTT broker. Only runs when --probe is passed.
352
+ // Does not subscribe — just connects + disconnects to verify the
353
+ // credential + TLS handshake works end-to-end. Hard 5s timeout so
354
+ // a misbehaving broker never wedges the doctor command.
355
+ const { fetchMqttCredential } = await import('../mqtt/credential.js');
356
+ const { SwitchBotMqttClient } = await import('../mqtt/client.js');
357
+ const token = process.env.SWITCHBOT_TOKEN;
358
+ const secret = process.env.SWITCHBOT_SECRET;
359
+ let creds = null;
360
+ if (token && secret) {
361
+ creds = { token, secret };
362
+ }
363
+ else {
364
+ const file = configFilePath();
365
+ if (fs.existsSync(file)) {
366
+ try {
367
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf-8'));
368
+ if (cfg.token && cfg.secret) {
369
+ creds = { token: cfg.token, secret: cfg.secret };
370
+ }
371
+ }
372
+ catch { /* fall through */ }
373
+ }
374
+ }
375
+ if (!creds) {
376
+ return {
377
+ name: 'mqtt',
378
+ status: 'warn',
379
+ detail: { probe: 'skipped', reason: 'no credentials configured' },
380
+ };
381
+ }
382
+ const deadline = new Promise((_, reject) => setTimeout(() => reject(new Error('probe timeout after 5000ms')), 5000));
383
+ try {
384
+ const cred = await Promise.race([fetchMqttCredential(creds.token, creds.secret), deadline]);
385
+ const client = new SwitchBotMqttClient(cred);
386
+ await Promise.race([client.connect(), deadline]);
387
+ await client.disconnect();
388
+ return {
389
+ name: 'mqtt',
390
+ status: 'ok',
391
+ detail: { probe: 'connected', brokerUrl: cred.brokerUrl, region: cred.region },
392
+ };
393
+ }
394
+ catch (err) {
395
+ return {
396
+ name: 'mqtt',
397
+ status: 'warn',
398
+ detail: { probe: 'failed', reason: err instanceof Error ? err.message : String(err) },
399
+ };
400
+ }
401
+ }
402
+ function checkMcp() {
403
+ // P10: dry-run instantiation of the MCP server to catch tool-registration
404
+ // regressions. No network I/O, no token needed. If createSwitchBotMcpServer
405
+ // throws (e.g. duplicate tool name, schema build error) the check fails.
406
+ try {
407
+ const server = createSwitchBotMcpServer();
408
+ const tools = listRegisteredTools(server);
409
+ return {
410
+ name: 'mcp',
411
+ status: 'ok',
412
+ detail: {
413
+ serverInstantiated: true,
414
+ toolCount: tools.length,
415
+ tools,
416
+ transportsAvailable: ['stdio', 'http'],
417
+ message: `${tools.length} tools registered; no network probe`,
418
+ },
419
+ };
420
+ }
421
+ catch (err) {
422
+ return {
423
+ name: 'mcp',
424
+ status: 'fail',
425
+ detail: {
426
+ serverInstantiated: false,
427
+ error: err instanceof Error ? err.message : String(err),
428
+ },
429
+ };
430
+ }
431
+ }
432
+ const CHECK_REGISTRY = [
433
+ { name: 'node', description: 'Node.js version compatibility', run: () => checkNodeVersion() },
434
+ { name: 'credentials', description: 'credentials file present and parseable', run: () => checkCredentials() },
435
+ { name: 'profiles', description: 'profile definitions valid', run: () => checkProfiles() },
436
+ { name: 'catalog', description: 'catalog loads', run: () => checkCatalog() },
437
+ { name: 'catalog-schema', description: 'catalog vs agent-bootstrap version aligned', run: () => checkCatalogSchema() },
438
+ { name: 'cache', description: 'device cache state', run: () => checkCache() },
439
+ { name: 'quota', description: 'API quota headroom', run: () => checkQuotaFile() },
440
+ { name: 'clock', description: 'system clock skew', run: () => checkClockSkew() },
441
+ {
442
+ name: 'mqtt',
443
+ description: 'MQTT credentials (+ --probe for live broker handshake)',
444
+ run: ({ probe }) => (probe ? checkMqttProbe() : checkMqtt()),
445
+ },
446
+ { name: 'mcp', description: 'MCP server instantiable + tool count', run: () => checkMcp() },
447
+ { name: 'audit', description: 'recent command errors (last 24h)', run: () => checkAudit() },
448
+ ];
449
+ function applyFixes(checks, writeOk) {
450
+ const results = [];
451
+ for (const c of checks) {
452
+ if (c.name === 'cache' && c.status !== 'ok') {
453
+ if (writeOk) {
454
+ try {
455
+ resetListCache();
456
+ results.push({ check: 'cache', action: 'cache-cleared', applied: true });
457
+ }
458
+ catch (err) {
459
+ results.push({
460
+ check: 'cache',
461
+ action: 'cache-clear',
462
+ applied: false,
463
+ message: err instanceof Error ? err.message : String(err),
464
+ });
465
+ }
466
+ }
467
+ else {
468
+ results.push({
469
+ check: 'cache',
470
+ action: 'cache-clear',
471
+ applied: false,
472
+ message: 'pass --yes to apply',
473
+ });
474
+ }
475
+ }
476
+ else if (c.name === 'catalog-schema' && c.status !== 'ok') {
477
+ results.push({
478
+ check: 'catalog-schema',
479
+ action: 'manual',
480
+ applied: false,
481
+ message: "drift detected — run 'switchbot capabilities --reload' to refresh overlay",
482
+ });
483
+ }
484
+ else if (c.name === 'credentials' && c.status === 'fail') {
485
+ results.push({
486
+ check: 'credentials',
487
+ action: 'manual',
488
+ applied: false,
489
+ message: "run 'switchbot config set-token' to configure credentials",
490
+ });
491
+ }
492
+ }
493
+ return results;
494
+ }
213
495
  export function registerDoctorCommand(program) {
214
496
  program
215
497
  .command('doctor')
216
- .description('Self-check: credentials, catalog, cache, quota, profiles, Node version')
498
+ .description('Self-check the SwitchBot CLI setup: credentials, catalog, cache, quota, MQTT, MCP')
499
+ .option('--section <names>', 'Comma-separated list of checks to run (see --list for names)')
500
+ .option('--list', 'Print the registered check names and exit 0 without running any check')
501
+ .option('--fix', 'Apply safe, reversible remediations for failing checks (e.g. clear stale cache)')
502
+ .option('--yes', 'Required together with --fix to confirm write actions')
503
+ .option('--probe', 'Perform live-probe variant of checks that support it (mqtt)')
217
504
  .addHelpText('after', `
218
505
  Runs a battery of local sanity checks and exits with code 0 only when every
219
506
  check is 'ok'. 'warn' → exit 0 (informational); 'fail' → exit 1.
@@ -221,18 +508,52 @@ check is 'ok'. 'warn' → exit 0 (informational); 'fail' → exit 1.
221
508
  Examples:
222
509
  $ switchbot doctor
223
510
  $ switchbot --json doctor | jq '.checks[] | select(.status != "ok")'
511
+ $ switchbot doctor --list
512
+ $ switchbot doctor --section credentials,mcp --json
513
+ $ switchbot doctor --probe --json
514
+ $ switchbot doctor --fix --yes --json
224
515
  `)
225
- .action(async () => {
226
- const checks = [
227
- checkNodeVersion(),
228
- await checkCredentials(),
229
- checkProfiles(),
230
- checkCatalog(),
231
- checkCache(),
232
- checkQuotaFile(),
233
- await checkClockSkew(),
234
- checkMqtt(),
235
- ];
516
+ .action(async (opts) => {
517
+ // --list: print the registry and exit 0.
518
+ if (opts.list) {
519
+ if (isJsonMode()) {
520
+ printJson({
521
+ checks: CHECK_REGISTRY.map((c) => ({ name: c.name, description: c.description })),
522
+ });
523
+ }
524
+ else {
525
+ console.log('Available checks:');
526
+ for (const c of CHECK_REGISTRY) {
527
+ console.log(` ${c.name.padEnd(16)} ${c.description}`);
528
+ }
529
+ }
530
+ return;
531
+ }
532
+ // --section: run only the named subset, dedup and validate.
533
+ let selected = CHECK_REGISTRY;
534
+ if (opts.section) {
535
+ const raw = opts.section.split(',').map((s) => s.trim()).filter(Boolean);
536
+ const names = Array.from(new Set(raw));
537
+ const known = new Set(CHECK_REGISTRY.map((c) => c.name));
538
+ const unknown = names.filter((n) => !known.has(n));
539
+ if (unknown.length > 0) {
540
+ exitWithError({
541
+ code: 2,
542
+ kind: 'usage',
543
+ message: `Unknown check name(s): ${unknown.join(', ')}. Valid: ${CHECK_REGISTRY.map((c) => c.name).join(', ')}`,
544
+ });
545
+ return;
546
+ }
547
+ const order = new Map(CHECK_REGISTRY.map((c, i) => [c.name, i]));
548
+ selected = names
549
+ .map((n) => CHECK_REGISTRY.find((c) => c.name === n))
550
+ .sort((a, b) => (order.get(a.name) - order.get(b.name)));
551
+ }
552
+ const runOpts = { probe: Boolean(opts.probe) };
553
+ const checks = [];
554
+ for (const def of selected) {
555
+ checks.push(await def.run(runOpts));
556
+ }
236
557
  const summary = {
237
558
  ok: checks.filter((c) => c.status === 'ok').length,
238
559
  warn: checks.filter((c) => c.status === 'warn').length,
@@ -240,20 +561,27 @@ Examples:
240
561
  };
241
562
  const overallFail = summary.fail > 0;
242
563
  const overall = overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok';
564
+ let fixes;
565
+ if (opts.fix) {
566
+ fixes = applyFixes(checks, Boolean(opts.yes));
567
+ }
243
568
  if (isJsonMode()) {
244
569
  // Stable contract (locked as doctor.schemaVersion=1):
245
570
  // { ok: boolean, overall: 'ok'|'warn'|'fail', generatedAt, schemaVersion,
246
571
  // summary: { ok, warn, fail }, checks: [{ name, status, detail }] }
247
572
  // `ok` is an alias of (overall === 'ok') — agents prefer the boolean,
248
573
  // humans prefer the string; both are provided.
249
- printJson({
574
+ const payload = {
250
575
  ok: overall === 'ok',
251
576
  overall,
252
577
  generatedAt: new Date().toISOString(),
253
578
  schemaVersion: DOCTOR_SCHEMA_VERSION,
254
579
  summary,
255
580
  checks,
256
- });
581
+ };
582
+ if (fixes !== undefined)
583
+ payload.fixes = fixes;
584
+ printJson(payload);
257
585
  }
258
586
  else {
259
587
  for (const c of checks) {
@@ -263,6 +591,14 @@ Examples:
263
591
  }
264
592
  console.log('');
265
593
  console.log(`${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`);
594
+ if (fixes && fixes.length > 0) {
595
+ console.log('');
596
+ console.log('Fixes:');
597
+ for (const f of fixes) {
598
+ const marker = f.applied ? '✓' : '-';
599
+ console.log(` ${marker} ${f.check}: ${f.action}${f.message ? ' — ' + f.message : ''}`);
600
+ }
601
+ }
266
602
  }
267
603
  if (overallFail)
268
604
  process.exit(1);
@@ -1,6 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
- import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
3
+ import { printJson, isJsonMode, handleError, UsageError, emitStreamHeader } from '../utils/output.js';
4
4
  import { intArg, stringArg, durationArg } from '../utils/arg-parsers.js';
5
5
  import { parseDurationToMs } from '../utils/flags.js';
6
6
  import { parseFilterExpr, matchClause, FilterSyntaxError } from '../utils/filter.js';
@@ -19,6 +19,16 @@ import { deviceHistoryStore } from '../mcp/device-history.js';
19
19
  const DEFAULT_PORT = 3000;
20
20
  const DEFAULT_PATH = '/';
21
21
  const MAX_BODY_BYTES = 1_000_000;
22
+ /**
23
+ * P6: unified-envelope schema version shared by webhook and MQTT event output.
24
+ *
25
+ * The same key set now appears on both `events tail` (webhook) and
26
+ * `events mqtt-tail` (MQTT) output lines so downstream JSONL consumers can
27
+ * use a single parser regardless of source. Old fields are kept for one
28
+ * minor window so existing consumers keep working — see README and
29
+ * CHANGELOG for the deprecation schedule.
30
+ */
31
+ export const EVENTS_SCHEMA_VERSION = '1';
22
32
  function extractEventId(parsed) {
23
33
  if (!parsed || typeof parsed !== 'object')
24
34
  return null;
@@ -30,13 +40,27 @@ function extractEventId(parsed) {
30
40
  return ctx.eventId;
31
41
  return null;
32
42
  }
33
- function matchFilter(body, clauses) {
43
+ function extractDeviceId(parsed) {
44
+ if (!parsed || typeof parsed !== 'object')
45
+ return null;
46
+ const p = parsed;
47
+ const ctx = p.context ?? p;
48
+ const mac = ctx.deviceMac;
49
+ if (typeof mac === 'string' && mac.length > 0)
50
+ return mac;
51
+ const id = ctx.deviceId;
52
+ if (typeof id === 'string' && id.length > 0)
53
+ return id;
54
+ return null;
55
+ }
56
+ function matchFilterDetail(body, clauses) {
34
57
  if (!clauses || clauses.length === 0)
35
- return true;
58
+ return { matched: true, matchedKeys: [] };
36
59
  if (!body || typeof body !== 'object')
37
- return false;
60
+ return { matched: false, matchedKeys: [] };
38
61
  const b = body;
39
62
  const ctx = (b.context ?? b);
63
+ const hitKeys = [];
40
64
  for (const c of clauses) {
41
65
  let candidate;
42
66
  if (c.key === 'deviceId') {
@@ -49,9 +73,10 @@ function matchFilter(body, clauses) {
49
73
  candidate = typeof t === 'string' ? t : '';
50
74
  }
51
75
  if (!matchClause(candidate, c))
52
- return false;
76
+ return { matched: false, matchedKeys: [] };
77
+ hitKeys.push(c.key);
53
78
  }
54
- return true;
79
+ return { matched: true, matchedKeys: hitKeys };
55
80
  }
56
81
  const EVENT_FILTER_KEYS = ['deviceId', 'type'];
57
82
  function parseFilter(flag) {
@@ -108,11 +133,22 @@ export function startReceiver(port, pathMatch, filter, onEvent) {
108
133
  catch {
109
134
  // keep raw
110
135
  }
111
- const matched = matchFilter(body, filter);
136
+ const { matched, matchedKeys } = matchFilterDetail(body, filter);
137
+ const t = new Date().toISOString();
138
+ const urlPath = req.url ?? '/';
112
139
  onEvent({
113
- t: new Date().toISOString(),
140
+ schemaVersion: EVENTS_SCHEMA_VERSION,
141
+ source: 'webhook',
142
+ kind: 'event',
143
+ t,
144
+ eventId: extractEventId(body),
145
+ deviceId: extractDeviceId(body),
146
+ topic: urlPath,
147
+ payload: body,
148
+ matchedKeys,
149
+ // Legacy mirror:
114
150
  remote: `${req.socket.remoteAddress ?? ''}:${req.socket.remotePort ?? ''}`,
115
- path: req.url ?? '/',
151
+ path: urlPath,
116
152
  body,
117
153
  matched,
118
154
  });
@@ -143,9 +179,14 @@ SwitchBot posts events to a single webhook URL configured via:
143
179
  the port to the internet yourself (ngrok/cloudflared/reverse proxy) and
144
180
  point the SwitchBot webhook at that public URL.
145
181
 
146
- Output (JSONL, one event per line):
147
- { "t": "<ISO>", "remote": "<ip:port>", "path": "/",
148
- "body": <parsed JSON or raw string>, "matched": true }
182
+ Output (JSONL, one event per line; P6 unified envelope v2.7+):
183
+ { "schemaVersion": "1", "source": "webhook", "kind": "event",
184
+ "t": "<ISO>", "eventId": <string|null>, "deviceId": <string|null>,
185
+ "topic": "/", // = path
186
+ "payload": <parsed JSON or raw string>,
187
+ "matchedKeys": ["deviceId"], // which filter clauses matched
188
+ // Legacy fields kept for one minor window (removed in v3.0):
189
+ "remote": "<ip:port>", "path": "/", "body": <payload>, "matched": true }
149
190
 
150
191
  Filter grammar: comma-separated clauses (AND-ed). Each clause is one of
151
192
  key=value — case-insensitive substring
@@ -179,6 +220,10 @@ Examples:
179
220
  const forTimer = forMs !== null && forMs > 0
180
221
  ? setTimeout(() => ac.abort(), forMs)
181
222
  : null;
223
+ // P7: streaming JSON contract — first line under --json is the
224
+ // stream header (webhook events arrive via push cadence).
225
+ if (isJsonMode())
226
+ emitStreamHeader({ eventKind: 'event', cadence: 'push' });
182
227
  await new Promise((resolve, reject) => {
183
228
  let server = null;
184
229
  try {
@@ -244,14 +289,20 @@ Connects to the SwitchBot MQTT service using your existing credentials
244
289
  (SWITCHBOT_TOKEN + SWITCHBOT_SECRET or ~/.switchbot/config.json).
245
290
  No additional MQTT configuration required.
246
291
 
247
- Output (JSONL, one event per line):
248
- { "t": "<ISO>", "eventId": "<uuid>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
292
+ Output (JSONL, one event per line; P6 unified envelope v2.7+):
293
+ { "schemaVersion": "1", "source": "mqtt", "kind": "event",
294
+ "t": "<ISO>", "eventId": "<uuid>", "deviceId": <string|null>,
295
+ "topic": "<mqtt-topic>",
296
+ "payload": <parsed JSON or raw string> }
249
297
 
250
- Control records (interleaved, no "payload" field use type-prefix to filter):
251
- { "type": "__session_start", "at": "<ISO>", "eventId": "<uuid>", "state": "connecting" } before credential fetch (JSON mode only)
252
- { "type": "__connect", "at": "<ISO>", "eventId": "<uuid>" } first successful connect
253
- { "type": "__reconnect", "at": "<ISO>", "eventId": "<uuid>" } connect after a disconnect
254
- { "type": "__disconnect", "at": "<ISO>", "eventId": "<uuid>" } reconnecting or failed
298
+ Control records (interleaved, kind: "control" — filter by the "kind" field):
299
+ { "schemaVersion": "1", "source": "mqtt", "kind": "control",
300
+ "controlKind": "session_start"|"connect"|"reconnect"|"disconnect"|"heartbeat",
301
+ "t": "<ISO>", "eventId": "<uuid>",
302
+ "state": "connecting" // present on session_start only
303
+ // Legacy fields kept for one minor window (removed in v3.0):
304
+ "type": "__session_start"|"__connect"|"__reconnect"|"__disconnect",
305
+ "at": "<ISO>" }
255
306
 
256
307
  Reconnect policy: the MQTT client retries with exponential backoff
257
308
  (1s → 30s capped, forever) while the credential is still valid; if the
@@ -346,15 +397,27 @@ Examples:
346
397
  if (!isJsonMode()) {
347
398
  console.error('Fetching MQTT credentials from SwitchBot service…');
348
399
  }
400
+ // P7: streaming JSON contract — first line under --json is the stream
401
+ // header (mqtt events arrive via push cadence). Must emit BEFORE
402
+ // __session_start so header is always the very first line.
403
+ if (isJsonMode())
404
+ emitStreamHeader({ eventKind: 'event', cadence: 'push' });
349
405
  // Emit a __session_start envelope immediately (before any credential
350
406
  // fetch) so JSON consumers can distinguish "connecting" from "never
351
407
  // connected" even when mqtt-tail exits before the broker connects.
352
408
  if (isJsonMode()) {
409
+ const sessionStartAt = new Date().toISOString();
353
410
  printJson({
354
- type: '__session_start',
355
- at: new Date().toISOString(),
411
+ schemaVersion: EVENTS_SCHEMA_VERSION,
412
+ source: 'mqtt',
413
+ kind: 'control',
414
+ controlKind: 'session_start',
415
+ t: sessionStartAt,
356
416
  eventId: crypto.randomUUID(),
357
417
  state: 'connecting',
418
+ // Legacy (deprecated as of v2.7; removed in v3.0):
419
+ type: '__session_start',
420
+ at: sessionStartAt,
358
421
  });
359
422
  }
360
423
  const credential = await fetchMqttCredential(loaded.token, loaded.secret);
@@ -389,7 +452,16 @@ Examples:
389
452
  // Default behavior: record history + print to stdout
390
453
  const { deviceId, deviceType } = parseSinkEvent(parsed);
391
454
  deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
392
- const record = { t, eventId, topic: msgTopic, payload: parsed };
455
+ const record = {
456
+ schemaVersion: EVENTS_SCHEMA_VERSION,
457
+ source: 'mqtt',
458
+ kind: 'event',
459
+ t,
460
+ eventId,
461
+ deviceId: deviceId ?? null,
462
+ topic: msgTopic,
463
+ payload: parsed,
464
+ };
393
465
  if (isJsonMode()) {
394
466
  printJson(record);
395
467
  }
@@ -405,7 +477,24 @@ Examples:
405
477
  let mqttFailed = false;
406
478
  let hasConnectedBefore = false;
407
479
  const emitControl = (kind) => {
408
- const ctl = { type: kind, at: new Date().toISOString(), eventId: crypto.randomUUID() };
480
+ const at = new Date().toISOString();
481
+ const controlKindMap = {
482
+ __connect: 'connect',
483
+ __reconnect: 'reconnect',
484
+ __disconnect: 'disconnect',
485
+ __heartbeat: 'heartbeat',
486
+ };
487
+ const ctl = {
488
+ schemaVersion: EVENTS_SCHEMA_VERSION,
489
+ source: 'mqtt',
490
+ kind: 'control',
491
+ controlKind: controlKindMap[kind],
492
+ t: at,
493
+ eventId: crypto.randomUUID(),
494
+ // Legacy (deprecated as of v2.7; removed in v3.0):
495
+ type: kind,
496
+ at,
497
+ };
409
498
  // Control events always go to stdout as JSONL so consumers that
410
499
  // filter real events by presence of `payload` can skip them.
411
500
  if (isJsonMode()) {