Skip to content

Commit 82ad7be

Browse files
chenliuyunchenliuyun
andauthored
3.5.0: device intelligence, UX polish & correctness (#44)
* feat: AI device controlType fallback with typeSource signal * feat: inject deviceId/deviceType into all status responses * feat: warn on AI devices using controlType fallback * feat: add scenes run and history list aliases * feat: expand setColor to ~148 CSS Level 4 named colors * feat: add --once shorthand for --max 1 on watch * feat: add --plan flag for inline JSON on plan run * feat: layered health audit with expected/unexpected error breakdown * feat: add [n/total] progress counter to batch human output * fix: show "Unknown Device" for devices with no type from API AI MindClip and AI PinNote report neither deviceType nor controlType. Previously rendered as empty/dash; now shows "Unknown Device" with a stderr warning in describe mode. * fix: v3.5.0 correctness fixes from real-device validation - Status identity injection: ensure deviceId/deviceType override raw API fields - Cache stores typeSource to distinguish deviceType vs controlType origin - Cache-to-DeviceList synthesis respects typeSource (prevents controlType leaking as deviceType) - --plan invalid JSON now throws UsageError (exit 2) instead of runtime error - Tests updated for typeSource, controlType fallback warning, and stricter assertions --------- Co-authored-by: chenliuyun <chenliuyun@onero.com>
1 parent fbe9e9d commit 82ad7be

23 files changed

Lines changed: 1012 additions & 55 deletions

src/commands/batch.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,9 +386,13 @@ Examples:
386386
return;
387387
}
388388

389+
const totalDevices = resolved.ids.length;
390+
const deviceIndices = new Map(resolved.ids.map((id, i) => [id, i + 1]));
391+
389392
const startedAt = Date.now();
390393

