scrypted-kasa 1.3.0 → 1.3.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.
- package/dist/plugin.zip +0 -0
- package/package.json +4 -1
- package/.github/dependabot.yml +0 -14
- package/.github/workflows/ci.yml +0 -40
- package/.github/workflows/publish.yml +0 -52
- package/.nvmrc +0 -1
- package/.prettierignore +0 -6
- package/.prettierrc.json +0 -11
- package/eslint.config.mjs +0 -33
- package/src/camera/api.ts +0 -354
- package/src/camera/intercom.ts +0 -163
- package/src/camera/linkie.ts +0 -185
- package/src/camera/rtp.ts +0 -238
- package/src/camera/rtsp.ts +0 -270
- package/src/camera/stream.ts +0 -346
- package/src/iot/bulb.ts +0 -223
- package/src/iot/device.ts +0 -250
- package/src/iot/dimmer.ts +0 -19
- package/src/iot/plug.ts +0 -5
- package/src/iot/protocol.ts +0 -182
- package/src/iot/switch.ts +0 -5
- package/src/main.ts +0 -1389
- package/src/shared/cipher.ts +0 -49
- package/src/shared/discovery.ts +0 -232
- package/src/shared/readme.ts +0 -20
- package/tsconfig.json +0 -15
package/src/iot/bulb.ts
DELETED
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Brightness,
|
|
3
|
-
ColorHsv,
|
|
4
|
-
ColorSettingHsv,
|
|
5
|
-
ColorSettingTemperature,
|
|
6
|
-
OnOff,
|
|
7
|
-
Readme,
|
|
8
|
-
ScryptedDeviceBase,
|
|
9
|
-
Setting,
|
|
10
|
-
Settings,
|
|
11
|
-
SettingValue,
|
|
12
|
-
} from '@scrypted/sdk';
|
|
13
|
-
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
14
|
-
import { formatKasaMac, renderKv } from '../shared/readme';
|
|
15
|
-
import { KASA_IOT_PORT, getSysInfo, kasaIotCall } from './protocol';
|
|
16
|
-
|
|
17
|
-
const STATE_POLL_INTERVAL_MS = 30000;
|
|
18
|
-
|
|
19
|
-
// Smart bulbs (LB100/110/120/130, KL110/120/130/135/...). All bulb state — on/off,
|
|
20
|
-
// brightness, hue, saturation, color temperature — is controlled by a single command:
|
|
21
|
-
// {"smartlife.iot.smartbulb.lightingservice":{"transition_light_state":{...}}}
|
|
22
|
-
//
|
|
23
|
-
// Capability flags from sysinfo:
|
|
24
|
-
// is_dimmable 1 = supports brightness
|
|
25
|
-
// is_color 1 = supports hue/saturation
|
|
26
|
-
// is_variable_color_temp 1 = supports color temperature
|
|
27
|
-
// Color temperature range is determined per-model; we expose the values the bulb returns
|
|
28
|
-
// in `getTemperatureMinK`/`getTemperatureMaxK` and fall back to a safe pair otherwise.
|
|
29
|
-
export class KasaBulb
|
|
30
|
-
extends ScryptedDeviceBase
|
|
31
|
-
implements OnOff, Brightness, ColorSettingHsv, ColorSettingTemperature, Settings, Readme
|
|
32
|
-
{
|
|
33
|
-
storageSettings = new StorageSettings(this, {
|
|
34
|
-
ip: {
|
|
35
|
-
title: 'IP Address',
|
|
36
|
-
placeholder: '192.168.1.100',
|
|
37
|
-
},
|
|
38
|
-
port: {
|
|
39
|
-
title: 'Port',
|
|
40
|
-
type: 'number',
|
|
41
|
-
defaultValue: KASA_IOT_PORT,
|
|
42
|
-
},
|
|
43
|
-
// Capability flags captured from sysinfo at adoption — internal only, drive which
|
|
44
|
-
// interfaces the device advertises. Hidden because users don't typically need to
|
|
45
|
-
// toggle them (auto-detected from the bulb's own metadata).
|
|
46
|
-
isColor: {
|
|
47
|
-
type: 'boolean',
|
|
48
|
-
hide: true,
|
|
49
|
-
},
|
|
50
|
-
isVariableColorTemp: {
|
|
51
|
-
type: 'boolean',
|
|
52
|
-
hide: true,
|
|
53
|
-
},
|
|
54
|
-
colorTemperatureMinK: {
|
|
55
|
-
type: 'number',
|
|
56
|
-
defaultValue: 2700,
|
|
57
|
-
hide: true,
|
|
58
|
-
},
|
|
59
|
-
colorTemperatureMaxK: {
|
|
60
|
-
type: 'number',
|
|
61
|
-
defaultValue: 6500,
|
|
62
|
-
hide: true,
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
private pollTimer?: NodeJS.Timeout;
|
|
67
|
-
private pollStartTimer?: NodeJS.Timeout;
|
|
68
|
-
// See KasaIotDevice for rationale: shared in-flight promise prevents overlapping
|
|
69
|
-
// refreshState calls from racing if a poll takes longer than the interval.
|
|
70
|
-
private refreshInFlight?: Promise<void>;
|
|
71
|
-
|
|
72
|
-
constructor(nativeId: string) {
|
|
73
|
-
super(nativeId);
|
|
74
|
-
process.nextTick(() => this.refreshState().catch(e => this.console.warn('refresh state failed', e)));
|
|
75
|
-
// Random first-fire offset to spread load when many bulbs are constructed together.
|
|
76
|
-
const jitter = Math.floor(Math.random() * STATE_POLL_INTERVAL_MS);
|
|
77
|
-
this.pollStartTimer = setTimeout(() => {
|
|
78
|
-
this.pollStartTimer = undefined;
|
|
79
|
-
this.pollTimer = setInterval(() => this.refreshState().catch(() => {}), STATE_POLL_INTERVAL_MS);
|
|
80
|
-
}, jitter);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
getSettings(): Promise<Setting[]> {
|
|
84
|
-
return this.storageSettings.getSettings();
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
putSetting(key: string, value: SettingValue): Promise<void> {
|
|
88
|
-
return this.storageSettings.putSetting(key, value);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Per-device Readme tab. Bulbs don't share KasaIotDevice's base implementation
|
|
92
|
-
// (different inheritance chain because of the color/brightness mixins), so the
|
|
93
|
-
// structure is duplicated here on purpose. Adds the static capability list (color /
|
|
94
|
-
// color-temp range) since those depend on per-model sysinfo flags. Live state is not
|
|
95
|
-
// duplicated here — the device page already shows it.
|
|
96
|
-
async getReadmeMarkdown(): Promise<string> {
|
|
97
|
-
const info = this.info || {};
|
|
98
|
-
const ip = this.storageSettings.values.ip || info.ip || '?';
|
|
99
|
-
const port = this.storageSettings.values.port || KASA_IOT_PORT;
|
|
100
|
-
const { isColor, isVariableColorTemp, colorTemperatureMinK, colorTemperatureMaxK } =
|
|
101
|
-
this.storageSettings.values;
|
|
102
|
-
|
|
103
|
-
const capabilities: string[] = ['Brightness'];
|
|
104
|
-
if (isColor) capabilities.push('Color (HSV)');
|
|
105
|
-
if (isVariableColorTemp)
|
|
106
|
-
capabilities.push(`Color temperature (${colorTemperatureMinK}–${colorTemperatureMaxK} K)`);
|
|
107
|
-
|
|
108
|
-
return [
|
|
109
|
-
`# ${this.name || 'Kasa Bulb'}`,
|
|
110
|
-
'',
|
|
111
|
-
'## Device',
|
|
112
|
-
'',
|
|
113
|
-
'```',
|
|
114
|
-
renderKv([
|
|
115
|
-
['Model', info.model || '?'],
|
|
116
|
-
['Firmware', info.firmware || '?'],
|
|
117
|
-
['MAC', formatKasaMac(info.mac)],
|
|
118
|
-
['IP', ip],
|
|
119
|
-
['Port', String(port)],
|
|
120
|
-
]),
|
|
121
|
-
'```',
|
|
122
|
-
'',
|
|
123
|
-
'## Capabilities',
|
|
124
|
-
'',
|
|
125
|
-
...capabilities.map(c => `- ${c}`),
|
|
126
|
-
'',
|
|
127
|
-
'## Protocol',
|
|
128
|
-
'',
|
|
129
|
-
`Local TCP/${port} — Kasa's legacy "smarthome" wire format. All bulb state changes`,
|
|
130
|
-
'are issued via `smartlife.iot.smartbulb.lightingservice.transition_light_state`.',
|
|
131
|
-
].join('\n');
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
async turnOn(): Promise<void> {
|
|
135
|
-
await this.lightState({ on_off: 1 });
|
|
136
|
-
this.on = true;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async turnOff(): Promise<void> {
|
|
140
|
-
await this.lightState({ on_off: 0 });
|
|
141
|
-
this.on = false;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async setBrightness(brightness: number): Promise<void> {
|
|
145
|
-
await this.lightState({ brightness });
|
|
146
|
-
this.brightness = brightness;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async setHsv(hue: number, saturation: number, value: number): Promise<void> {
|
|
150
|
-
// Switch the bulb to color mode (mode 1) when HSV is set; without this some bulbs
|
|
151
|
-
// ignore the hue/saturation while in color-temperature mode.
|
|
152
|
-
await this.lightState({
|
|
153
|
-
color_temp: 0,
|
|
154
|
-
hue,
|
|
155
|
-
saturation,
|
|
156
|
-
brightness: value,
|
|
157
|
-
});
|
|
158
|
-
this.hsv = { h: hue, s: saturation, v: value };
|
|
159
|
-
this.brightness = value;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async setColorTemperature(kelvin: number): Promise<void> {
|
|
163
|
-
await this.lightState({ color_temp: kelvin });
|
|
164
|
-
this.colorTemperature = kelvin;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async getTemperatureMinK(): Promise<number> {
|
|
168
|
-
return this.storageSettings.values.colorTemperatureMinK;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
async getTemperatureMaxK(): Promise<number> {
|
|
172
|
-
return this.storageSettings.values.colorTemperatureMaxK;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async refreshState(): Promise<void> {
|
|
176
|
-
if (this.refreshInFlight) return this.refreshInFlight;
|
|
177
|
-
this.refreshInFlight = this.refreshStateInternal().finally(() => {
|
|
178
|
-
this.refreshInFlight = undefined;
|
|
179
|
-
});
|
|
180
|
-
return this.refreshInFlight;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
private async refreshStateInternal(): Promise<void> {
|
|
184
|
-
if (!this.storageSettings.values.ip) return;
|
|
185
|
-
const sys = await getSysInfo(this.iotOptions());
|
|
186
|
-
const ls = sys?.light_state;
|
|
187
|
-
if (!ls) return;
|
|
188
|
-
// light_state during off-state nests the actual values under `dft_on_state`.
|
|
189
|
-
const active = ls.on_off ? ls : ls.dft_on_state || {};
|
|
190
|
-
this.on = ls.on_off === 1;
|
|
191
|
-
if (typeof active.brightness === 'number') this.brightness = active.brightness;
|
|
192
|
-
if (typeof active.color_temp === 'number') this.colorTemperature = active.color_temp;
|
|
193
|
-
if (typeof active.hue === 'number' && typeof active.saturation === 'number') {
|
|
194
|
-
const hsv: ColorHsv = {
|
|
195
|
-
h: active.hue,
|
|
196
|
-
s: active.saturation,
|
|
197
|
-
v: typeof active.brightness === 'number' ? active.brightness : 100,
|
|
198
|
-
};
|
|
199
|
-
this.hsv = hsv;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
private lightState(state: Record<string, any>): Promise<any> {
|
|
204
|
-
// ignore_default tells the bulb to merge with current state instead of resetting.
|
|
205
|
-
return kasaIotCall(this.iotOptions(), {
|
|
206
|
-
'smartlife.iot.smartbulb.lightingservice': {
|
|
207
|
-
transition_light_state: { ignore_default: 1, ...state },
|
|
208
|
-
},
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
private iotOptions() {
|
|
213
|
-
return {
|
|
214
|
-
host: this.storageSettings.values.ip,
|
|
215
|
-
port: this.storageSettings.values.port,
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
release(): void {
|
|
220
|
-
clearInterval(this.pollTimer);
|
|
221
|
-
clearTimeout(this.pollStartTimer);
|
|
222
|
-
}
|
|
223
|
-
}
|
package/src/iot/device.ts
DELETED
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
import { OnOff, Readme, ScryptedDeviceBase, ScryptedInterface, Setting, Settings, SettingValue } from '@scrypted/sdk';
|
|
2
|
-
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
3
|
-
import { formatKasaMac, renderKv } from '../shared/readme';
|
|
4
|
-
import { EmeterReading, KASA_IOT_PORT, getEmeterRealtime, getSysInfo, kasaIotCall } from './protocol';
|
|
5
|
-
|
|
6
|
-
const STATE_POLL_INTERVAL_MS = 30000;
|
|
7
|
-
|
|
8
|
-
// Shared base for every Kasa "smarthome" single-relay device — outlets, switches, and
|
|
9
|
-
// dimmers. Only the on/off relay protocol lives here; dimmable devices add Brightness on
|
|
10
|
-
// top in KasaDimmer.
|
|
11
|
-
//
|
|
12
|
-
// Wire protocol (TCP/9999):
|
|
13
|
-
// on/off: {"system":{"set_relay_state":{"state":1|0}}}
|
|
14
|
-
// query: {"system":{"get_sysinfo":{}}}
|
|
15
|
-
//
|
|
16
|
-
// Multi-outlet strips (HS300, KP303) aren't modeled — they need per-child relay handling.
|
|
17
|
-
export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff, Settings, Readme {
|
|
18
|
-
storageSettings = new StorageSettings(this, {
|
|
19
|
-
ip: {
|
|
20
|
-
title: 'IP Address',
|
|
21
|
-
placeholder: '192.168.1.100',
|
|
22
|
-
},
|
|
23
|
-
port: {
|
|
24
|
-
title: 'Port',
|
|
25
|
-
type: 'number',
|
|
26
|
-
defaultValue: KASA_IOT_PORT,
|
|
27
|
-
},
|
|
28
|
-
// Energy-monitoring capability flag derived from sysinfo.feature (devices report
|
|
29
|
-
// "TIM:ENE" when they have an emeter). Hidden because users don't toggle it —
|
|
30
|
-
// it's set at adoption and re-evaluated on every sysinfo poll. Drives whether
|
|
31
|
-
// the Readme tab fetches and displays the live wattage.
|
|
32
|
-
hasEmeter: {
|
|
33
|
-
type: 'boolean',
|
|
34
|
-
hide: true,
|
|
35
|
-
},
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
private pollTimer?: NodeJS.Timeout;
|
|
39
|
-
private pollStartTimer?: NodeJS.Timeout;
|
|
40
|
-
// Guard against overlapping polls: if a refresh is in-flight when the interval fires
|
|
41
|
-
// (e.g., the camera is slow), the second call would open a parallel TCP connection
|
|
42
|
-
// and could race the first response. Sharing the in-flight promise avoids that.
|
|
43
|
-
private refreshInFlight?: Promise<void>;
|
|
44
|
-
|
|
45
|
-
constructor(nativeId: string) {
|
|
46
|
-
super(nativeId);
|
|
47
|
-
// Drive an initial state refresh on next tick (so storageSettings is ready) and a
|
|
48
|
-
// periodic poll thereafter so external state changes (other apps, physical button
|
|
49
|
-
// presses) eventually surface in Scrypted/HomeKit. A small per-instance jitter on
|
|
50
|
-
// the first poll spreads the load when many devices were started together.
|
|
51
|
-
process.nextTick(() => this.refreshState().catch(e => this.console.warn('refresh state failed', e)));
|
|
52
|
-
const jitter = Math.floor(Math.random() * STATE_POLL_INTERVAL_MS);
|
|
53
|
-
this.pollStartTimer = setTimeout(() => {
|
|
54
|
-
this.pollStartTimer = undefined;
|
|
55
|
-
this.pollTimer = setInterval(() => this.refreshState().catch(() => {}), STATE_POLL_INTERVAL_MS);
|
|
56
|
-
}, jitter);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async getSettings(): Promise<Setting[]> {
|
|
60
|
-
const base = await this.storageSettings.getSettings();
|
|
61
|
-
if (!this.storageSettings.values.hasEmeter) return base;
|
|
62
|
-
|
|
63
|
-
// Devices with an emeter get a live readout block + a Refresh button so the user
|
|
64
|
-
// can force a fresh sample without waiting for the 5 s cache to expire. Reads go
|
|
65
|
-
// through the same cached emeterRealtime() the Readme uses, so opening the
|
|
66
|
-
// Settings tab right after viewing the Readme is free.
|
|
67
|
-
const reading = await this.emeterRealtime();
|
|
68
|
-
const energy: Setting[] = [
|
|
69
|
-
{
|
|
70
|
-
key: 'energyPower',
|
|
71
|
-
group: 'Energy',
|
|
72
|
-
title: 'Power',
|
|
73
|
-
readonly: true,
|
|
74
|
-
value: reading ? `${reading.powerW.toFixed(1)} W` : '?',
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
key: 'energyVoltage',
|
|
78
|
-
group: 'Energy',
|
|
79
|
-
title: 'Voltage',
|
|
80
|
-
readonly: true,
|
|
81
|
-
value: reading ? `${reading.voltageV.toFixed(1)} V` : '?',
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
key: 'energyCurrent',
|
|
85
|
-
group: 'Energy',
|
|
86
|
-
title: 'Current',
|
|
87
|
-
readonly: true,
|
|
88
|
-
value: reading ? `${reading.currentA.toFixed(3)} A` : '?',
|
|
89
|
-
},
|
|
90
|
-
{
|
|
91
|
-
key: 'energyTotal',
|
|
92
|
-
group: 'Energy',
|
|
93
|
-
title: 'Total energy',
|
|
94
|
-
readonly: true,
|
|
95
|
-
value: reading ? `${(reading.totalWh / 1000).toFixed(3)} kWh` : '?',
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
key: 'refreshEnergy',
|
|
99
|
-
group: 'Energy',
|
|
100
|
-
title: 'Refresh',
|
|
101
|
-
description: 'Discard the cached emeter sample and read fresh values from the device.',
|
|
102
|
-
type: 'button',
|
|
103
|
-
},
|
|
104
|
-
];
|
|
105
|
-
return [...base, ...energy];
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async putSetting(key: string, value: SettingValue): Promise<void> {
|
|
109
|
-
if (key === 'refreshEnergy') {
|
|
110
|
-
// Drop the cached value and fetch fresh; the new reading goes back into the
|
|
111
|
-
// cache so subsequent renders see it. Emit a Settings event so the UI knows
|
|
112
|
-
// to re-render with the updated readout fields.
|
|
113
|
-
this.emeterCache = undefined;
|
|
114
|
-
await this.emeterRealtime();
|
|
115
|
-
await this.onDeviceEvent(ScryptedInterface.Settings, undefined);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
return this.storageSettings.putSetting(key, value);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Per-device Readme tab. Surfaces static identity / network info — live state
|
|
122
|
-
// (on/off, brightness) is not duplicated here, the device page already shows it.
|
|
123
|
-
// Devices that report energy-monitoring capability also get a live Energy section
|
|
124
|
-
// (lazy fetch — only fires when the user actually opens the Readme tab).
|
|
125
|
-
async getReadmeMarkdown(): Promise<string> {
|
|
126
|
-
const info = this.info || {};
|
|
127
|
-
const ip = this.storageSettings.values.ip || info.ip || '?';
|
|
128
|
-
const port = this.storageSettings.values.port || KASA_IOT_PORT;
|
|
129
|
-
const lines: string[] = [
|
|
130
|
-
`# ${this.name || 'Kasa Device'}`,
|
|
131
|
-
'',
|
|
132
|
-
'## Device',
|
|
133
|
-
'',
|
|
134
|
-
'```',
|
|
135
|
-
renderKv([
|
|
136
|
-
['Model', info.model || '?'],
|
|
137
|
-
['Firmware', info.firmware || '?'],
|
|
138
|
-
['MAC', formatKasaMac(info.mac)],
|
|
139
|
-
['IP', ip],
|
|
140
|
-
['Port', String(port)],
|
|
141
|
-
]),
|
|
142
|
-
'```',
|
|
143
|
-
'',
|
|
144
|
-
];
|
|
145
|
-
|
|
146
|
-
if (this.storageSettings.values.hasEmeter) {
|
|
147
|
-
const reading = await this.emeterRealtime();
|
|
148
|
-
const fmt = (n: number, digits: number) => n.toFixed(digits);
|
|
149
|
-
const rows: [string, string][] = reading
|
|
150
|
-
? [
|
|
151
|
-
['Power', `${fmt(reading.powerW, 1)} W`],
|
|
152
|
-
['Voltage', `${fmt(reading.voltageV, 1)} V`],
|
|
153
|
-
['Current', `${fmt(reading.currentA, 3)} A`],
|
|
154
|
-
['Total', `${fmt(reading.totalWh / 1000, 3)} kWh`],
|
|
155
|
-
]
|
|
156
|
-
: [
|
|
157
|
-
['Power', '?'],
|
|
158
|
-
['Voltage', '?'],
|
|
159
|
-
['Current', '?'],
|
|
160
|
-
['Total', '?'],
|
|
161
|
-
];
|
|
162
|
-
lines.push('## Energy', '', '```', renderKv(rows), '```', '');
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
lines.push(
|
|
166
|
-
'## Protocol',
|
|
167
|
-
'',
|
|
168
|
-
`Local TCP/${port} — Kasa's legacy "smarthome" wire format. No cloud account or`,
|
|
169
|
-
'credentials required, the plugin reaches the device directly on the LAN.',
|
|
170
|
-
);
|
|
171
|
-
return lines.join('\n');
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Cache the most recent emeter reading for a short window so quick re-renders of
|
|
175
|
-
// the Readme tab (focus events etc.) don't fan out into multiple TCP queries to the
|
|
176
|
-
// plug. Errors cache as `undefined` so an offline plug doesn't keep retrying on
|
|
177
|
-
// every render either — the next miss after the window will try again.
|
|
178
|
-
private static EMETER_CACHE_MS = 5000;
|
|
179
|
-
private emeterCache?: { value: EmeterReading | undefined; at: number };
|
|
180
|
-
private emeterInFlight?: Promise<EmeterReading | undefined>;
|
|
181
|
-
|
|
182
|
-
async emeterRealtime(): Promise<EmeterReading | undefined> {
|
|
183
|
-
const now = Date.now();
|
|
184
|
-
if (this.emeterCache && now - this.emeterCache.at < KasaIotDevice.EMETER_CACHE_MS) {
|
|
185
|
-
return this.emeterCache.value;
|
|
186
|
-
}
|
|
187
|
-
if (this.emeterInFlight) return this.emeterInFlight;
|
|
188
|
-
this.emeterInFlight = getEmeterRealtime(this.iotOptions())
|
|
189
|
-
.catch(() => undefined)
|
|
190
|
-
.finally(() => {
|
|
191
|
-
this.emeterInFlight = undefined;
|
|
192
|
-
});
|
|
193
|
-
const value = await this.emeterInFlight;
|
|
194
|
-
this.emeterCache = { value, at: Date.now() };
|
|
195
|
-
return value;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async turnOn(): Promise<void> {
|
|
199
|
-
await this.callIot({ system: { set_relay_state: { state: 1 } } });
|
|
200
|
-
this.on = true;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
async turnOff(): Promise<void> {
|
|
204
|
-
await this.callIot({ system: { set_relay_state: { state: 0 } } });
|
|
205
|
-
this.on = false;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async refreshState(): Promise<void> {
|
|
209
|
-
if (this.refreshInFlight) return this.refreshInFlight;
|
|
210
|
-
this.refreshInFlight = this.refreshStateInternal().finally(() => {
|
|
211
|
-
this.refreshInFlight = undefined;
|
|
212
|
-
});
|
|
213
|
-
return this.refreshInFlight;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
private async refreshStateInternal(): Promise<void> {
|
|
217
|
-
if (!this.storageSettings.values.ip) return;
|
|
218
|
-
const sys = await getSysInfo(this.iotOptions());
|
|
219
|
-
if (!sys) return;
|
|
220
|
-
if (typeof sys.relay_state === 'number') this.on = sys.relay_state === 1;
|
|
221
|
-
// Energy-monitoring capability is advertised in sysinfo.feature as "ENE"
|
|
222
|
-
// (e.g. "TIM:ENE"). Cache the boolean so the Readme tab knows whether to
|
|
223
|
-
// bother fetching live wattage.
|
|
224
|
-
const hasEmeter = /ENE/i.test(typeof sys.feature === 'string' ? sys.feature : '');
|
|
225
|
-
if (this.storageSettings.values.hasEmeter !== hasEmeter) {
|
|
226
|
-
this.storageSettings.values.hasEmeter = hasEmeter;
|
|
227
|
-
}
|
|
228
|
-
// Subclasses with extra state extend via onSysInfo hook.
|
|
229
|
-
this.onSysInfo(sys);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Hook for subclasses to consume additional sysinfo fields (e.g. brightness for dimmers).
|
|
233
|
-
protected onSysInfo(_sys: Record<string, any>): void {}
|
|
234
|
-
|
|
235
|
-
protected callIot(command: Record<string, any>): Promise<any> {
|
|
236
|
-
return kasaIotCall(this.iotOptions(), command);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
protected iotOptions() {
|
|
240
|
-
return {
|
|
241
|
-
host: this.storageSettings.values.ip,
|
|
242
|
-
port: this.storageSettings.values.port,
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
release(): void {
|
|
247
|
-
clearInterval(this.pollTimer);
|
|
248
|
-
clearTimeout(this.pollStartTimer);
|
|
249
|
-
}
|
|
250
|
-
}
|
package/src/iot/dimmer.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { Brightness } from '@scrypted/sdk';
|
|
2
|
-
import { KasaIotDevice } from './device';
|
|
3
|
-
|
|
4
|
-
// Dimmable Kasa devices (HS220 dimmer plug, KS230 3-way dimmer switch, etc.). Same
|
|
5
|
-
// relay protocol as KasaPlug/KasaSwitch plus a brightness command:
|
|
6
|
-
// {"smartlife.iot.dimmer":{"set_brightness":{"brightness":N}}}
|
|
7
|
-
//
|
|
8
|
-
// Always exposed as `Light` since these are essentially always wired to a light fixture
|
|
9
|
-
// (the Kasa app does the same). On/off uses the relay; brightness uses the dimmer module.
|
|
10
|
-
export class KasaDimmer extends KasaIotDevice implements Brightness {
|
|
11
|
-
async setBrightness(brightness: number): Promise<void> {
|
|
12
|
-
await this.callIot({ 'smartlife.iot.dimmer': { set_brightness: { brightness } } });
|
|
13
|
-
this.brightness = brightness;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
protected onSysInfo(sys: Record<string, any>): void {
|
|
17
|
-
if (typeof sys.brightness === 'number') this.brightness = sys.brightness;
|
|
18
|
-
}
|
|
19
|
-
}
|
package/src/iot/plug.ts
DELETED
package/src/iot/protocol.ts
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import net from 'net';
|
|
2
|
-
import { xorDecrypt, xorEncryptInPlace } from '../shared/cipher';
|
|
3
|
-
|
|
4
|
-
// Legacy Kasa "IOT" protocol used by plugs, switches, and bulbs (everything that isn't a
|
|
5
|
-
// camera). Wire format on TCP/9999:
|
|
6
|
-
// 4-byte big-endian length prefix + XOR-AB(plaintext-json) bytes
|
|
7
|
-
// Same XOR-AB autokey cipher as UDP discovery; the only difference is the framing.
|
|
8
|
-
//
|
|
9
|
-
// Same-port UDP/9999 also accepts the encrypted JSON without the 4-byte prefix, but TCP
|
|
10
|
-
// is more reliable for control commands so we use it exclusively here.
|
|
11
|
-
|
|
12
|
-
export const KASA_IOT_PORT = 9999;
|
|
13
|
-
const REQUEST_TIMEOUT_MS = 5000;
|
|
14
|
-
|
|
15
|
-
export interface KasaIotOptions {
|
|
16
|
-
host: string;
|
|
17
|
-
port?: number;
|
|
18
|
-
timeoutMs?: number;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function kasaIotCall(options: KasaIotOptions, command: Record<string, any>): Promise<any> {
|
|
22
|
-
const port = options.port || KASA_IOT_PORT;
|
|
23
|
-
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
24
|
-
const json = JSON.stringify(command);
|
|
25
|
-
// Encode the 4-byte length prefix and JSON in one allocation, encrypt the JSON portion
|
|
26
|
-
// in-place (we own this buffer and don't read the plaintext again).
|
|
27
|
-
const jsonBytes = Buffer.byteLength(json, 'utf8');
|
|
28
|
-
const payload = Buffer.allocUnsafe(4 + jsonBytes);
|
|
29
|
-
payload.writeUInt32BE(jsonBytes, 0);
|
|
30
|
-
payload.write(json, 4, 'utf8');
|
|
31
|
-
xorEncryptInPlace(payload.subarray(4));
|
|
32
|
-
|
|
33
|
-
return new Promise((resolve, reject) => {
|
|
34
|
-
const socket = new net.Socket();
|
|
35
|
-
// Queue of received TCP chunks plus byte total, so we don't repeatedly Buffer.concat
|
|
36
|
-
// the accumulator on every 'data' event (O(n²) over multi-chunk responses; bulb
|
|
37
|
-
// light_state replies span 2-3 chunks).
|
|
38
|
-
const chunks: Buffer[] = [];
|
|
39
|
-
let queuedBytes = 0;
|
|
40
|
-
let expected = -1;
|
|
41
|
-
let settled = false;
|
|
42
|
-
|
|
43
|
-
const finish = (err?: Error, value?: any) => {
|
|
44
|
-
if (settled) return;
|
|
45
|
-
settled = true;
|
|
46
|
-
clearTimeout(timer);
|
|
47
|
-
try {
|
|
48
|
-
socket.destroy();
|
|
49
|
-
} catch {}
|
|
50
|
-
// Drop references so any retained chunks can be GC'd promptly.
|
|
51
|
-
chunks.length = 0;
|
|
52
|
-
if (err) reject(err);
|
|
53
|
-
else resolve(value);
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const timer = setTimeout(() => finish(new Error(`kasa iot call timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
57
|
-
|
|
58
|
-
const peek4 = (): number => {
|
|
59
|
-
// We've verified queuedBytes >= 4 before calling. Read big-endian uint32 either
|
|
60
|
-
// straight from the head buffer (common case) or after walking the queue.
|
|
61
|
-
if (chunks[0].length >= 4) return chunks[0].readUInt32BE(0);
|
|
62
|
-
// Rare: 4-byte length straddles two chunks. Concat just the first two.
|
|
63
|
-
return Buffer.concat(chunks.slice(0, 2), 4).readUInt32BE(0);
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const consume = (n: number): Buffer => {
|
|
67
|
-
// Pop and return exactly n bytes from the head of the queue.
|
|
68
|
-
queuedBytes -= n;
|
|
69
|
-
if (chunks[0].length === n) return chunks.shift()!;
|
|
70
|
-
if (chunks[0].length > n) {
|
|
71
|
-
const out = chunks[0].subarray(0, n);
|
|
72
|
-
chunks[0] = chunks[0].subarray(n);
|
|
73
|
-
return out;
|
|
74
|
-
}
|
|
75
|
-
const out = Buffer.allocUnsafe(n);
|
|
76
|
-
let written = 0;
|
|
77
|
-
while (written < n) {
|
|
78
|
-
const head = chunks[0];
|
|
79
|
-
const need = n - written;
|
|
80
|
-
if (head.length <= need) {
|
|
81
|
-
head.copy(out, written);
|
|
82
|
-
written += head.length;
|
|
83
|
-
chunks.shift();
|
|
84
|
-
} else {
|
|
85
|
-
head.copy(out, written, 0, need);
|
|
86
|
-
chunks[0] = head.subarray(need);
|
|
87
|
-
written = n;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return out;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
socket.on('data', (chunk: Buffer) => {
|
|
94
|
-
chunks.push(chunk);
|
|
95
|
-
queuedBytes += chunk.length;
|
|
96
|
-
if (expected < 0) {
|
|
97
|
-
if (queuedBytes < 4) return;
|
|
98
|
-
expected = peek4();
|
|
99
|
-
consume(4);
|
|
100
|
-
}
|
|
101
|
-
if (queuedBytes >= expected) {
|
|
102
|
-
const cipher = consume(expected);
|
|
103
|
-
const plain = xorDecrypt(cipher).toString('utf8');
|
|
104
|
-
try {
|
|
105
|
-
finish(undefined, JSON.parse(plain));
|
|
106
|
-
} catch (e) {
|
|
107
|
-
finish(e as Error);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
socket.on('error', e => finish(e));
|
|
113
|
-
socket.on('close', () => {
|
|
114
|
-
if (!settled) finish(new Error('kasa iot socket closed before response'));
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
socket.connect(port, options.host, () => {
|
|
118
|
-
socket.write(payload);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Common helpers used by both plugs/switches and bulbs.
|
|
124
|
-
|
|
125
|
-
export interface KasaSysInfoCommon {
|
|
126
|
-
alias?: string;
|
|
127
|
-
model?: string;
|
|
128
|
-
mac?: string;
|
|
129
|
-
mic_mac?: string;
|
|
130
|
-
deviceId?: string;
|
|
131
|
-
sw_ver?: string;
|
|
132
|
-
hw_ver?: string;
|
|
133
|
-
type?: string;
|
|
134
|
-
mic_type?: string;
|
|
135
|
-
dev_name?: string;
|
|
136
|
-
feature?: string;
|
|
137
|
-
[k: string]: any;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export async function getSysInfo(options: KasaIotOptions): Promise<KasaSysInfoCommon | undefined> {
|
|
141
|
-
const r = await kasaIotCall(options, { system: { get_sysinfo: {} } });
|
|
142
|
-
return r?.system?.get_sysinfo;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Energy-monitoring readout for plugs that report sysinfo.feature containing "ENE"
|
|
146
|
-
// (HS110, KP115, KP125, EP25, HS220, ...). The plug answers `emeter.get_realtime` with
|
|
147
|
-
// either the newer milli-units schema or the older base-unit schema:
|
|
148
|
-
//
|
|
149
|
-
// newer: { voltage_mv, current_ma, power_mw, total_wh, err_code }
|
|
150
|
-
// older: { voltage, current, power, total, err_code } // V, A, W, kWh
|
|
151
|
-
//
|
|
152
|
-
// Normalize to a single V/A/W/Wh shape so callers don't care which firmware they're on.
|
|
153
|
-
// Returns undefined on err_code or when neither schema matches.
|
|
154
|
-
export interface EmeterReading {
|
|
155
|
-
voltageV: number;
|
|
156
|
-
currentA: number;
|
|
157
|
-
powerW: number;
|
|
158
|
-
totalWh: number;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export async function getEmeterRealtime(options: KasaIotOptions): Promise<EmeterReading | undefined> {
|
|
162
|
-
const r = await kasaIotCall(options, { emeter: { get_realtime: {} } });
|
|
163
|
-
const e = r?.emeter?.get_realtime;
|
|
164
|
-
if (!e || e.err_code) return undefined;
|
|
165
|
-
if (typeof e.voltage_mv === 'number') {
|
|
166
|
-
return {
|
|
167
|
-
voltageV: e.voltage_mv / 1000,
|
|
168
|
-
currentA: (e.current_ma ?? 0) / 1000,
|
|
169
|
-
powerW: (e.power_mw ?? 0) / 1000,
|
|
170
|
-
totalWh: e.total_wh ?? 0,
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
if (typeof e.voltage === 'number') {
|
|
174
|
-
return {
|
|
175
|
-
voltageV: e.voltage,
|
|
176
|
-
currentA: e.current ?? 0,
|
|
177
|
-
powerW: e.power ?? 0,
|
|
178
|
-
totalWh: (e.total ?? 0) * 1000,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
return undefined;
|
|
182
|
-
}
|
package/src/iot/switch.ts
DELETED