@attraccess/plugin-shelly 0.1.0-alpha.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.
- package/dist/index.js +952 -0
- package/dist/migrations.js +57 -0
- package/frontend/__federation_expose_Plugin-Dd1Vs4iR.js +1627 -0
- package/frontend/__federation_fn_import-DAGm_xmC.js +2 -0
- package/frontend/_virtual___federation_fn_import-BXODRNto.js +261 -0
- package/frontend/index.html +13 -0
- package/frontend/jsx-runtime-B9Qmn8k7.js +42 -0
- package/frontend/preload-helper-DxWiKyOd.js +68 -0
- package/frontend/react-D5kF5LxZ.js +383 -0
- package/frontend/remoteEntry.js +74 -0
- package/frontend/rolldown-runtime-EAsCxrDo.js +14 -0
- package/frontend/style.css +442 -0
- package/package.json +44 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
20
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
21
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
22
|
+
if (decorator = decorators[i])
|
|
23
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
24
|
+
if (kind && result) __defProp(target, key, result);
|
|
25
|
+
return result;
|
|
26
|
+
};
|
|
27
|
+
var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
|
|
28
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
29
|
+
|
|
30
|
+
// backend/plugin.ts
|
|
31
|
+
var plugin_exports = {};
|
|
32
|
+
__export(plugin_exports, {
|
|
33
|
+
default: () => plugin_default
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(plugin_exports);
|
|
36
|
+
|
|
37
|
+
// backend/device-registry.service.ts
|
|
38
|
+
var import_common = require("@nestjs/common");
|
|
39
|
+
|
|
40
|
+
// backend/shelly-device.entity.ts
|
|
41
|
+
var import_plugins_backend_sdk = require("@attraccess/plugins-backend-sdk");
|
|
42
|
+
var ShellyDevice = class {
|
|
43
|
+
constructor() {
|
|
44
|
+
__publicField(this, "id");
|
|
45
|
+
__publicField(this, "name");
|
|
46
|
+
__publicField(this, "ipAddress");
|
|
47
|
+
__publicField(this, "generation");
|
|
48
|
+
__publicField(this, "model");
|
|
49
|
+
__publicField(this, "authState");
|
|
50
|
+
__publicField(this, "lastProbeAt");
|
|
51
|
+
__publicField(this, "lastProbeError");
|
|
52
|
+
__publicField(this, "createdAt");
|
|
53
|
+
__publicField(this, "updatedAt");
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
__decorateClass([
|
|
57
|
+
(0, import_plugins_backend_sdk.PrimaryGeneratedColumn)({ type: "integer" })
|
|
58
|
+
], ShellyDevice.prototype, "id", 2);
|
|
59
|
+
__decorateClass([
|
|
60
|
+
(0, import_plugins_backend_sdk.Column)({ type: "varchar" })
|
|
61
|
+
], ShellyDevice.prototype, "name", 2);
|
|
62
|
+
__decorateClass([
|
|
63
|
+
(0, import_plugins_backend_sdk.Column)({ type: "varchar", name: "ip_address", unique: true })
|
|
64
|
+
], ShellyDevice.prototype, "ipAddress", 2);
|
|
65
|
+
__decorateClass([
|
|
66
|
+
(0, import_plugins_backend_sdk.Column)({ type: "integer", nullable: true })
|
|
67
|
+
], ShellyDevice.prototype, "generation", 2);
|
|
68
|
+
__decorateClass([
|
|
69
|
+
(0, import_plugins_backend_sdk.Column)({ type: "varchar", nullable: true })
|
|
70
|
+
], ShellyDevice.prototype, "model", 2);
|
|
71
|
+
__decorateClass([
|
|
72
|
+
(0, import_plugins_backend_sdk.Column)({ type: "varchar", name: "auth_state", default: "unknown" })
|
|
73
|
+
], ShellyDevice.prototype, "authState", 2);
|
|
74
|
+
__decorateClass([
|
|
75
|
+
(0, import_plugins_backend_sdk.Column)({ type: "varchar", name: "last_probe_at", nullable: true })
|
|
76
|
+
], ShellyDevice.prototype, "lastProbeAt", 2);
|
|
77
|
+
__decorateClass([
|
|
78
|
+
(0, import_plugins_backend_sdk.Column)({ type: "varchar", name: "last_probe_error", nullable: true })
|
|
79
|
+
], ShellyDevice.prototype, "lastProbeError", 2);
|
|
80
|
+
__decorateClass([
|
|
81
|
+
(0, import_plugins_backend_sdk.Column)({ type: "varchar", name: "created_at" })
|
|
82
|
+
], ShellyDevice.prototype, "createdAt", 2);
|
|
83
|
+
__decorateClass([
|
|
84
|
+
(0, import_plugins_backend_sdk.Column)({ type: "varchar", name: "updated_at" })
|
|
85
|
+
], ShellyDevice.prototype, "updatedAt", 2);
|
|
86
|
+
ShellyDevice = __decorateClass([
|
|
87
|
+
(0, import_plugins_backend_sdk.Entity)({ name: "plugin_shelly_devices" })
|
|
88
|
+
], ShellyDevice);
|
|
89
|
+
|
|
90
|
+
// backend/device-registry.service.ts
|
|
91
|
+
var PLUGIN_CONTEXT = /* @__PURE__ */ Symbol.for("attraccess.plugin.context");
|
|
92
|
+
var DeviceRegistryService = class {
|
|
93
|
+
constructor(context) {
|
|
94
|
+
__publicField(this, "context", context);
|
|
95
|
+
__publicField(this, "repository", null);
|
|
96
|
+
}
|
|
97
|
+
// Resolve the repository lazily, not in the constructor: plugin providers are
|
|
98
|
+
// instantiated during the host's DI pass, before the host DataSource ref is
|
|
99
|
+
// wired up (PluginContext.getRepository throws "Host DataSource is not
|
|
100
|
+
// available yet" until bootstrap completes). The first query happens on an
|
|
101
|
+
// HTTP request, well after bootstrap, so resolving on demand is safe; cache it
|
|
102
|
+
// once resolved.
|
|
103
|
+
get devices() {
|
|
104
|
+
if (this.repository === null) {
|
|
105
|
+
this.repository = this.context.getRepository(ShellyDevice);
|
|
106
|
+
}
|
|
107
|
+
return this.repository;
|
|
108
|
+
}
|
|
109
|
+
list() {
|
|
110
|
+
return this.devices.find({ order: { createdAt: "ASC", id: "ASC" } });
|
|
111
|
+
}
|
|
112
|
+
findById(id) {
|
|
113
|
+
return this.devices.findOne({ where: { id } });
|
|
114
|
+
}
|
|
115
|
+
findByIp(ipAddress) {
|
|
116
|
+
return this.devices.findOne({ where: { ipAddress } });
|
|
117
|
+
}
|
|
118
|
+
create(input) {
|
|
119
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
120
|
+
const device = this.devices.create({
|
|
121
|
+
name: input.name,
|
|
122
|
+
ipAddress: input.ipAddress,
|
|
123
|
+
generation: input.generation,
|
|
124
|
+
model: input.model,
|
|
125
|
+
authState: input.authState,
|
|
126
|
+
lastProbeAt: input.lastProbeAt,
|
|
127
|
+
lastProbeError: input.lastProbeError,
|
|
128
|
+
createdAt: now,
|
|
129
|
+
updatedAt: now
|
|
130
|
+
});
|
|
131
|
+
return this.devices.save(device);
|
|
132
|
+
}
|
|
133
|
+
async updateProbe(id, patch) {
|
|
134
|
+
await this.devices.update(id, {
|
|
135
|
+
generation: patch.generation,
|
|
136
|
+
model: patch.model,
|
|
137
|
+
authState: patch.authState,
|
|
138
|
+
lastProbeAt: patch.lastProbeAt,
|
|
139
|
+
lastProbeError: patch.lastProbeError,
|
|
140
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async updateAuthState(id, authState) {
|
|
144
|
+
await this.devices.update(id, {
|
|
145
|
+
authState,
|
|
146
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
async delete(id) {
|
|
150
|
+
await this.devices.delete(id);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
DeviceRegistryService = __decorateClass([
|
|
154
|
+
(0, import_common.Injectable)(),
|
|
155
|
+
__decorateParam(0, (0, import_common.Inject)(PLUGIN_CONTEXT))
|
|
156
|
+
], DeviceRegistryService);
|
|
157
|
+
|
|
158
|
+
// backend/discovery.service.ts
|
|
159
|
+
var import_common3 = require("@nestjs/common");
|
|
160
|
+
|
|
161
|
+
// backend/mdns-discovery.ts
|
|
162
|
+
var import_node_dgram = require("node:dgram");
|
|
163
|
+
var MDNS_ADDRESS = "224.0.0.251";
|
|
164
|
+
var MDNS_PORT = 5353;
|
|
165
|
+
var SERVICE = "_shelly._tcp.local";
|
|
166
|
+
var DEFAULT_TIMEOUT_MS = 2e3;
|
|
167
|
+
var TYPE_PTR = 12;
|
|
168
|
+
var TYPE_A = 1;
|
|
169
|
+
var QCLASS_IN_UNICAST = 32769;
|
|
170
|
+
function buildQuery() {
|
|
171
|
+
const labels = SERVICE.split(".");
|
|
172
|
+
const nameLength = labels.reduce((total, label) => total + 1 + label.length, 0) + 1;
|
|
173
|
+
const packet = Buffer.alloc(12 + nameLength + 4);
|
|
174
|
+
packet.writeUInt16BE(1, 4);
|
|
175
|
+
let offset = 12;
|
|
176
|
+
for (const label of labels) {
|
|
177
|
+
packet.writeUInt8(label.length, offset);
|
|
178
|
+
offset += 1;
|
|
179
|
+
offset += packet.write(label, offset, "ascii");
|
|
180
|
+
}
|
|
181
|
+
packet.writeUInt8(0, offset);
|
|
182
|
+
offset += 1;
|
|
183
|
+
packet.writeUInt16BE(TYPE_PTR, offset);
|
|
184
|
+
packet.writeUInt16BE(QCLASS_IN_UNICAST, offset + 2);
|
|
185
|
+
return packet;
|
|
186
|
+
}
|
|
187
|
+
function skipName(packet, start) {
|
|
188
|
+
let offset = start;
|
|
189
|
+
while (offset < packet.length) {
|
|
190
|
+
const length = packet[offset];
|
|
191
|
+
if (length === 0) return offset + 1;
|
|
192
|
+
if ((length & 192) === 192) return offset + 2;
|
|
193
|
+
offset += length + 1;
|
|
194
|
+
}
|
|
195
|
+
return packet.length;
|
|
196
|
+
}
|
|
197
|
+
function parseShellyAddresses(packet) {
|
|
198
|
+
if (!packet.toString("latin1").includes("_shelly")) return [];
|
|
199
|
+
if (packet.length < 12) return [];
|
|
200
|
+
const counts = [4, 6, 8, 10].map((at) => packet.readUInt16BE(at));
|
|
201
|
+
const [questions, answers, authorities, additionals] = counts;
|
|
202
|
+
let offset = 12;
|
|
203
|
+
for (let i = 0; i < questions; i++) {
|
|
204
|
+
offset = skipName(packet, offset) + 4;
|
|
205
|
+
}
|
|
206
|
+
const addresses = [];
|
|
207
|
+
const records = answers + authorities + additionals;
|
|
208
|
+
for (let i = 0; i < records && offset + 10 <= packet.length; i++) {
|
|
209
|
+
offset = skipName(packet, offset);
|
|
210
|
+
if (offset + 10 > packet.length) break;
|
|
211
|
+
const type = packet.readUInt16BE(offset);
|
|
212
|
+
const rdLength = packet.readUInt16BE(offset + 8);
|
|
213
|
+
offset += 10;
|
|
214
|
+
if (offset + rdLength > packet.length) break;
|
|
215
|
+
if (type === TYPE_A && rdLength === 4) {
|
|
216
|
+
addresses.push([packet[offset], packet[offset + 1], packet[offset + 2], packet[offset + 3]].join("."));
|
|
217
|
+
}
|
|
218
|
+
offset += rdLength;
|
|
219
|
+
}
|
|
220
|
+
return [...new Set(addresses)];
|
|
221
|
+
}
|
|
222
|
+
function discoverViaMdns(timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
223
|
+
return new Promise((resolve) => {
|
|
224
|
+
const found = /* @__PURE__ */ new Set();
|
|
225
|
+
let socket;
|
|
226
|
+
try {
|
|
227
|
+
socket = (0, import_node_dgram.createSocket)({ type: "udp4", reuseAddr: true });
|
|
228
|
+
} catch {
|
|
229
|
+
resolve([]);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
let settled = false;
|
|
233
|
+
const finish = () => {
|
|
234
|
+
if (settled) return;
|
|
235
|
+
settled = true;
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
try {
|
|
238
|
+
socket.close();
|
|
239
|
+
} catch {
|
|
240
|
+
}
|
|
241
|
+
resolve([...found]);
|
|
242
|
+
};
|
|
243
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
244
|
+
timer.unref?.();
|
|
245
|
+
socket.on("error", finish);
|
|
246
|
+
socket.on("message", (message) => {
|
|
247
|
+
for (const address of parseShellyAddresses(message)) found.add(address);
|
|
248
|
+
});
|
|
249
|
+
socket.on("listening", () => {
|
|
250
|
+
try {
|
|
251
|
+
socket.setMulticastTTL(255);
|
|
252
|
+
socket.send(buildQuery(), MDNS_PORT, MDNS_ADDRESS);
|
|
253
|
+
} catch {
|
|
254
|
+
finish();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
try {
|
|
258
|
+
socket.bind(0);
|
|
259
|
+
} catch {
|
|
260
|
+
finish();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// backend/network-scan.ts
|
|
266
|
+
var import_node_os = require("node:os");
|
|
267
|
+
var MIN_PREFIX_LENGTH = 22;
|
|
268
|
+
var InvalidCidrError = class extends Error {
|
|
269
|
+
};
|
|
270
|
+
var CIDR_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/;
|
|
271
|
+
function toInt(octets) {
|
|
272
|
+
return (octets[0] << 24 | octets[1] << 16 | octets[2] << 8 | octets[3]) >>> 0;
|
|
273
|
+
}
|
|
274
|
+
function toIp(value) {
|
|
275
|
+
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
276
|
+
}
|
|
277
|
+
function isPrivateIpv4(ip) {
|
|
278
|
+
const parts = ip.split(".").map(Number);
|
|
279
|
+
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return false;
|
|
280
|
+
const [a, b] = parts;
|
|
281
|
+
if (a === 10) return true;
|
|
282
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
283
|
+
if (a === 192 && b === 168) return true;
|
|
284
|
+
if (a === 169 && b === 254) return true;
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
function expandCidr(cidr) {
|
|
288
|
+
const match = CIDR_PATTERN.exec(cidr.trim());
|
|
289
|
+
if (!match) {
|
|
290
|
+
throw new InvalidCidrError(`"${cidr}" is not a valid IPv4 CIDR (expected e.g. 192.168.1.0/24)`);
|
|
291
|
+
}
|
|
292
|
+
const octets = match.slice(1, 5).map(Number);
|
|
293
|
+
const prefix = Number(match[5]);
|
|
294
|
+
if (octets.some((o) => o > 255) || prefix > 32) {
|
|
295
|
+
throw new InvalidCidrError(`"${cidr}" is not a valid IPv4 CIDR (expected e.g. 192.168.1.0/24)`);
|
|
296
|
+
}
|
|
297
|
+
if (prefix < MIN_PREFIX_LENGTH) {
|
|
298
|
+
throw new InvalidCidrError(
|
|
299
|
+
`subnet ${cidr} is too large to scan \u2014 use a prefix of /${MIN_PREFIX_LENGTH} or longer (e.g. /24)`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const address = toInt(octets);
|
|
303
|
+
const mask = prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0;
|
|
304
|
+
const network = (address & mask) >>> 0;
|
|
305
|
+
const broadcast = (network | ~mask >>> 0) >>> 0;
|
|
306
|
+
if (!isPrivateIpv4(toIp(network))) {
|
|
307
|
+
throw new InvalidCidrError(
|
|
308
|
+
`refusing to scan ${cidr}: only private networks (10/8, 172.16/12, 192.168/16, 169.254/16) may be scanned`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const first = prefix >= 31 ? network : network + 1;
|
|
312
|
+
const last = prefix >= 31 ? broadcast : broadcast - 1;
|
|
313
|
+
const hosts = [];
|
|
314
|
+
for (let value = first; value <= last; value++) {
|
|
315
|
+
hosts.push(toIp(value));
|
|
316
|
+
}
|
|
317
|
+
return hosts;
|
|
318
|
+
}
|
|
319
|
+
function localScanTargets() {
|
|
320
|
+
const targets = /* @__PURE__ */ new Set();
|
|
321
|
+
for (const addresses of Object.values((0, import_node_os.networkInterfaces)())) {
|
|
322
|
+
for (const address of addresses ?? []) {
|
|
323
|
+
const isIpv4 = address.family === "IPv4" || address.family === 4;
|
|
324
|
+
if (!isIpv4 || address.internal || !address.cidr) continue;
|
|
325
|
+
const prefix = Number(address.cidr.split("/")[1]);
|
|
326
|
+
if (!Number.isInteger(prefix) || prefix < MIN_PREFIX_LENGTH) continue;
|
|
327
|
+
if (!isPrivateIpv4(address.address)) continue;
|
|
328
|
+
targets.add(address.cidr);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return [...targets];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// backend/shelly-probe.service.ts
|
|
335
|
+
var import_common2 = require("@nestjs/common");
|
|
336
|
+
var PROBE_TIMEOUT_MS = 5e3;
|
|
337
|
+
var SCAN_PROBE_TIMEOUT_MS = 1e3;
|
|
338
|
+
var ShellyProbeService = class {
|
|
339
|
+
/**
|
|
340
|
+
* Probes `http://<ipAddress>/shelly` and interprets the response. Throws an
|
|
341
|
+
* Error with a human-readable message if the device is unreachable or returns
|
|
342
|
+
* a non-OK / non-JSON response.
|
|
343
|
+
*/
|
|
344
|
+
async probe(ipAddress, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
345
|
+
const url = `http://${ipAddress}/shelly`;
|
|
346
|
+
let json;
|
|
347
|
+
try {
|
|
348
|
+
const res = await fetch(url, {
|
|
349
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
350
|
+
headers: { Accept: "application/json" }
|
|
351
|
+
});
|
|
352
|
+
if (!res.ok) {
|
|
353
|
+
throw new Error(`device responded HTTP ${res.status}`);
|
|
354
|
+
}
|
|
355
|
+
json = await res.json();
|
|
356
|
+
} catch (err) {
|
|
357
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
358
|
+
throw new Error(`probe of ${url} failed: ${reason}`);
|
|
359
|
+
}
|
|
360
|
+
return this.interpret(json);
|
|
361
|
+
}
|
|
362
|
+
interpret(json) {
|
|
363
|
+
if (typeof json.gen === "number") {
|
|
364
|
+
const authState2 = json.auth_en === true ? "required" : "none";
|
|
365
|
+
return { generation: json.gen, model: json.model ?? null, authState: authState2, raw: json };
|
|
366
|
+
}
|
|
367
|
+
const authState = json.auth === true ? "required" : "none";
|
|
368
|
+
return { generation: 1, model: json.type ?? null, authState, raw: json };
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
ShellyProbeService = __decorateClass([
|
|
372
|
+
(0, import_common2.Injectable)()
|
|
373
|
+
], ShellyProbeService);
|
|
374
|
+
|
|
375
|
+
// backend/discovery.service.ts
|
|
376
|
+
var SCAN_CONCURRENCY = 64;
|
|
377
|
+
async function mapWithConcurrency(items, limit, worker) {
|
|
378
|
+
const results = new Array(items.length);
|
|
379
|
+
let cursor = 0;
|
|
380
|
+
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
381
|
+
while (cursor < items.length) {
|
|
382
|
+
const index = cursor++;
|
|
383
|
+
results[index] = await worker(items[index]);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
await Promise.all(runners);
|
|
387
|
+
return results;
|
|
388
|
+
}
|
|
389
|
+
var DiscoveryService = class {
|
|
390
|
+
constructor(registry, probe) {
|
|
391
|
+
__publicField(this, "registry", registry);
|
|
392
|
+
__publicField(this, "probe", probe);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Discovers devices and writes them to the registry.
|
|
396
|
+
*
|
|
397
|
+
* @param cidr Subnet to scan. Omitted, the host's own private networks are
|
|
398
|
+
* used — which finds nothing on a Docker bridge network (its /16 is skipped
|
|
399
|
+
* as too large), so containerised installs are expected to pass one.
|
|
400
|
+
*/
|
|
401
|
+
async discover(cidr) {
|
|
402
|
+
const subnets = cidr ? [cidr] : localScanTargets();
|
|
403
|
+
const scanTargets = subnets.flatMap((subnet) => expandCidr(subnet));
|
|
404
|
+
const mdnsAddresses = (await discoverViaMdns()).filter(isPrivateIpv4);
|
|
405
|
+
const seen = new Set(mdnsAddresses);
|
|
406
|
+
const candidates = mdnsAddresses.map((ipAddress) => ({
|
|
407
|
+
ipAddress,
|
|
408
|
+
source: "mdns"
|
|
409
|
+
}));
|
|
410
|
+
for (const ipAddress of scanTargets) {
|
|
411
|
+
if (seen.has(ipAddress)) continue;
|
|
412
|
+
seen.add(ipAddress);
|
|
413
|
+
candidates.push({ ipAddress, source: "scan" });
|
|
414
|
+
}
|
|
415
|
+
const hits = (await mapWithConcurrency(candidates, SCAN_CONCURRENCY, async (candidate) => {
|
|
416
|
+
try {
|
|
417
|
+
const result = await this.probe.probe(candidate.ipAddress, SCAN_PROBE_TIMEOUT_MS);
|
|
418
|
+
return { ...candidate, result };
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
})).filter((hit) => hit !== null);
|
|
423
|
+
const devices = [];
|
|
424
|
+
for (const hit of hits) {
|
|
425
|
+
devices.push(await this.persist(hit.ipAddress, hit.source, hit.result));
|
|
426
|
+
}
|
|
427
|
+
return { subnets, probed: candidates.length, devices };
|
|
428
|
+
}
|
|
429
|
+
async persist(ipAddress, source, result) {
|
|
430
|
+
const probeFields = {
|
|
431
|
+
generation: result.generation,
|
|
432
|
+
model: result.model,
|
|
433
|
+
authState: result.authState,
|
|
434
|
+
lastProbeAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
435
|
+
lastProbeError: null
|
|
436
|
+
};
|
|
437
|
+
const existing = await this.registry.findByIp(ipAddress);
|
|
438
|
+
if (existing) {
|
|
439
|
+
await this.registry.updateProbe(existing.id, probeFields);
|
|
440
|
+
return { deviceId: existing.id, ipAddress, name: existing.name, ...result, isNew: false, source };
|
|
441
|
+
}
|
|
442
|
+
const name = result.model ? `${result.model} (${ipAddress})` : ipAddress;
|
|
443
|
+
const created = await this.registry.create({ name, ipAddress, ...probeFields });
|
|
444
|
+
return { deviceId: created.id, ipAddress, name, ...result, isNew: true, source };
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
DiscoveryService = __decorateClass([
|
|
448
|
+
(0, import_common3.Injectable)(),
|
|
449
|
+
__decorateParam(0, (0, import_common3.Inject)(DeviceRegistryService)),
|
|
450
|
+
__decorateParam(1, (0, import_common3.Inject)(ShellyProbeService))
|
|
451
|
+
], DiscoveryService);
|
|
452
|
+
|
|
453
|
+
// backend/shelly.controller.ts
|
|
454
|
+
var import_common7 = require("@nestjs/common");
|
|
455
|
+
var import_plugins_backend_sdk2 = require("@attraccess/plugins-backend-sdk");
|
|
456
|
+
|
|
457
|
+
// backend/shelly-device-api.service.ts
|
|
458
|
+
var import_common5 = require("@nestjs/common");
|
|
459
|
+
|
|
460
|
+
// backend/shelly-http.client.ts
|
|
461
|
+
var import_crypto = require("crypto");
|
|
462
|
+
var import_common4 = require("@nestjs/common");
|
|
463
|
+
var REQUEST_TIMEOUT_MS = 8e3;
|
|
464
|
+
var ShellyHttpClient = class {
|
|
465
|
+
getJson(url, credentials) {
|
|
466
|
+
return this.requestJson(url, { method: "GET", credentials });
|
|
467
|
+
}
|
|
468
|
+
postJson(url, body, credentials) {
|
|
469
|
+
return this.requestJson(url, { method: "POST", body, credentials });
|
|
470
|
+
}
|
|
471
|
+
async requestJson(url, options) {
|
|
472
|
+
const requestOptions = this.buildFetchOptions(options.method, options.body);
|
|
473
|
+
let response = await fetch(url, requestOptions);
|
|
474
|
+
if (response.status === 401 && options.credentials.currentPassword) {
|
|
475
|
+
response = await fetch(url, {
|
|
476
|
+
...requestOptions,
|
|
477
|
+
headers: {
|
|
478
|
+
...asHeaderRecord(requestOptions.headers),
|
|
479
|
+
...this.buildAuthHeader(response, url, options.method, options.credentials)
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
if (!response.ok) {
|
|
484
|
+
throw new Error(`Shelly request ${url} failed: HTTP ${response.status}`);
|
|
485
|
+
}
|
|
486
|
+
return response.json();
|
|
487
|
+
}
|
|
488
|
+
buildFetchOptions(method, body) {
|
|
489
|
+
const headers = { Accept: "application/json" };
|
|
490
|
+
if (body !== void 0) {
|
|
491
|
+
headers["Content-Type"] = "application/json";
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
method,
|
|
495
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
496
|
+
headers,
|
|
497
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
buildAuthHeader(response, url, method, credentials) {
|
|
501
|
+
const username = credentials.username?.trim() || "admin";
|
|
502
|
+
const password = credentials.currentPassword ?? "";
|
|
503
|
+
const challenge = response.headers.get("www-authenticate") ?? response.headers.get("WWW-Authenticate") ?? "";
|
|
504
|
+
if (!challenge.toLowerCase().startsWith("digest ")) {
|
|
505
|
+
return { Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` };
|
|
506
|
+
}
|
|
507
|
+
return { Authorization: buildDigestAuthorization(challenge, url, method, username, password) };
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
ShellyHttpClient = __decorateClass([
|
|
511
|
+
(0, import_common4.Injectable)()
|
|
512
|
+
], ShellyHttpClient);
|
|
513
|
+
function asHeaderRecord(headers) {
|
|
514
|
+
if (!headers) return {};
|
|
515
|
+
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
|
516
|
+
if (Array.isArray(headers)) return Object.fromEntries(headers);
|
|
517
|
+
return headers;
|
|
518
|
+
}
|
|
519
|
+
function buildDigestAuthorization(challenge, url, method, username, password) {
|
|
520
|
+
const params = parseDigestChallenge(challenge);
|
|
521
|
+
const algorithm = (params.algorithm ?? "MD5").toUpperCase();
|
|
522
|
+
const hash = algorithm.includes("SHA-256") ? sha256 : md5;
|
|
523
|
+
const nonce = params.nonce;
|
|
524
|
+
const realm = params.realm;
|
|
525
|
+
if (!nonce || !realm) {
|
|
526
|
+
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
527
|
+
}
|
|
528
|
+
const requestUrl = new URL(url);
|
|
529
|
+
const uri = `${requestUrl.pathname}${requestUrl.search}`;
|
|
530
|
+
const qop = params.qop?.split(",").map((value) => value.trim()).includes("auth") ? "auth" : void 0;
|
|
531
|
+
const nc = "00000001";
|
|
532
|
+
const cnonce = (0, import_crypto.randomBytes)(8).toString("hex");
|
|
533
|
+
const ha1 = hash(`${username}:${realm}:${password}`);
|
|
534
|
+
const ha2 = hash(`${method}:${uri}`);
|
|
535
|
+
const response = qop ? hash(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`) : hash(`${ha1}:${nonce}:${ha2}`);
|
|
536
|
+
const parts = [
|
|
537
|
+
`username="${username}"`,
|
|
538
|
+
`realm="${realm}"`,
|
|
539
|
+
`nonce="${nonce}"`,
|
|
540
|
+
`uri="${uri}"`,
|
|
541
|
+
`response="${response}"`,
|
|
542
|
+
`algorithm=${algorithm}`
|
|
543
|
+
];
|
|
544
|
+
if (params.opaque) parts.push(`opaque="${params.opaque}"`);
|
|
545
|
+
if (qop) parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`);
|
|
546
|
+
return `Digest ${parts.join(", ")}`;
|
|
547
|
+
}
|
|
548
|
+
function parseDigestChallenge(challenge) {
|
|
549
|
+
const digest = challenge.replace(/^Digest\s+/i, "");
|
|
550
|
+
const params = {};
|
|
551
|
+
for (const match of digest.matchAll(/([a-zA-Z0-9_-]+)=(?:"([^"]*)"|([^,]*))/g)) {
|
|
552
|
+
params[match[1].toLowerCase()] = match[2] ?? match[3] ?? "";
|
|
553
|
+
}
|
|
554
|
+
return params;
|
|
555
|
+
}
|
|
556
|
+
function md5(value) {
|
|
557
|
+
return (0, import_crypto.createHash)("md5").update(value).digest("hex");
|
|
558
|
+
}
|
|
559
|
+
function sha256(value) {
|
|
560
|
+
return (0, import_crypto.createHash)("sha256").update(value).digest("hex");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// backend/shelly-device-api.service.ts
|
|
564
|
+
var ShellyDeviceApiService = class {
|
|
565
|
+
// esbuild does not emit decorator metadata, so Nest cannot infer constructor
|
|
566
|
+
// types for injection — always inject by an explicit token.
|
|
567
|
+
constructor(http) {
|
|
568
|
+
__publicField(this, "http", http);
|
|
569
|
+
}
|
|
570
|
+
async getDeviceInfo(target) {
|
|
571
|
+
const [status, config] = await Promise.all(
|
|
572
|
+
target.generation === 1 ? [
|
|
573
|
+
this.http.getJson(`http://${target.ipAddress}/status`, target),
|
|
574
|
+
this.http.getJson(`http://${target.ipAddress}/settings`, target)
|
|
575
|
+
] : [
|
|
576
|
+
this.http.getJson(`http://${target.ipAddress}/rpc/Shelly.GetStatus`, target),
|
|
577
|
+
this.http.getJson(`http://${target.ipAddress}/rpc/Shelly.GetConfig`, target)
|
|
578
|
+
]
|
|
579
|
+
);
|
|
580
|
+
return { generation: target.generation, status, config, fetchedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
581
|
+
}
|
|
582
|
+
async setAdminPassword(input) {
|
|
583
|
+
const username = input.username?.trim() || "admin";
|
|
584
|
+
if (input.generation === 1) {
|
|
585
|
+
const url = new URL(`http://${input.ipAddress}/settings/login`);
|
|
586
|
+
url.searchParams.set("enabled", "1");
|
|
587
|
+
url.searchParams.set("username", username);
|
|
588
|
+
url.searchParams.set("password", input.password);
|
|
589
|
+
await this.http.getJson(url.toString(), input);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const info = await this.http.getJson(`http://${input.ipAddress}/shelly`, input);
|
|
593
|
+
const realm = info.id?.trim();
|
|
594
|
+
if (!realm) {
|
|
595
|
+
throw new Error("Shelly.SetAuth requires the device id from GET /shelly");
|
|
596
|
+
}
|
|
597
|
+
await this.http.postJson(
|
|
598
|
+
`http://${input.ipAddress}/rpc/Shelly.SetAuth`,
|
|
599
|
+
{
|
|
600
|
+
user: username,
|
|
601
|
+
realm,
|
|
602
|
+
ha1: md5(`${username}:${realm}:${input.password}`)
|
|
603
|
+
},
|
|
604
|
+
input
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
ShellyDeviceApiService = __decorateClass([
|
|
609
|
+
(0, import_common5.Injectable)(),
|
|
610
|
+
__decorateParam(0, (0, import_common5.Inject)(ShellyHttpClient))
|
|
611
|
+
], ShellyDeviceApiService);
|
|
612
|
+
|
|
613
|
+
// backend/shelly-firmware.service.ts
|
|
614
|
+
var import_common6 = require("@nestjs/common");
|
|
615
|
+
var ShellyFirmwareService = class {
|
|
616
|
+
// esbuild does not emit decorator metadata, so Nest cannot infer constructor
|
|
617
|
+
// types for injection — always inject by an explicit token.
|
|
618
|
+
constructor(http) {
|
|
619
|
+
__publicField(this, "http", http);
|
|
620
|
+
}
|
|
621
|
+
async getStatus(target) {
|
|
622
|
+
return target.generation === 1 ? this.getGen1Status(target) : this.getGen2Status(target);
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Kicks off the OTA. The device answers before it reboots; from then on it is
|
|
626
|
+
* unreachable for a while, so callers must poll getStatus() rather than expect
|
|
627
|
+
* a completion signal here.
|
|
628
|
+
*/
|
|
629
|
+
async startUpdate(target, stage) {
|
|
630
|
+
if (target.generation === 1) {
|
|
631
|
+
const url = new URL(`http://${target.ipAddress}/ota`);
|
|
632
|
+
url.searchParams.set(stage === "beta" ? "beta" : "update", "true");
|
|
633
|
+
await this.http.getJson(url.toString(), target);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
await this.http.postJson(`http://${target.ipAddress}/rpc/Shelly.Update`, { stage }, target);
|
|
637
|
+
}
|
|
638
|
+
async getGen1Status(target) {
|
|
639
|
+
const ota = await this.http.getJson(`http://${target.ipAddress}/ota`, target);
|
|
640
|
+
const current = ota.old_version ?? null;
|
|
641
|
+
const stable = ota.has_update && ota.new_version && ota.new_version !== current ? ota.new_version : null;
|
|
642
|
+
const beta = ota.beta_version && ota.beta_version !== current ? ota.beta_version : null;
|
|
643
|
+
return {
|
|
644
|
+
generation: 1,
|
|
645
|
+
currentVersion: current,
|
|
646
|
+
available: { stable, beta },
|
|
647
|
+
hasUpdate: stable !== null,
|
|
648
|
+
state: toFirmwareState(ota.status),
|
|
649
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
async getGen2Status(target) {
|
|
653
|
+
const [info, check] = await Promise.all([
|
|
654
|
+
this.http.getJson(`http://${target.ipAddress}/rpc/Shelly.GetDeviceInfo`, target),
|
|
655
|
+
this.http.getJson(
|
|
656
|
+
`http://${target.ipAddress}/rpc/Shelly.CheckForUpdate`,
|
|
657
|
+
target
|
|
658
|
+
)
|
|
659
|
+
]);
|
|
660
|
+
const stable = check?.stable?.version ?? null;
|
|
661
|
+
const beta = check?.beta?.version ?? null;
|
|
662
|
+
return {
|
|
663
|
+
generation: target.generation,
|
|
664
|
+
currentVersion: info?.ver ?? null,
|
|
665
|
+
available: { stable, beta },
|
|
666
|
+
hasUpdate: stable !== null,
|
|
667
|
+
// ponytail: Gen2+ only reports OTA progress over its WebSocket NotifyEvent
|
|
668
|
+
// channel, not over REST — a device we can reach is by definition not
|
|
669
|
+
// mid-update, so the UI infers "updating" from the device being offline
|
|
670
|
+
// right after it triggered one.
|
|
671
|
+
state: "idle",
|
|
672
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
ShellyFirmwareService = __decorateClass([
|
|
677
|
+
(0, import_common6.Injectable)(),
|
|
678
|
+
__decorateParam(0, (0, import_common6.Inject)(ShellyHttpClient))
|
|
679
|
+
], ShellyFirmwareService);
|
|
680
|
+
function toFirmwareState(status) {
|
|
681
|
+
return status === "idle" || status === "pending" || status === "updating" ? status : "unknown";
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// backend/shelly.controller.ts
|
|
685
|
+
var ShellyController = class {
|
|
686
|
+
// esbuild does not emit decorator metadata, so Nest cannot infer constructor
|
|
687
|
+
// types for injection — always inject by an explicit token.
|
|
688
|
+
constructor(registry, probe, discovery, deviceApi, firmware) {
|
|
689
|
+
__publicField(this, "registry", registry);
|
|
690
|
+
__publicField(this, "probe", probe);
|
|
691
|
+
__publicField(this, "discovery", discovery);
|
|
692
|
+
__publicField(this, "deviceApi", deviceApi);
|
|
693
|
+
__publicField(this, "firmware", firmware);
|
|
694
|
+
}
|
|
695
|
+
async discover(body) {
|
|
696
|
+
const cidr = (body?.cidr ?? "").trim() || void 0;
|
|
697
|
+
try {
|
|
698
|
+
return await this.discovery.discover(cidr);
|
|
699
|
+
} catch (err) {
|
|
700
|
+
if (err instanceof InvalidCidrError) {
|
|
701
|
+
throw new import_common7.BadRequestException(err.message);
|
|
702
|
+
}
|
|
703
|
+
throw err;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
list() {
|
|
707
|
+
return this.registry.list();
|
|
708
|
+
}
|
|
709
|
+
async add(body) {
|
|
710
|
+
const ipAddress = (body?.ipAddress ?? "").trim();
|
|
711
|
+
if (!ipAddress) {
|
|
712
|
+
throw new import_common7.BadRequestException("ipAddress is required");
|
|
713
|
+
}
|
|
714
|
+
if (await this.registry.findByIp(ipAddress)) {
|
|
715
|
+
throw new import_common7.ConflictException(`a device with IP ${ipAddress} already exists`);
|
|
716
|
+
}
|
|
717
|
+
const name = (body?.name ?? "").trim() || ipAddress;
|
|
718
|
+
const probed = await this.tryProbe(ipAddress);
|
|
719
|
+
return this.registry.create({
|
|
720
|
+
name,
|
|
721
|
+
ipAddress,
|
|
722
|
+
generation: probed.result?.generation ?? null,
|
|
723
|
+
model: probed.result?.model ?? null,
|
|
724
|
+
authState: probed.result?.authState ?? "unknown",
|
|
725
|
+
lastProbeAt: probed.at,
|
|
726
|
+
lastProbeError: probed.error
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
async firmwareOverview() {
|
|
730
|
+
const devices = await this.registry.list();
|
|
731
|
+
return Promise.all(
|
|
732
|
+
devices.map(async (device) => {
|
|
733
|
+
if (device.generation === null) {
|
|
734
|
+
return { deviceId: device.id, status: null, error: "device generation is unknown; probe it first" };
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
const status = await this.firmware.getStatus({
|
|
738
|
+
ipAddress: device.ipAddress,
|
|
739
|
+
generation: device.generation
|
|
740
|
+
});
|
|
741
|
+
return { deviceId: device.id, status, error: null };
|
|
742
|
+
} catch (err) {
|
|
743
|
+
return { deviceId: device.id, status: null, error: err instanceof Error ? err.message : String(err) };
|
|
744
|
+
}
|
|
745
|
+
})
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
async firmwareStatus(id, query) {
|
|
749
|
+
const device = await this.requireDeviceWithGeneration(id);
|
|
750
|
+
return this.firmware.getStatus({
|
|
751
|
+
ipAddress: device.ipAddress,
|
|
752
|
+
generation: device.generation,
|
|
753
|
+
username: query.username,
|
|
754
|
+
currentPassword: query.currentPassword
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
async startFirmwareUpdate(id, body) {
|
|
758
|
+
const stage = body?.stage ?? "stable";
|
|
759
|
+
if (stage !== "stable" && stage !== "beta") {
|
|
760
|
+
throw new import_common7.BadRequestException(`stage must be "stable" or "beta"`);
|
|
761
|
+
}
|
|
762
|
+
const device = await this.requireDeviceWithGeneration(id);
|
|
763
|
+
await this.firmware.startUpdate(
|
|
764
|
+
{
|
|
765
|
+
ipAddress: device.ipAddress,
|
|
766
|
+
generation: device.generation,
|
|
767
|
+
username: body?.username,
|
|
768
|
+
currentPassword: body?.currentPassword
|
|
769
|
+
},
|
|
770
|
+
stage
|
|
771
|
+
);
|
|
772
|
+
return { started: true, stage };
|
|
773
|
+
}
|
|
774
|
+
async reprobe(id) {
|
|
775
|
+
const device = await this.registry.findById(id);
|
|
776
|
+
if (!device) {
|
|
777
|
+
throw new import_common7.NotFoundException(`device ${id} not found`);
|
|
778
|
+
}
|
|
779
|
+
const probed = await this.tryProbe(device.ipAddress);
|
|
780
|
+
await this.registry.updateProbe(id, {
|
|
781
|
+
// On a failed re-probe keep the previously-known values rather than
|
|
782
|
+
// wiping them; only the error + timestamp are refreshed.
|
|
783
|
+
generation: probed.result?.generation ?? device.generation,
|
|
784
|
+
model: probed.result?.model ?? device.model,
|
|
785
|
+
authState: probed.result?.authState ?? device.authState,
|
|
786
|
+
lastProbeAt: probed.at,
|
|
787
|
+
lastProbeError: probed.error
|
|
788
|
+
});
|
|
789
|
+
const updated = await this.registry.findById(id);
|
|
790
|
+
if (!updated) {
|
|
791
|
+
throw new import_common7.NotFoundException(`device ${id} not found`);
|
|
792
|
+
}
|
|
793
|
+
return updated;
|
|
794
|
+
}
|
|
795
|
+
async info(id, body) {
|
|
796
|
+
const device = await this.requireDeviceWithGeneration(id);
|
|
797
|
+
try {
|
|
798
|
+
return await this.deviceApi.getDeviceInfo({
|
|
799
|
+
ipAddress: device.ipAddress,
|
|
800
|
+
generation: device.generation,
|
|
801
|
+
username: body?.username,
|
|
802
|
+
currentPassword: body?.currentPassword
|
|
803
|
+
});
|
|
804
|
+
} catch (err) {
|
|
805
|
+
throw toDeviceCommunicationException(err);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
async setAuth(id, body) {
|
|
809
|
+
const password = body?.password?.trim();
|
|
810
|
+
if (!password) {
|
|
811
|
+
throw new import_common7.BadRequestException("password is required");
|
|
812
|
+
}
|
|
813
|
+
const device = await this.requireDeviceWithGeneration(id);
|
|
814
|
+
try {
|
|
815
|
+
await this.deviceApi.setAdminPassword({
|
|
816
|
+
ipAddress: device.ipAddress,
|
|
817
|
+
generation: device.generation,
|
|
818
|
+
username: body.username,
|
|
819
|
+
currentPassword: body.currentPassword,
|
|
820
|
+
password
|
|
821
|
+
});
|
|
822
|
+
} catch (err) {
|
|
823
|
+
throw toDeviceCommunicationException(err);
|
|
824
|
+
}
|
|
825
|
+
await this.registry.updateAuthState(id, "required");
|
|
826
|
+
const updated = await this.registry.findById(id);
|
|
827
|
+
if (!updated) {
|
|
828
|
+
throw new import_common7.NotFoundException(`device ${id} not found`);
|
|
829
|
+
}
|
|
830
|
+
return updated;
|
|
831
|
+
}
|
|
832
|
+
async remove(id) {
|
|
833
|
+
if (!await this.registry.findById(id)) {
|
|
834
|
+
throw new import_common7.NotFoundException(`device ${id} not found`);
|
|
835
|
+
}
|
|
836
|
+
await this.registry.delete(id);
|
|
837
|
+
return { deleted: true };
|
|
838
|
+
}
|
|
839
|
+
async requireDeviceWithGeneration(id) {
|
|
840
|
+
const device = await this.registry.findById(id);
|
|
841
|
+
if (!device) {
|
|
842
|
+
throw new import_common7.NotFoundException(`device ${id} not found`);
|
|
843
|
+
}
|
|
844
|
+
if (device.generation !== null) {
|
|
845
|
+
return device;
|
|
846
|
+
}
|
|
847
|
+
const probed = await this.tryProbe(device.ipAddress);
|
|
848
|
+
if (!probed.result) {
|
|
849
|
+
throw new import_common7.BadRequestException(`device generation is unknown; probe failed: ${probed.error}`);
|
|
850
|
+
}
|
|
851
|
+
await this.registry.updateProbe(id, {
|
|
852
|
+
generation: probed.result.generation,
|
|
853
|
+
model: probed.result.model,
|
|
854
|
+
authState: probed.result.authState,
|
|
855
|
+
lastProbeAt: probed.at,
|
|
856
|
+
lastProbeError: null
|
|
857
|
+
});
|
|
858
|
+
const updated = await this.registry.findById(id);
|
|
859
|
+
if (!updated?.generation) {
|
|
860
|
+
throw new import_common7.NotFoundException(`device ${id} not found`);
|
|
861
|
+
}
|
|
862
|
+
return updated;
|
|
863
|
+
}
|
|
864
|
+
async tryProbe(ipAddress) {
|
|
865
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
866
|
+
try {
|
|
867
|
+
return { result: await this.probe.probe(ipAddress), error: null, at };
|
|
868
|
+
} catch (err) {
|
|
869
|
+
return { result: null, error: err instanceof Error ? err.message : String(err), at };
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
__decorateClass([
|
|
874
|
+
(0, import_common7.Post)("discovery"),
|
|
875
|
+
__decorateParam(0, (0, import_common7.Body)())
|
|
876
|
+
], ShellyController.prototype, "discover", 1);
|
|
877
|
+
__decorateClass([
|
|
878
|
+
(0, import_common7.Get)("devices")
|
|
879
|
+
], ShellyController.prototype, "list", 1);
|
|
880
|
+
__decorateClass([
|
|
881
|
+
(0, import_common7.Post)("devices"),
|
|
882
|
+
__decorateParam(0, (0, import_common7.Body)())
|
|
883
|
+
], ShellyController.prototype, "add", 1);
|
|
884
|
+
__decorateClass([
|
|
885
|
+
(0, import_common7.Get)("devices/firmware")
|
|
886
|
+
], ShellyController.prototype, "firmwareOverview", 1);
|
|
887
|
+
__decorateClass([
|
|
888
|
+
(0, import_common7.Get)("devices/:id/firmware"),
|
|
889
|
+
__decorateParam(0, (0, import_common7.Param)("id", import_common7.ParseIntPipe)),
|
|
890
|
+
__decorateParam(1, (0, import_common7.Query)())
|
|
891
|
+
], ShellyController.prototype, "firmwareStatus", 1);
|
|
892
|
+
__decorateClass([
|
|
893
|
+
(0, import_common7.Post)("devices/:id/firmware/update"),
|
|
894
|
+
__decorateParam(0, (0, import_common7.Param)("id", import_common7.ParseIntPipe)),
|
|
895
|
+
__decorateParam(1, (0, import_common7.Body)())
|
|
896
|
+
], ShellyController.prototype, "startFirmwareUpdate", 1);
|
|
897
|
+
__decorateClass([
|
|
898
|
+
(0, import_common7.Post)("devices/:id/probe"),
|
|
899
|
+
__decorateParam(0, (0, import_common7.Param)("id", import_common7.ParseIntPipe))
|
|
900
|
+
], ShellyController.prototype, "reprobe", 1);
|
|
901
|
+
__decorateClass([
|
|
902
|
+
(0, import_common7.Post)("devices/:id/info"),
|
|
903
|
+
__decorateParam(0, (0, import_common7.Param)("id", import_common7.ParseIntPipe)),
|
|
904
|
+
__decorateParam(1, (0, import_common7.Body)())
|
|
905
|
+
], ShellyController.prototype, "info", 1);
|
|
906
|
+
__decorateClass([
|
|
907
|
+
(0, import_common7.Post)("devices/:id/auth"),
|
|
908
|
+
__decorateParam(0, (0, import_common7.Param)("id", import_common7.ParseIntPipe)),
|
|
909
|
+
__decorateParam(1, (0, import_common7.Body)())
|
|
910
|
+
], ShellyController.prototype, "setAuth", 1);
|
|
911
|
+
__decorateClass([
|
|
912
|
+
(0, import_common7.Delete)("devices/:id"),
|
|
913
|
+
__decorateParam(0, (0, import_common7.Param)("id", import_common7.ParseIntPipe))
|
|
914
|
+
], ShellyController.prototype, "remove", 1);
|
|
915
|
+
ShellyController = __decorateClass([
|
|
916
|
+
(0, import_plugins_backend_sdk2.Auth)("resources.update"),
|
|
917
|
+
(0, import_common7.Controller)("shelly"),
|
|
918
|
+
__decorateParam(0, (0, import_common7.Inject)(DeviceRegistryService)),
|
|
919
|
+
__decorateParam(1, (0, import_common7.Inject)(ShellyProbeService)),
|
|
920
|
+
__decorateParam(2, (0, import_common7.Inject)(DiscoveryService)),
|
|
921
|
+
__decorateParam(3, (0, import_common7.Inject)(ShellyDeviceApiService)),
|
|
922
|
+
__decorateParam(4, (0, import_common7.Inject)(ShellyFirmwareService))
|
|
923
|
+
], ShellyController);
|
|
924
|
+
function toDeviceCommunicationException(err) {
|
|
925
|
+
return new import_common7.BadGatewayException(err instanceof Error ? err.message : String(err));
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// backend/plugin.ts
|
|
929
|
+
var PLUGIN_CONTEXT2 = /* @__PURE__ */ Symbol.for("attraccess.plugin.context");
|
|
930
|
+
var ShellyPluginModule = class {
|
|
931
|
+
};
|
|
932
|
+
var plugin = {
|
|
933
|
+
// Host registers these into the shared DataSource at load time (gated on
|
|
934
|
+
// DATABASE_ACCESS) so the registry service can use a real repository.
|
|
935
|
+
entities: [ShellyDevice],
|
|
936
|
+
register(context) {
|
|
937
|
+
return {
|
|
938
|
+
module: ShellyPluginModule,
|
|
939
|
+
controllers: [ShellyController],
|
|
940
|
+
providers: [
|
|
941
|
+
{ provide: PLUGIN_CONTEXT2, useValue: context },
|
|
942
|
+
DeviceRegistryService,
|
|
943
|
+
ShellyProbeService,
|
|
944
|
+
DiscoveryService,
|
|
945
|
+
ShellyHttpClient,
|
|
946
|
+
ShellyDeviceApiService,
|
|
947
|
+
ShellyFirmwareService
|
|
948
|
+
]
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
var plugin_default = plugin;
|