391394
const outcomes = await runPool(resolved.ids, concurrency, staggerMs, async (id) => {
395+
const stepIdx = deviceIndices.get(id)!;
392396
const stepStart = Date.now();
393397
const startedIso = new Date(stepStart).toISOString();
394398
try {
@@ -403,7 +407,7 @@ Examples:
403407
const replayed =
404408
typeof result === 'object' && result !== null && (result as { replayed?: boolean }).replayed === true;
405409
if (!isJsonMode()) {
406-
console.log(`✓ ${id}: ${cmd}${replayed ? ' (replayed)' : ''}`);
410+
console.log(`[${stepIdx}/${totalDevices}] ${id}: ${cmd}${replayed ? ' (replayed)' : ''}`);
407411
}
408412
return {
409413
ok: true as const,
@@ -428,7 +432,7 @@ Examples:
428432
}
429433
const errorPayload = buildErrorPayload(err);
430434
if (!isJsonMode()) {
431-
console.error(`✗ ${id}: ${errorPayload.message}`);
435+
console.error(`[${stepIdx}/${totalDevices}] ${id}: ${errorPayload.message}`);
432436
}
433437
return {
434438
ok: false as const,

src/commands/devices.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,21 @@ const EXPAND_HINTS: Record<string, { command: string; flags: string }> = {
4444
'Relay Switch 2PM': { command: 'setMode', flags: '--channel 1 --mode edge' },
4545
};
4646

47-
function annotateStatusPayload(deviceId: string, body: Record<string, unknown>): Record<string, unknown> {
48-
const annotated = { ...body };
47+
function annotateStatusPayload(
48+
deviceId: string,
49+
body: Record<string, unknown>,
50+
): Record<string, unknown> {
51+
const cached = getCachedDevice(deviceId);
52+
const deviceType = cached?.type ?? '';
53+
const annotated: Record<string, unknown> = { deviceId, deviceType, ...body };
54+
annotated.deviceId = deviceId;
55+
annotated.deviceType = deviceType;
4956
if (Object.keys(body).length === 0) {
5057
annotated.supported = false;
5158
annotated.note = 'this device does not expose cloud status';
5259
return annotated;
5360
}
5461

55-
const cached = getCachedDevice(deviceId);
5662
const looksLikeMeter = cached?.type?.toLowerCase().includes('meter') ?? false;
5763
const staleZeroReading =
5864
looksLikeMeter &&
@@ -221,7 +227,7 @@ Examples:
221227
rows.push([
222228
d.deviceId,
223229
d.deviceName,
224-
d.deviceType || '—',
230+
d.deviceType || d.controlType || 'Unknown Device',
225231
'physical',
226232
d.controlType || '—',
227233
d.familyName || '—',
@@ -887,12 +893,20 @@ Examples:
887893
return;
888894
}
889895

896+
if (result.typeSource === 'controlType') {
897+
const deviceName = (device as Device).deviceName ?? deviceId;
898+
console.error(`warning: ${deviceName} (${deviceId}): deviceType not reported by API, using controlType "${result.controlType}". Capabilities may be limited.`);
899+
} else if (typeName === 'Unknown Device') {
900+
const deviceName = (device as Device).deviceName ?? deviceId;
901+
console.error(`warning: ${deviceName} (${deviceId}): neither deviceType nor controlType reported by API. Capabilities may be limited.`);
902+
}
903+
890904
if (isPhysical) {
891905
const physical = device as Device;
892906
printKeyValue({
893907
deviceId: physical.deviceId,
894908
deviceName: physical.deviceName,
895-
deviceType: physical.deviceType || '—',
909+
deviceType: physical.deviceType || physical.controlType || 'Unknown Device',
896910
controlType: physical.controlType || '—',
897911
family: physical.familyName || '—',
898912
roomID: physical.roomID || '—',

src/commands/history.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Examples:
3636

3737
history
3838
.command('show')
39+
.alias('list')
3940
.description('Print recent audit entries')
4041
.option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
4142
.option('--limit <n>', 'Show only the last N entries', intArg('--limit', { min: 1 }))

src/commands/plan.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,18 +493,31 @@ against the live API without executing any mutations.
493493
.option('--yes', 'Authorize destructive commands (e.g. Smart Lock unlock, Garage open)')
494494
.option('--require-approval', 'Prompt for confirmation before each destructive step (TTY only; mutually exclusive with --json)')
495495
.option('--continue-on-error', 'Keep running after a failed step (default: stop at first error)')
496+
.option('--plan <json>', 'Inline plan JSON (alternative to file argument or stdin)')
496497
.action(
497498
async (
498499
file: string | undefined,
499-
options: { yes?: boolean; requireApproval?: boolean; continueOnError?: boolean },
500+
options: { yes?: boolean; requireApproval?: boolean; continueOnError?: boolean; plan?: string },
500501
) => {
501502
if (options.requireApproval && isJsonMode()) {
502503
console.error('error: --require-approval cannot be used with --json (no TTY available for prompts)');
503504
process.exit(1);
504505
}
506+
if (options.plan !== undefined && file !== undefined) {
507+
console.error('error: --plan and a file argument are mutually exclusive.');
508+
process.exit(2);
509+
}
505510
let raw: unknown;
506511
try {
507-
raw = await readPlanSource(file);
512+
if (options.plan !== undefined) {
513+
try {
514+
raw = JSON.parse(options.plan);
515+
} catch (err) {
516+
throw new UsageError(`--plan is not valid JSON: ${(err as Error).message}`);
517+
}
518+
} else {
519+
raw = await readPlanSource(file);
520+
}
508521
} catch (err) {
509522
handleError(err);
510523
}

src/commands/scenes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Examples:
5151
// switchbot scenes execute <sceneId>
5252
scenes
5353
.command('execute')
54+
.alias('run')
5455
.description('Execute a manual scene by its ID')
5556
.argument('<sceneId>', 'Scene ID from "scenes list"')
5657
.addHelpText('after', `

src/commands/watch.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export function registerWatchCommand(devices: Command): void {
9191
'30s',
9292
)
9393
.option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
94+
.option('--once', 'Stop after one tick (shorthand for --max 1)')
9495
.option('--for <dur>', 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg('--for'))
9596
.option('--include-unchanged', 'Emit a tick even when no field changed')
9697
.option('--initial <mode>', 'How to handle the first poll: snapshot | emit | skip (default: snapshot)', enumArg('--initial', INITIAL_MODES), 'snapshot')
@@ -128,12 +129,20 @@ Examples:
128129
name?: string;
129130
interval: string;
130131
max?: string;
132+
once?: boolean;
131133
for?: string;
132134
includeUnchanged?: boolean;
133135
initial: 'snapshot' | 'emit' | 'skip';
134136
},
135137
) => {
136138
try {
139+
if (options.once && options.max !== undefined) {
140+
throw new UsageError('--once and --max are mutually exclusive.');
141+
}
142+
if (options.once) {
143+
options.max = '1';
144+
}
145+
137146
const allIds = [...deviceIds];
138147
if (options.name) {
139148
const resolved = resolveDeviceId(undefined, options.name);

src/devices/cache.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const DEFAULT_STATUS_GC_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
3131

3232
export interface CachedDevice {
3333
type: string;
34+
typeSource?: 'deviceType' | 'controlType' | 'remoteType';
3435
name: string;
3536
category: 'physical' | 'ir';
3637
hubDeviceId?: string;
@@ -151,9 +152,8 @@ export function updateCacheFromDeviceList(body: DeviceListBodyShape): void {
151152
for (const d of body.deviceList) {
152153
if (!d.deviceId) continue;
153154
devices[d.deviceId] = {
154-
// Some real devices omit deviceType entirely (for example AI accessories).
155-
// Keep them in cache with an empty type string rather than dropping the row.
156-
type: d.deviceType ?? '',
155+
type: d.deviceType || d.controlType || 'Unknown Device',
156+
typeSource: d.deviceType ? 'deviceType' : d.controlType ? 'controlType' : 'deviceType',
157157
name: d.deviceName,
158158
category: 'physical',
159159
hubDeviceId: d.hubDeviceId,
@@ -168,6 +168,7 @@ export function updateCacheFromDeviceList(body: DeviceListBodyShape): void {
168168
if (!d.deviceId) continue;
169169
devices[d.deviceId] = {
170170
type: d.remoteType,
171+
typeSource: 'remoteType',
171172
name: d.deviceName,
172173
category: 'ir',
173174
hubDeviceId: d.hubDeviceId,

src/devices/css-colors.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
export const CSS_COLORS: Record<string, [number, number, number]> = {
2+
aliceblue: [240, 248, 255],
3+
antiquewhite: [250, 235, 215],
4+
aqua: [0, 255, 255],
5+
aquamarine: [127, 255, 212],
6+
azure: [240, 255, 255],
7+
beige: [245, 245, 220],
8+
bisque: [255, 228, 196],
9+
black: [0, 0, 0],
10+
blanchedalmond: [255, 235, 205],
11+
blue: [0, 0, 255],
12+
blueviolet: [138, 43, 226],
13+
brown: [165, 42, 42],
14+
burlywood: [222, 184, 135],
15+
cadetblue: [95, 158, 160],
16+
chartreuse: [127, 255, 0],
17+
chocolate: [210, 105, 30],
18+
coral: [255, 127, 80],
19+
cornflowerblue: [100, 149, 237],
20+
cornsilk: [255, 248, 220],
21+
crimson: [220, 20, 60],
22+
cyan: [0, 255, 255],
23+
darkblue: [0, 0, 139],
24+
darkcyan: [0, 139, 139],
25+
darkgoldenrod: [184, 134, 11],
26+
darkgray: [169, 169, 169],
27+
darkgreen: [0, 100, 0],
28+
darkgrey: [169, 169, 169],
29+
darkkhaki: [189, 183, 107],
30+
darkmagenta: [139, 0, 139],
31+
darkolivegreen: [85, 107, 47],
32+
darkorange: [255, 140, 0],
33+
darkorchid: [153, 50, 204],
34+
darkred: [139, 0, 0],
35+
darksalmon: [233, 150, 122],
36+
darkseagreen: [143, 188, 143],
37+
darkslateblue: [72, 61, 139],
38+
darkslategray: [47, 79, 79],
39+
darkslategrey: [47, 79, 79],
40+
darkturquoise: [0, 206, 209],
41+
darkviolet: [148, 0, 211],
42+
deeppink: [255, 20, 147],
43+
deepskyblue: [0, 191, 255],
44+
dimgray: [105, 105, 105],
45+
dimgrey: [105, 105, 105],
46+
dodgerblue: [30, 144, 255],
47+
firebrick: [178, 34, 34],
48+
floralwhite: [255, 250, 240],
49+
forestgreen: [34, 139, 34],
50+
fuchsia: [255, 0, 255],
51+
gainsboro: [220, 220, 220],
52+
ghostwhite: [248, 248, 255],
53+
gold: [255, 215, 0],
54+
goldenrod: [218, 165, 32],
55+
gray: [128, 128, 128],
56+
green: [0, 128, 0],
57+
greenyellow: [173, 255, 47],
58+
grey: [128, 128, 128],
59+
honeydew: [240, 255, 240],
60+
hotpink: [255, 105, 180],
61+
indianred: [205, 92, 92],
62+
indigo: [75, 0, 130],
63+
ivory: [255, 255, 240],
64+
khaki: [240, 230, 140],
65+
lavender: [230, 230, 250],
66+
lavenderblush: [255, 240, 245],
67+
lawngreen: [124, 252, 0],
68+
lemonchiffon: [255, 250, 205],
69+
lightblue: [173, 216, 230],
70+
lightcoral: [240, 128, 128],
71+
lightcyan: [224, 255, 255],
72+
lightgoldenrodyellow: [250, 250, 210],
73+
lightgray: [211, 211, 211],
74+
lightgreen: [144, 238, 144],
75+
lightgrey: [211, 211, 211],
76+
lightpink: [255, 182, 193],
77+
lightsalmon: [255, 160, 122],
78+
lightseagreen: [32, 178, 170],
79+
lightskyblue: [135, 206, 250],
80+
lightslategray: [119, 136, 153],
81+
lightslategrey: [119, 136, 153],
82+
lightsteelblue: [176, 196, 222],
83+
lightyellow: [255, 255, 224],
84+
lime: [0, 255, 0],
85+
limegreen: [50, 205, 50],
86+
linen: [250, 240, 230],
87+
magenta: [255, 0, 255],
88+
maroon: [128, 0, 0],
89+
mediumaquamarine: [102, 205, 170],
90+
mediumblue: [0, 0, 205],
91+
mediumorchid: [186, 85, 211],
92+
mediumpurple: [147, 111, 219],
93+
mediumseagreen: [60, 179, 113],
94+
mediumslateblue: [123, 104, 238],
95+
mediumspringgreen: [0, 250, 154],
96+
mediumturquoise: [72, 209, 204],
97+
mediumvioletred: [199, 21, 133],
98+
midnightblue: [25, 25, 112],
99+
mintcream: [245, 255, 250],
100+
mistyrose: [255, 228, 225],
101+
moccasin: [255, 228, 181],
102+
navajowhite: [255, 222, 173],
103+
navy: [0, 0, 128],
104+
oldlace: [253, 245, 230],
105+
olive: [128, 128, 0],
106+
olivedrab: [107, 142, 35],
107+
orange: [255, 165, 0],
108+
orangered: [255, 69, 0],
109+
orchid: [218, 112, 214],
110+
palegoldenrod: [238, 232, 170],
111+
palegreen: [152, 251, 152],
112+
paleturquoise: [175, 238, 238],
113+
palevioletred: [219, 112, 147],
114+
papayawhip: [255, 239, 213],
115+
peachpuff: [255, 218, 185],
116+
peru: [205, 133, 63],
117+
pink: [255, 192, 203],
118+
plum: [221, 160, 221],
119+
powderblue: [176, 224, 230],
120+
purple: [128, 0, 128],
121+
rebeccapurple: [102, 51, 153],
122+
red: [255, 0, 0],
123+
rosybrown: [188, 143, 143],
124+
royalblue: [65, 105, 225],
125+
saddlebrown: [139, 69, 19],
126+
salmon: [250, 128, 114],
127+
sandybrown: [244, 164, 96],
128+
seagreen: [46, 139, 87],
129+
seashell: [255, 245, 238],
130+
sienna: [160, 82, 45],
131+
silver: [192, 192, 192],
132+
skyblue: [135, 206, 235],
133+
slateblue: [106, 90, 205],
134+
slategray: [112, 128, 144],
135+
slategrey: [112, 128, 144],
136+
snow: [255, 250, 250],
137+
springgreen: [0, 255, 127],
138+
steelblue: [70, 130, 180],
139+
tan: [210, 180, 140],
140+
teal: [0, 128, 128],
141+
thistle: [216, 191, 216],
142+
tomato: [255, 99, 71],
143+
turquoise: [64, 224, 208],
144+
violet: [238, 130, 238],
145+
wheat: [245, 222, 179],
146+
white: [255, 255, 255],
147+
whitesmoke: [245, 245, 245],
148+
yellow: [255, 255, 0],
149+
yellowgreen: [154, 205, 50],
150+
};

src/devices/param-validator.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { UsageError } from '../utils/output.js';
2+
import { CSS_COLORS } from './css-colors.js';
23

34
export const AC_MODE_MAP: Record<string, number> = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
45
export const AC_FAN_MAP: Record<string, number> = { auto: 1, low: 2, mid: 3, high: 4 };
@@ -212,33 +213,23 @@ function hintBrightnessRetry(): string {
212213
return `Ask the user whether they meant a percentage (1-100). Example: "50".`;
213214
}
214215

215-
// B-12: setColor accepts R:G:B, R,G,B, #RRGGBB, #RGB, or a small CSS named color
216-
// palette. All forms are normalized to `R:G:B` (the only wire shape SwitchBot
216+
// B-12: setColor accepts R:G:B, R,G,B, #RRGGBB, #RGB, or a CSS Level 4 named
217+
// color. All forms are normalized to `R:G:B` (the only wire shape SwitchBot
217218
// accepts) so the caller can POST the result unchanged.
218-
const NAMED_COLORS: Record<string, [number, number, number]> = {
219-
red: [255, 0, 0],
220-
green: [0, 128, 0],
221-
lime: [0, 255, 0],
222-
blue: [0, 0, 255],
223-
yellow: [255, 255, 0],
224-
cyan: [0, 255, 255],
225-
magenta: [255, 0, 255],
226-
white: [255, 255, 255],
227-
black: [0, 0, 0],
228-
orange: [255, 165, 0],
229-
purple: [128, 0, 128],
230-
pink: [255, 192, 203],
231-
brown: [165, 42, 42],
232-
grey: [128, 128, 128],
233-
gray: [128, 128, 128],
219+
const CUSTOM_COLORS: Record<string, [number, number, number]> = {
234220
warm: [255, 180, 100],
235221
};
236222

223+
const NAMED_COLORS: Record<string, [number, number, number]> = {
224+
...CSS_COLORS,
225+
...CUSTOM_COLORS,
226+
};
227+
237228
function validateSetColor(raw: string | undefined): ValidateResult {
238229
if (raw === undefined || raw === '' || raw === 'default') {
239230
return {
240231
ok: false,
241-
error: `setColor requires a color. Expected one of: "R:G:B" (e.g. "255:0:0"), "#RRGGBB" (e.g. "#FF0000"), "#RGB", "R,G,B", or a named color (${Object.keys(NAMED_COLORS).slice(0, 8).join(', ')}, ...).`,
232+
error: `setColor requires a color. Use a CSS color name (e.g. coral, teal, salmon), hex (#RRGGBB / #RGB), or R:G:B format.`,
242233
};
243234
}
244235
const trimmed = raw.trim();

0 commit comments

Comments
 (0)