@attraccess/plugin-wago 0.1.0-nightly.925.2 → 0.1.0-nightly.926.1

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 CHANGED
@@ -36,15 +36,17 @@ module.exports = __toCommonJS(plugin_exports);
36
36
 
37
37
  // backend/wago.controller.ts
38
38
  var import_common2 = require("@nestjs/common");
39
- var import_plugins_backend_sdk4 = require("@attraccess/plugins-backend-sdk");
39
+ var import_plugins_backend_sdk6 = require("@attraccess/plugins-backend-sdk");
40
40
 
41
41
  // backend/wago.service.ts
42
- var import_node_crypto = require("node:crypto");
42
+ var import_node_crypto2 = require("node:crypto");
43
43
  var import_common = require("@nestjs/common");
44
44
 
45
45
  // backend/protocol.ts
46
46
  var DISCOVERY_ROOT = "attraccess/wago/discovery";
47
- var REQUIRED_CAPABILITIES = ["claim", "heartbeat"];
47
+ var CONFIGURATION_PROTOCOL_VERSION = 1;
48
+ var CONFIGURATION_CAPABILITY = `configuration-v${CONFIGURATION_PROTOCOL_VERSION}`;
49
+ var REQUIRED_CAPABILITIES = ["claim", "heartbeat", CONFIGURATION_CAPABILITY];
48
50
  var SUPPORTED_PROTOCOL_MAJOR = 1;
49
51
  function parseAnnouncement(payload) {
50
52
  let value;
@@ -85,8 +87,38 @@ function compatibilityError(announcement) {
85
87
  function discoveryTopic(hardwareId) {
86
88
  return `${DISCOVERY_ROOT}/${hardwareId}`;
87
89
  }
88
- function heartbeatTopic(hardwareId) {
89
- return `attraccess/wago/controllers/${hardwareId}/heartbeat`;
90
+ function heartbeatTopic(prefix, hardwareId) {
91
+ return `${normalizeOperationalPrefix(prefix)}/v${CONFIGURATION_PROTOCOL_VERSION}/controllers/${hardwareId}/heartbeat`;
92
+ }
93
+ function configurationDesiredTopic(prefix, hardwareId) {
94
+ return `${normalizeOperationalPrefix(prefix)}/v${CONFIGURATION_PROTOCOL_VERSION}/controllers/${hardwareId}/configuration/desired`;
95
+ }
96
+ function configurationReportedTopic(prefix, hardwareId) {
97
+ return `${normalizeOperationalPrefix(prefix)}/v${CONFIGURATION_PROTOCOL_VERSION}/controllers/${hardwareId}/configuration/reported`;
98
+ }
99
+ function commandTopic(prefix, hardwareId) {
100
+ return `${normalizeOperationalPrefix(prefix)}/v${CONFIGURATION_PROTOCOL_VERSION}/controllers/${hardwareId}/commands`;
101
+ }
102
+ function configurationReportedWildcardTopic(prefix) {
103
+ return configurationReportedTopic(prefix, "+");
104
+ }
105
+ function configurationReportedHardwareId(prefix, topic) {
106
+ const topicPrefix = `${normalizeOperationalPrefix(prefix)}/v${CONFIGURATION_PROTOCOL_VERSION}/controllers/`;
107
+ const topicSuffix = "/configuration/reported";
108
+ if (!topic.startsWith(topicPrefix) || !topic.endsWith(topicSuffix)) return null;
109
+ const hardwareId = topic.slice(topicPrefix.length, -topicSuffix.length);
110
+ return hardwareId && !/[+/]/.test(hardwareId) ? hardwareId : null;
111
+ }
112
+ function normalizeOperationalPrefix(prefix) {
113
+ const trimmed = prefix.trim();
114
+ let start = 0;
115
+ let end = trimmed.length;
116
+ while (trimmed[start] === "/") start += 1;
117
+ while (trimmed[end - 1] === "/") end -= 1;
118
+ const normalized = trimmed.slice(start, end);
119
+ if (!normalized || normalized.split("/").some((segment) => !segment || /[+#]/.test(segment)))
120
+ throw new Error("MQTT prefix must contain non-empty segments without wildcards");
121
+ return normalized;
90
122
  }
91
123
 
92
124
  // backend/wago-controller.entity.ts
@@ -173,6 +205,7 @@ var WagoSettings = class {
173
205
  constructor() {
174
206
  __publicField(this, "id");
175
207
  __publicField(this, "defaultMqttServerId");
208
+ __publicField(this, "operationalPrefix");
176
209
  }
177
210
  };
178
211
  __decorateClass([
@@ -181,6 +214,9 @@ __decorateClass([
181
214
  __decorateClass([
182
215
  (0, import_plugins_backend_sdk2.Column)({ type: "integer", name: "default_mqtt_server_id", nullable: true })
183
216
  ], WagoSettings.prototype, "defaultMqttServerId", 2);
217
+ __decorateClass([
218
+ (0, import_plugins_backend_sdk2.Column)({ type: "varchar", name: "operational_prefix", default: "attraccess/wago" })
219
+ ], WagoSettings.prototype, "operationalPrefix", 2);
184
220
  WagoSettings = __decorateClass([
185
221
  (0, import_plugins_backend_sdk2.Entity)({ name: "plugin_wago_settings" })
186
222
  ], WagoSettings);
@@ -231,9 +267,365 @@ WagoEnrollment = __decorateClass([
231
267
  (0, import_plugins_backend_sdk3.Entity)({ name: "plugin_wago_enrollments" })
232
268
  ], WagoEnrollment);
233
269
 
270
+ // backend/configuration.ts
271
+ var import_node_crypto = require("node:crypto");
272
+ var HARDWARE_PROFILES = ["751-9301", "879-3000", "879-1300"];
273
+ var CHANNEL_PROFILES = [
274
+ "metered-switched-load",
275
+ "pulsed-lock-bank",
276
+ "guarded-enable-request",
277
+ "generic-digital-output",
278
+ "generic-monitored-input"
279
+ ];
280
+ var CAPABILITIES = ["output", "input", "measurement", "pulse", "guard"];
281
+ function canonicalSnapshot(snapshot) {
282
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot))
283
+ throw new Error("configuration snapshot must be an object");
284
+ return JSON.stringify(sortValue(snapshot));
285
+ }
286
+ function configurationHash(snapshot) {
287
+ return (0, import_node_crypto.createHash)("sha256").update(canonicalSnapshot(snapshot)).digest("hex");
288
+ }
289
+ function configurationDiff(previous, current) {
290
+ const changes = [];
291
+ diffValue("$", previous, current, changes);
292
+ return changes;
293
+ }
294
+ function parseConfigurationReport(value) {
295
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
296
+ const report = value;
297
+ if (!Number.isSafeInteger(report.revision) || report.revision < 1 || typeof report.contentHash !== "string" || !report.contentHash.trim() || report.errors !== void 0 && !Array.isArray(report.errors))
298
+ return null;
299
+ const errors = report.errors ?? [];
300
+ if (!errors.every(isConfigurationValidationError)) return null;
301
+ return { revision: report.revision, contentHash: report.contentHash, errors };
302
+ }
303
+ function validateSnapshot(snapshot) {
304
+ let value;
305
+ try {
306
+ value = JSON.parse(canonicalSnapshot(snapshot));
307
+ } catch (error) {
308
+ return [
309
+ { path: "$", code: "invalid_snapshot", message: error instanceof Error ? error.message : "invalid snapshot" }
310
+ ];
311
+ }
312
+ const errors = [];
313
+ exactKeys(value, "$", ["version", "physicalPoints", "logicalChannels"], errors);
314
+ if (value.version !== CONFIGURATION_PROTOCOL_VERSION)
315
+ errors.push({
316
+ path: "version",
317
+ code: "unsupported_version",
318
+ message: `version must be ${CONFIGURATION_PROTOCOL_VERSION}`
319
+ });
320
+ const points = collection(value.physicalPoints, "physicalPoints", errors);
321
+ const channels = collection(value.logicalChannels, "logicalChannels", errors);
322
+ const pointIds = /* @__PURE__ */ new Set();
323
+ const channelIds = /* @__PURE__ */ new Set();
324
+ points.forEach((point, index) => {
325
+ const path = `physicalPoints[${index}]`;
326
+ if (!record(point, path, errors)) return;
327
+ exactKeys(point, path, ["id", "hardwareProfile", "channel"], errors);
328
+ addId(point.id, `${path}.id`, pointIds, errors);
329
+ enumValue(point.hardwareProfile, `${path}.hardwareProfile`, HARDWARE_PROFILES, errors);
330
+ if (!Number.isSafeInteger(point.channel) || point.channel < 0)
331
+ errors.push({
332
+ path: `${path}.channel`,
333
+ code: "invalid_channel",
334
+ message: "channel must be a non-negative integer"
335
+ });
336
+ });
337
+ channels.forEach((channel, index) => {
338
+ if (channel && typeof channel === "object" && !Array.isArray(channel))
339
+ addId(channel.id, `logicalChannels[${index}].id`, channelIds, errors);
340
+ });
341
+ channels.forEach((channel, index) => {
342
+ const path = `logicalChannels[${index}]`;
343
+ if (!record(channel, path, errors)) return;
344
+ exactKeys(
345
+ channel,
346
+ path,
347
+ [
348
+ "id",
349
+ "physicalPointId",
350
+ "profile",
351
+ "capabilities",
352
+ "disconnectPolicy",
353
+ "range",
354
+ "pulse",
355
+ "guard",
356
+ "measurement"
357
+ ],
358
+ errors
359
+ );
360
+ if (typeof channel.physicalPointId !== "string" || !pointIds.has(channel.physicalPointId))
361
+ errors.push(referenceError(`${path}.physicalPointId`, "physical point", channel.physicalPointId));
362
+ enumValue(channel.profile, `${path}.profile`, CHANNEL_PROFILES, errors);
363
+ const capabilities = capabilityList(channel.capabilities, `${path}.capabilities`, errors);
364
+ validateDisconnectPolicy(channel.disconnectPolicy, `${path}.disconnectPolicy`, errors);
365
+ validateRange(channel.range, `${path}.range`, capabilities, errors);
366
+ validatePulse(channel.pulse, `${path}.pulse`, capabilities, errors);
367
+ validateGuard(channel.guard, `${path}.guard`, capabilities, channelIds, errors);
368
+ validateMeasurement(channel.measurement, `${path}.measurement`, capabilities, errors);
369
+ validateProfile(channel.profile, capabilities, path, errors);
370
+ });
371
+ return errors;
372
+ }
373
+ function diffValue(path, previous, current, changes) {
374
+ if (Object.is(previous, current)) return;
375
+ if (Array.isArray(previous) && Array.isArray(current)) {
376
+ const length = Math.max(previous.length, current.length);
377
+ for (let index = 0; index < length; index += 1)
378
+ diffValue(`${path}[${index}]`, previous[index], current[index], changes);
379
+ return;
380
+ }
381
+ if (isRecord(previous) && isRecord(current)) {
382
+ const keys = /* @__PURE__ */ new Set([...Object.keys(previous), ...Object.keys(current)]);
383
+ for (const key of [...keys].sort()) diffValue(`${path}.${key}`, previous[key], current[key], changes);
384
+ return;
385
+ }
386
+ changes.push({ path, previous, current });
387
+ }
388
+ function isConfigurationValidationError(value) {
389
+ return isRecord(value) && typeof value.path === "string" && Boolean(value.path.trim()) && typeof value.code === "string" && Boolean(value.code.trim()) && typeof value.message === "string" && Boolean(value.message.trim());
390
+ }
391
+ function isRecord(value) {
392
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
393
+ }
394
+ function collection(value, path, errors) {
395
+ if (!Array.isArray(value)) {
396
+ errors.push({ path, code: "invalid_collection", message: `${path} must be an array` });
397
+ return [];
398
+ }
399
+ return value;
400
+ }
401
+ function record(value, path, errors) {
402
+ if (value && typeof value === "object" && !Array.isArray(value)) return true;
403
+ errors.push({ path, code: "invalid_object", message: `${path} must be an object` });
404
+ return false;
405
+ }
406
+ function exactKeys(value, path, allowed, errors, optional = []) {
407
+ Object.keys(value).filter((key) => !allowed.includes(key)).forEach(
408
+ (key) => errors.push({
409
+ path: path === "$" ? key : `${path}.${key}`,
410
+ code: "unknown_field",
411
+ message: "field is not supported by configuration version 1"
412
+ })
413
+ );
414
+ allowed.filter((key) => !["range", "pulse", "guard", "measurement", ...optional].includes(key) && !(key in value)).forEach(
415
+ (key) => errors.push({
416
+ path: path === "$" ? key : `${path}.${key}`,
417
+ code: "required_field",
418
+ message: "field is required"
419
+ })
420
+ );
421
+ }
422
+ function addId(value, path, ids, errors) {
423
+ if (typeof value !== "string" || !value.trim()) {
424
+ errors.push({ path, code: "invalid_id", message: "id must be a non-empty string" });
425
+ return;
426
+ }
427
+ if (ids.has(value)) errors.push({ path, code: "duplicate_id", message: `duplicate id ${value}` });
428
+ ids.add(value);
429
+ }
430
+ function enumValue(value, path, options, errors) {
431
+ if (!options.includes(value))
432
+ errors.push({ path, code: "unsupported_value", message: `must be one of: ${options.join(", ")}` });
433
+ }
434
+ function capabilityList(value, path, errors) {
435
+ if (!Array.isArray(value) || !value.length) {
436
+ errors.push({ path, code: "invalid_capabilities", message: "capabilities must be a non-empty array" });
437
+ return /* @__PURE__ */ new Set();
438
+ }
439
+ const capabilities = /* @__PURE__ */ new Set();
440
+ value.forEach((item, index) => {
441
+ if (!CAPABILITIES.includes(item))
442
+ errors.push({
443
+ path: `${path}[${index}]`,
444
+ code: "unsupported_capability",
445
+ message: `must be one of: ${CAPABILITIES.join(", ")}`
446
+ });
447
+ else if (capabilities.has(item))
448
+ errors.push({ path: `${path}[${index}]`, code: "duplicate_capability", message: `duplicate capability ${item}` });
449
+ else capabilities.add(item);
450
+ });
451
+ return capabilities;
452
+ }
453
+ function validateDisconnectPolicy(value, path, errors) {
454
+ if (!record(value, path, errors)) return;
455
+ exactKeys(value, path, ["mode", "timeoutMs"], errors, ["timeoutMs"]);
456
+ if (!["hold", "immediate", "watchdog"].includes(value.mode))
457
+ errors.push({
458
+ path: `${path}.mode`,
459
+ code: "unsupported_value",
460
+ message: "mode must be hold, immediate, or watchdog"
461
+ });
462
+ if (value.mode === "watchdog" && (!Number.isSafeInteger(value.timeoutMs) || value.timeoutMs <= 0))
463
+ errors.push({
464
+ path: `${path}.timeoutMs`,
465
+ code: "required_field",
466
+ message: "watchdog timeoutMs must be a positive integer"
467
+ });
468
+ if (value.mode !== "watchdog" && value.timeoutMs !== void 0)
469
+ errors.push({
470
+ path: `${path}.timeoutMs`,
471
+ code: "unsupported_field",
472
+ message: "timeoutMs is only valid for watchdog policies"
473
+ });
474
+ }
475
+ function validateRange(value, path, capabilities, errors) {
476
+ if (value === void 0) return;
477
+ if (!record(value, path, errors)) return;
478
+ exactKeys(value, path, ["minimum", "maximum"], errors);
479
+ if (!Number.isFinite(value.minimum) || !Number.isFinite(value.maximum) || value.minimum >= value.maximum)
480
+ errors.push({
481
+ path,
482
+ code: "invalid_range",
483
+ message: "minimum and maximum must be finite numbers where minimum is less than maximum"
484
+ });
485
+ if (!capabilities.has("input") && !capabilities.has("measurement"))
486
+ errors.push({ path, code: "unsupported_field", message: "range requires input or measurement capability" });
487
+ }
488
+ function validatePulse(value, path, capabilities, errors) {
489
+ if (value === void 0) return;
490
+ if (!record(value, path, errors)) return;
491
+ exactKeys(value, path, ["durationMs"], errors);
492
+ if (!Number.isSafeInteger(value.durationMs) || value.durationMs <= 0)
493
+ errors.push({
494
+ path: `${path}.durationMs`,
495
+ code: "invalid_duration",
496
+ message: "durationMs must be a positive integer"
497
+ });
498
+ if (!capabilities.has("pulse"))
499
+ errors.push({ path, code: "unsupported_field", message: "pulse requires pulse capability" });
500
+ }
501
+ function validateGuard(value, path, capabilities, channelIds, errors) {
502
+ if (value === void 0) return;
503
+ if (!record(value, path, errors)) return;
504
+ exactKeys(value, path, ["channelId", "when"], errors);
505
+ if (typeof value.channelId !== "string" || !channelIds.has(value.channelId))
506
+ errors.push(referenceError(`${path}.channelId`, "logical channel", value.channelId));
507
+ if (!["on", "off"].includes(value.when))
508
+ errors.push({ path: `${path}.when`, code: "unsupported_value", message: "when must be on or off" });
509
+ if (!capabilities.has("guard"))
510
+ errors.push({ path, code: "unsupported_field", message: "guard requires guard capability" });
511
+ }
512
+ function validateMeasurement(value, path, capabilities, errors) {
513
+ if (value === void 0) return;
514
+ if (!record(value, path, errors)) return;
515
+ exactKeys(value, path, ["unit", "scale", "offset"], errors);
516
+ enumValue(value.unit, `${path}.unit`, ["ampere", "volt", "watt", "percent"], errors);
517
+ if (!Number.isFinite(value.scale) || !Number.isFinite(value.offset))
518
+ errors.push({ path, code: "invalid_measurement", message: "scale and offset must be finite numbers" });
519
+ if (!capabilities.has("measurement"))
520
+ errors.push({ path, code: "unsupported_field", message: "measurement requires measurement capability" });
521
+ }
522
+ function validateProfile(value, capabilities, path, errors) {
523
+ const required = {
524
+ "metered-switched-load": ["output", "measurement"],
525
+ "pulsed-lock-bank": ["output", "pulse"],
526
+ "guarded-enable-request": ["output", "guard"],
527
+ "generic-digital-output": ["output"],
528
+ "generic-monitored-input": ["input"]
529
+ };
530
+ required[value]?.filter((capability) => !capabilities.has(capability)).forEach(
531
+ (capability) => errors.push({
532
+ path: `${path}.capabilities`,
533
+ code: "missing_capability",
534
+ message: `${value} requires ${capability} capability`
535
+ })
536
+ );
537
+ }
538
+ function referenceError(path, type, id) {
539
+ return { path, code: "missing_reference", message: `${type} ${String(id)} does not exist in this snapshot` };
540
+ }
541
+ function sortValue(value) {
542
+ if (Array.isArray(value)) return value.map(sortValue);
543
+ if (!value || typeof value !== "object") return value;
544
+ return Object.fromEntries(
545
+ Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, sortValue(item)])
546
+ );
547
+ }
548
+
549
+ // backend/wago-configuration-draft.entity.ts
550
+ var import_plugins_backend_sdk4 = require("@attraccess/plugins-backend-sdk");
551
+ var WagoConfigurationDraft = class {
552
+ constructor() {
553
+ __publicField(this, "id");
554
+ __publicField(this, "controllerId");
555
+ __publicField(this, "snapshot");
556
+ __publicField(this, "reviewedHash");
557
+ __publicField(this, "updatedAt");
558
+ }
559
+ };
560
+ __decorateClass([
561
+ (0, import_plugins_backend_sdk4.PrimaryGeneratedColumn)({ type: "integer" })
562
+ ], WagoConfigurationDraft.prototype, "id", 2);
563
+ __decorateClass([
564
+ (0, import_plugins_backend_sdk4.Column)({ type: "integer", name: "controller_id", unique: true })
565
+ ], WagoConfigurationDraft.prototype, "controllerId", 2);
566
+ __decorateClass([
567
+ (0, import_plugins_backend_sdk4.Column)({ type: "text" })
568
+ ], WagoConfigurationDraft.prototype, "snapshot", 2);
569
+ __decorateClass([
570
+ (0, import_plugins_backend_sdk4.Column)({ type: "varchar", name: "reviewed_hash", nullable: true })
571
+ ], WagoConfigurationDraft.prototype, "reviewedHash", 2);
572
+ __decorateClass([
573
+ (0, import_plugins_backend_sdk4.Column)({ type: "varchar", name: "updated_at" })
574
+ ], WagoConfigurationDraft.prototype, "updatedAt", 2);
575
+ WagoConfigurationDraft = __decorateClass([
576
+ (0, import_plugins_backend_sdk4.Entity)({ name: "plugin_wago_configuration_drafts" })
577
+ ], WagoConfigurationDraft);
578
+
579
+ // backend/wago-configuration-revision.entity.ts
580
+ var import_plugins_backend_sdk5 = require("@attraccess/plugins-backend-sdk");
581
+ var WagoConfigurationRevision = class {
582
+ constructor() {
583
+ __publicField(this, "id");
584
+ __publicField(this, "controllerId");
585
+ __publicField(this, "revision");
586
+ __publicField(this, "snapshot");
587
+ __publicField(this, "contentHash");
588
+ __publicField(this, "state");
589
+ __publicField(this, "rejectionErrors");
590
+ __publicField(this, "publishedAt");
591
+ __publicField(this, "reportedAt");
592
+ }
593
+ };
594
+ __decorateClass([
595
+ (0, import_plugins_backend_sdk5.PrimaryGeneratedColumn)({ type: "integer" })
596
+ ], WagoConfigurationRevision.prototype, "id", 2);
597
+ __decorateClass([
598
+ (0, import_plugins_backend_sdk5.Column)({ type: "integer", name: "controller_id" })
599
+ ], WagoConfigurationRevision.prototype, "controllerId", 2);
600
+ __decorateClass([
601
+ (0, import_plugins_backend_sdk5.Column)({ type: "integer" })
602
+ ], WagoConfigurationRevision.prototype, "revision", 2);
603
+ __decorateClass([
604
+ (0, import_plugins_backend_sdk5.Column)({ type: "text" })
605
+ ], WagoConfigurationRevision.prototype, "snapshot", 2);
606
+ __decorateClass([
607
+ (0, import_plugins_backend_sdk5.Column)({ type: "varchar", name: "content_hash" })
608
+ ], WagoConfigurationRevision.prototype, "contentHash", 2);
609
+ __decorateClass([
610
+ (0, import_plugins_backend_sdk5.Column)({ type: "varchar" })
611
+ ], WagoConfigurationRevision.prototype, "state", 2);
612
+ __decorateClass([
613
+ (0, import_plugins_backend_sdk5.Column)({ type: "text", name: "rejection_errors", nullable: true })
614
+ ], WagoConfigurationRevision.prototype, "rejectionErrors", 2);
615
+ __decorateClass([
616
+ (0, import_plugins_backend_sdk5.Column)({ type: "varchar", name: "published_at" })
617
+ ], WagoConfigurationRevision.prototype, "publishedAt", 2);
618
+ __decorateClass([
619
+ (0, import_plugins_backend_sdk5.Column)({ type: "varchar", name: "reported_at", nullable: true })
620
+ ], WagoConfigurationRevision.prototype, "reportedAt", 2);
621
+ WagoConfigurationRevision = __decorateClass([
622
+ (0, import_plugins_backend_sdk5.Entity)({ name: "plugin_wago_configuration_revisions" })
623
+ ], WagoConfigurationRevision);
624
+
234
625
  // backend/wago.service.ts
235
626
  var PLUGIN_CONTEXT = /* @__PURE__ */ Symbol.for("attraccess.plugin.context");
236
627
  var STALE_AFTER_MS = 9e4;
628
+ var MAX_PENDING_CONFIGURATION_REPORTS = 100;
237
629
  var ENROLLMENT_RETRY_MS = 6e4;
238
630
  var WagoService = class {
239
631
  constructor(context) {
@@ -241,9 +633,14 @@ var WagoService = class {
241
633
  __publicField(this, "controllers");
242
634
  __publicField(this, "settings");
243
635
  __publicField(this, "enrollments");
636
+ __publicField(this, "drafts");
637
+ __publicField(this, "revisions");
244
638
  __publicField(this, "subscriptions", []);
245
639
  __publicField(this, "enrollmentExpiryTimers", /* @__PURE__ */ new Map());
246
640
  __publicField(this, "claimLocks", /* @__PURE__ */ new Map());
641
+ __publicField(this, "configurationLocks", /* @__PURE__ */ new Map());
642
+ __publicField(this, "configurationReportQueues", /* @__PURE__ */ new Map());
643
+ __publicField(this, "claimConfigurationLock", Promise.resolve());
247
644
  __publicField(this, "subscriptionRebuild", Promise.resolve());
248
645
  __publicField(this, "subscriptionRetryTimer", null);
249
646
  __publicField(this, "activeSubscriptionGeneration", 0);
@@ -251,6 +648,8 @@ var WagoService = class {
251
648
  this.controllers = context.getRepository(WagoController);
252
649
  this.settings = context.getRepository(WagoSettings);
253
650
  this.enrollments = context.getRepository(WagoEnrollment);
651
+ this.drafts = context.getRepository(WagoConfigurationDraft);
652
+ this.revisions = context.getRepository(WagoConfigurationRevision);
254
653
  }
255
654
  async onModuleInit() {
256
655
  const enrollments = await this.enrollments.createQueryBuilder("enrollment").where("enrollment.consumedAt IS NULL").getMany();
@@ -288,18 +687,144 @@ var WagoService = class {
288
687
  async getSettings() {
289
688
  const settings = await this.settings.findOneBy({ id: 1 });
290
689
  if (settings) return settings;
291
- await this.settings.createQueryBuilder().insert().values({ id: 1, defaultMqttServerId: null }).orIgnore().execute();
690
+ await this.settings.createQueryBuilder().insert().values({ id: 1, defaultMqttServerId: null, operationalPrefix: "attraccess/wago" }).orIgnore().execute();
292
691
  return this.settings.findOneByOrFail({ id: 1 });
293
692
  }
294
- async setDefaultMqttServer(serverId) {
295
- if (serverId !== null && !await this.context.getMqttServerConfig(serverId))
693
+ async setSettings(serverId, operationalPrefix) {
694
+ if (serverId !== void 0 && serverId !== null && !await this.context.getMqttServerConfig(serverId))
296
695
  throw new import_common.NotFoundException(`MQTT server ${serverId} not found`);
297
- const settings = await this.getSettings();
298
- settings.defaultMqttServerId = serverId;
299
- await this.settings.save(settings);
696
+ const save = async () => {
697
+ const settings2 = await this.getSettings();
698
+ if (serverId !== void 0) settings2.defaultMqttServerId = serverId;
699
+ if (operationalPrefix !== void 0) {
700
+ const normalizedPrefix = normalizeOperationalPrefix(operationalPrefix);
701
+ if (normalizedPrefix !== settings2.operationalPrefix) {
702
+ const controllers = await this.controllers.find({ where: { trustState: "claimed" } });
703
+ if (controllers.length)
704
+ throw new import_common.ConflictException("operational MQTT prefix cannot change after a controller has been claimed");
705
+ settings2.operationalPrefix = normalizedPrefix;
706
+ }
707
+ }
708
+ await this.settings.save(settings2);
709
+ return settings2;
710
+ };
711
+ const settings = operationalPrefix === void 0 ? await save() : await this.withClaimConfigurationLock(save);
300
712
  await this.subscribeConfiguredServers();
301
713
  return settings;
302
714
  }
715
+ async setDefaultMqttServer(serverId) {
716
+ return this.setSettings(serverId);
717
+ }
718
+ async getDraft(controllerId) {
719
+ await this.claimedController(controllerId);
720
+ return this.drafts.findOneBy({ controllerId });
721
+ }
722
+ async saveDraft(controllerId, snapshot) {
723
+ return this.withConfigurationLock(controllerId, () => this.saveDraftWhileLocked(controllerId, snapshot));
724
+ }
725
+ async validateDraft(controllerId) {
726
+ const draft = await this.getDraft(controllerId);
727
+ if (!draft) throw new import_common.NotFoundException(`WAGO controller ${controllerId} has no configuration draft`);
728
+ const errors = validateSnapshot(JSON.parse(draft.snapshot));
729
+ return { valid: errors.length === 0, errors };
730
+ }
731
+ async revisionsFor(controllerId, offset = 0, limit = 20) {
732
+ await this.claimedController(controllerId);
733
+ const pageOffset = Number.isSafeInteger(offset) && offset > 0 ? offset : 0;
734
+ const pageLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, 100) : 20;
735
+ const revisions = await this.revisions.find({
736
+ where: { controllerId },
737
+ order: { revision: "DESC" },
738
+ select: [
739
+ "id",
740
+ "controllerId",
741
+ "revision",
742
+ "contentHash",
743
+ "state",
744
+ "rejectionErrors",
745
+ "publishedAt",
746
+ "reportedAt"
747
+ ],
748
+ skip: pageOffset,
749
+ take: pageLimit
750
+ });
751
+ return { revisions, offset: pageOffset, limit: pageLimit };
752
+ }
753
+ async reviewDraft(controllerId) {
754
+ return this.withConfigurationLock(controllerId, () => this.reviewDraftWhileLocked(controllerId));
755
+ }
756
+ async publishDraft(controllerId) {
757
+ return this.withConfigurationLock(controllerId, () => this.publishDraftWhileLocked(controllerId));
758
+ }
759
+ async rollback(controllerId, revision) {
760
+ return this.withConfigurationLock(controllerId, async () => {
761
+ const source = await this.revisions.findOneBy({ controllerId, revision });
762
+ if (!source) throw new import_common.NotFoundException(`WAGO configuration revision ${revision} not found`);
763
+ await this.saveDraftWhileLocked(controllerId, JSON.parse(source.snapshot));
764
+ await this.reviewDraftWhileLocked(controllerId);
765
+ return this.publishDraftWhileLocked(controllerId);
766
+ });
767
+ }
768
+ async previewRevision(controllerId, revision) {
769
+ await this.claimedController(controllerId);
770
+ const selected = await this.revisions.findOneBy({ controllerId, revision });
771
+ if (!selected) throw new import_common.NotFoundException(`WAGO configuration revision ${revision} not found`);
772
+ const [current] = await this.revisions.find({ where: { controllerId }, order: { revision: "DESC" }, take: 1 });
773
+ return {
774
+ revision: selected,
775
+ current: current ?? null,
776
+ diff: configurationDiff(current ? JSON.parse(current.snapshot) : null, JSON.parse(selected.snapshot))
777
+ };
778
+ }
779
+ async saveDraftWhileLocked(controllerId, snapshot) {
780
+ await this.claimedController(controllerId);
781
+ const serialized = canonicalSnapshot(snapshot);
782
+ const existing = await this.drafts.findOneBy({ controllerId });
783
+ const draft = existing ?? this.drafts.create({ controllerId, snapshot: serialized, reviewedHash: null, updatedAt: "" });
784
+ draft.snapshot = serialized;
785
+ draft.reviewedHash = null;
786
+ draft.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
787
+ return this.drafts.save(draft);
788
+ }
789
+ async reviewDraftWhileLocked(controllerId) {
790
+ await this.claimedController(controllerId);
791
+ const draft = await this.drafts.findOneBy({ controllerId });
792
+ if (!draft) throw new import_common.NotFoundException(`WAGO controller ${controllerId} has no configuration draft`);
793
+ const previous = await this.latestRevision(controllerId);
794
+ draft.reviewedHash = configurationHash(JSON.parse(draft.snapshot));
795
+ await this.drafts.save(draft);
796
+ const diff = configurationDiff(previous ? JSON.parse(previous.snapshot) : null, JSON.parse(draft.snapshot));
797
+ return { draft, previous, changed: diff.length > 0, diff };
798
+ }
799
+ async publishDraftWhileLocked(controllerId) {
800
+ const controller = await this.claimedController(controllerId);
801
+ const draft = await this.drafts.findOneBy({ controllerId });
802
+ if (!draft) throw new import_common.NotFoundException(`WAGO controller ${controllerId} has no configuration draft`);
803
+ const validation = validateSnapshot(JSON.parse(draft.snapshot));
804
+ if (validation.length)
805
+ throw new import_common.ConflictException({ message: "configuration draft is invalid", errors: validation });
806
+ const contentHash = configurationHash(JSON.parse(draft.snapshot));
807
+ if (draft.reviewedHash !== contentHash)
808
+ throw new import_common.ConflictException("review the current configuration draft before publishing it");
809
+ const previous = await this.latestRevision(controllerId);
810
+ if (previous?.state === "pending" && previous.contentHash === contentHash)
811
+ return this.publishRevision(controller, previous);
812
+ const revision = this.revisions.create({
813
+ controllerId,
814
+ revision: (previous?.revision ?? 0) + 1,
815
+ snapshot: draft.snapshot,
816
+ contentHash,
817
+ state: "pending",
818
+ rejectionErrors: null,
819
+ publishedAt: (/* @__PURE__ */ new Date()).toISOString(),
820
+ reportedAt: null
821
+ });
822
+ return this.publishRevision(controller, await this.revisions.save(revision));
823
+ }
824
+ async latestRevision(controllerId) {
825
+ const [revision] = await this.revisions.find({ where: { controllerId }, order: { revision: "DESC" }, take: 1 });
826
+ return revision ?? null;
827
+ }
303
828
  async createEnrollment(hardwareId, mqttServerId, manualCredentials) {
304
829
  const normalizedHardwareId = hardwareId.trim();
305
830
  if (!isValidHardwareId(normalizedHardwareId))
@@ -308,8 +833,8 @@ var WagoService = class {
308
833
  if (!selectedServerId) throw new import_common.ConflictException("select an MQTT server before creating an enrollment package");
309
834
  const server = await this.context.getMqttServerConfig(selectedServerId);
310
835
  if (!server) throw new import_common.NotFoundException(`MQTT server ${selectedServerId} not found`);
311
- const claimSecret = (0, import_node_crypto.randomBytes)(24).toString("base64url");
312
- const identity = `wago-enrollment-${(0, import_node_crypto.randomBytes)(8).toString("hex")}`;
836
+ const claimSecret = (0, import_node_crypto2.randomBytes)(24).toString("base64url");
837
+ const identity = `wago-enrollment-${(0, import_node_crypto2.randomBytes)(8).toString("hex")}`;
313
838
  const expiresAt = new Date(Date.now() + 15 * 6e4).toISOString();
314
839
  const provisionedCredential = await this.context.getMqttCredentialProvisioning().provision({
315
840
  mqttServerId: selectedServerId,
@@ -355,9 +880,37 @@ var WagoService = class {
355
880
  };
356
881
  }
357
882
  async claim(id, name, verifier, mqttServerId) {
358
- return this.withClaimLock(id, () => this.claimController(id, name, verifier, mqttServerId));
883
+ return this.withClaimLock(id, async () => {
884
+ const prepared = await this.withClaimConfigurationLock(() => this.prepareClaim(id, name, verifier, mqttServerId));
885
+ try {
886
+ await this.context.mqtt.publish(
887
+ prepared.mqttServerId,
888
+ `${discoveryTopic(prepared.controller.hardwareId)}/claim`,
889
+ JSON.stringify({
890
+ username: prepared.credential.username,
891
+ password: prepared.credential.password,
892
+ configuration: prepared.configuration
893
+ }),
894
+ { qos: 1 }
895
+ );
896
+ prepared.credentialDelivered = true;
897
+ await this.context.mqtt.publish(prepared.mqttServerId, discoveryTopic(prepared.controller.hardwareId), "", {
898
+ qos: 1,
899
+ retain: true
900
+ });
901
+ return prepared.controller;
902
+ } catch (error) {
903
+ if (!prepared.credentialDelivered) await this.restoreUnclaimedController(prepared);
904
+ throw error;
905
+ } finally {
906
+ await this.subscribeConfiguredServers().catch((error) => {
907
+ this.context.logger.warn(`Could not refresh WAGO MQTT subscriptions after claim: ${String(error)}`);
908
+ this.scheduleSubscriptionRetry();
909
+ });
910
+ }
911
+ });
359
912
  }
360
- async claimController(id, name, verifier, mqttServerId) {
913
+ async prepareClaim(id, name, verifier, mqttServerId) {
361
914
  const controller = await this.controllers.findOneBy({ id });
362
915
  if (!controller) throw new import_common.NotFoundException(`WAGO controller ${id} not found`);
363
916
  if (controller.trustState === "claimed") throw new import_common.ConflictException("controller has already been claimed");
@@ -377,20 +930,26 @@ var WagoService = class {
377
930
  "the controller enrollment package has expired or was already consumed; create a new one"
378
931
  );
379
932
  const identity = `wago-controller-${controller.hardwareId}`;
933
+ const settings = await this.getSettings();
934
+ const namespace = normalizeOperationalPrefix(settings.operationalPrefix);
380
935
  const credential = await this.context.getMqttCredentialProvisioning().provision({
381
936
  mqttServerId: selectedServerId,
382
937
  identity,
383
938
  username: identity,
384
939
  vhost: "/",
385
940
  topicPolicy: {
386
- publish: [`attraccess/wago/controllers/${controller.hardwareId}/#`],
387
- subscribe: [`attraccess/wago/controllers/${controller.hardwareId}/desired/#`]
941
+ publish: [
942
+ `${namespace}/v${CONFIGURATION_PROTOCOL_VERSION}/controllers/${controller.hardwareId}/#`
943
+ ],
944
+ subscribe: [
945
+ configurationDesiredTopic(namespace, controller.hardwareId),
946
+ commandTopic(namespace, controller.hardwareId)
947
+ ]
388
948
  }
389
949
  });
390
950
  if (!("password" in credential)) {
391
951
  throw new import_common.ConflictException(`Manual credential provisioning is required: ${credential.instructions.join(" ")}`);
392
952
  }
393
- let credentialDelivered = false;
394
953
  const previousController = {
395
954
  trustState: controller.trustState,
396
955
  name: controller.name,
@@ -404,36 +963,59 @@ var WagoService = class {
404
963
  controller.mqttServerId = selectedServerId;
405
964
  controller.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
406
965
  await this.controllers.save(controller);
407
- await this.context.mqtt.publish(
408
- selectedServerId,
409
- `${discoveryTopic(controller.hardwareId)}/claim`,
410
- JSON.stringify({ username: credential.username, password: credential.password }),
411
- { qos: 1 }
412
- );
413
- credentialDelivered = true;
414
- await this.context.mqtt.publish(selectedServerId, discoveryTopic(controller.hardwareId), "", {
415
- qos: 1,
416
- retain: true
417
- });
418
- return controller;
966
+ return {
967
+ controller,
968
+ mqttServerId: selectedServerId,
969
+ credential,
970
+ configuration: {
971
+ protocolVersion: CONFIGURATION_PROTOCOL_VERSION,
972
+ namespace,
973
+ desiredTopic: configurationDesiredTopic(namespace, controller.hardwareId),
974
+ reportedTopic: configurationReportedTopic(namespace, controller.hardwareId)
975
+ },
976
+ identity,
977
+ previousController,
978
+ credentialDelivered: false
979
+ };
419
980
  } catch (error) {
420
- if (!credentialDelivered) {
421
- await this.context.getMqttCredentialProvisioning().revoke({ mqttServerId: selectedServerId, identity, username: identity, vhost: "/" }).catch(() => void 0);
422
- Object.assign(controller, previousController);
423
- await this.controllers.save(controller).catch((rollbackError) => {
424
- this.context.logger.warn(
425
- `Could not restore WAGO controller ${controller.id} after claim failure: ${String(rollbackError)}`
426
- );
427
- });
428
- }
429
- throw error;
430
- } finally {
431
- await this.subscribeConfiguredServers().catch((error) => {
432
- this.context.logger.warn(`Could not refresh WAGO MQTT subscriptions after claim: ${String(error)}`);
433
- this.scheduleSubscriptionRetry();
981
+ await this.restoreUnclaimedControllerWhileLocked({
982
+ controller,
983
+ mqttServerId: selectedServerId,
984
+ identity,
985
+ previousController
434
986
  });
987
+ throw error;
435
988
  }
436
989
  }
990
+ async restoreUnclaimedController({
991
+ controller,
992
+ mqttServerId,
993
+ identity,
994
+ previousController
995
+ }) {
996
+ await this.withClaimConfigurationLock(
997
+ () => this.restoreUnclaimedControllerWhileLocked({
998
+ controller,
999
+ mqttServerId,
1000
+ identity,
1001
+ previousController
1002
+ })
1003
+ );
1004
+ }
1005
+ async restoreUnclaimedControllerWhileLocked({
1006
+ controller,
1007
+ mqttServerId,
1008
+ identity,
1009
+ previousController
1010
+ }) {
1011
+ await this.context.getMqttCredentialProvisioning().revoke({ mqttServerId, identity, username: identity, vhost: "/" }).catch(() => void 0);
1012
+ Object.assign(controller, previousController);
1013
+ await this.controllers.save(controller).catch((rollbackError) => {
1014
+ this.context.logger.warn(
1015
+ `Could not restore WAGO controller ${controller.id} after claim failure: ${String(rollbackError)}`
1016
+ );
1017
+ });
1018
+ }
437
1019
  async subscribeConfiguredServers() {
438
1020
  if (this.destroyed) return;
439
1021
  const rebuild = this.subscriptionRebuild.then(() => this.rebuildSubscriptions());
@@ -463,15 +1045,47 @@ var WagoService = class {
463
1045
  await this.onDiscovery(serverId, message.topic, message.payload);
464
1046
  })
465
1047
  );
466
- for (const controller of controllers.filter(
1048
+ if (this.destroyed) {
1049
+ replacements.forEach((subscription) => subscription.unsubscribe());
1050
+ return;
1051
+ }
1052
+ const claimedControllers = controllers.filter(
467
1053
  (item) => item.trustState === "claimed" && (item.mqttServerId ?? settings.defaultMqttServerId) === serverId
468
- )) {
469
- replacements.push(
470
- await this.context.mqtt.subscribe(serverId, heartbeatTopic(controller.hardwareId), async (message) => {
1054
+ );
1055
+ const controllersByHardwareId = new Map(
1056
+ claimedControllers.map((controller) => [controller.hardwareId, controller])
1057
+ );
1058
+ replacements.push(
1059
+ await this.context.mqtt.subscribe(
1060
+ serverId,
1061
+ configurationReportedWildcardTopic(settings.operationalPrefix),
1062
+ (message) => {
471
1063
  if (!this.isActiveSubscriptionGeneration(generation)) return;
472
- await this.onHeartbeat(controller.hardwareId, message.payload);
473
- })
1064
+ const hardwareId = configurationReportedHardwareId(settings.operationalPrefix, message.topic);
1065
+ const controller = hardwareId ? controllersByHardwareId.get(hardwareId) : void 0;
1066
+ if (controller) this.enqueueConfigurationReport(controller.id, message.payload);
1067
+ }
1068
+ )
1069
+ );
1070
+ if (this.destroyed) {
1071
+ replacements.forEach((subscription) => subscription.unsubscribe());
1072
+ return;
1073
+ }
1074
+ for (const controller of claimedControllers) {
1075
+ replacements.push(
1076
+ await this.context.mqtt.subscribe(
1077
+ serverId,
1078
+ heartbeatTopic(settings.operationalPrefix, controller.hardwareId),
1079
+ async (message) => {
1080
+ if (!this.isActiveSubscriptionGeneration(generation)) return;
1081
+ await this.onHeartbeat(controller.hardwareId, message.payload);
1082
+ }
1083
+ )
474
1084
  );
1085
+ if (this.destroyed) {
1086
+ replacements.forEach((subscription) => subscription.unsubscribe());
1087
+ return;
1088
+ }
475
1089
  }
476
1090
  }
477
1091
  } catch (error) {
@@ -572,6 +1186,91 @@ var WagoService = class {
572
1186
  controller.updatedAt = now;
573
1187
  await this.controllers.save(controller);
574
1188
  }
1189
+ async onConfigurationReported(controllerId, payload) {
1190
+ let report;
1191
+ try {
1192
+ report = parseConfigurationReport(JSON.parse(payload.toString("utf8")));
1193
+ } catch {
1194
+ this.context.logger.warn(`Ignoring invalid WAGO configuration report for controller ${controllerId}`);
1195
+ return;
1196
+ }
1197
+ if (!report) {
1198
+ this.context.logger.warn(`Ignoring malformed WAGO configuration report for controller ${controllerId}`);
1199
+ return;
1200
+ }
1201
+ await this.withConfigurationLock(controllerId, async () => {
1202
+ const revision = await this.revisions.findOneBy({ controllerId, revision: report.revision });
1203
+ if (!revision || revision.contentHash !== report.contentHash) return;
1204
+ if (revision.state !== "published") return;
1205
+ revision.state = report.errors.length ? "rejected" : "applied";
1206
+ revision.rejectionErrors = report.errors.length ? JSON.stringify(report.errors) : null;
1207
+ revision.reportedAt = (/* @__PURE__ */ new Date()).toISOString();
1208
+ await this.revisions.save(revision);
1209
+ });
1210
+ }
1211
+ enqueueConfigurationReport(controllerId, payload) {
1212
+ const queue = this.configurationReportQueues.get(controllerId) ?? { pending: /* @__PURE__ */ new Map(), processing: false };
1213
+ this.configurationReportQueues.set(controllerId, queue);
1214
+ if (queue.processing) {
1215
+ const revision = this.configurationReportRevision(payload);
1216
+ const key = revision ?? Number.NaN;
1217
+ if (queue.pending.has(key) || queue.pending.size < MAX_PENDING_CONFIGURATION_REPORTS)
1218
+ queue.pending.set(key, payload);
1219
+ else this.context.logger.warn(`Dropping excess WAGO configuration report for controller ${controllerId}`);
1220
+ return;
1221
+ }
1222
+ queue.processing = true;
1223
+ void this.processConfigurationReports(controllerId, payload, queue);
1224
+ }
1225
+ async processConfigurationReports(controllerId, payload, queue) {
1226
+ let next = payload;
1227
+ while (next) {
1228
+ try {
1229
+ await this.onConfigurationReported(controllerId, next);
1230
+ } catch (error) {
1231
+ this.context.logger.warn(`Could not process WAGO configuration report: ${String(error)}`);
1232
+ }
1233
+ const pending = queue.pending.entries().next();
1234
+ if (pending.done) next = null;
1235
+ else {
1236
+ const [revision, report] = pending.value;
1237
+ queue.pending.delete(revision);
1238
+ next = report;
1239
+ }
1240
+ }
1241
+ queue.processing = false;
1242
+ if (this.configurationReportQueues.get(controllerId) === queue) this.configurationReportQueues.delete(controllerId);
1243
+ }
1244
+ configurationReportRevision(payload) {
1245
+ try {
1246
+ const report = JSON.parse(payload.toString("utf8"));
1247
+ return Number.isSafeInteger(report.revision) && report.revision >= 1 ? report.revision : null;
1248
+ } catch {
1249
+ return null;
1250
+ }
1251
+ }
1252
+ async publishRevision(controller, revision) {
1253
+ if (!controller.mqttServerId) throw new import_common.ConflictException(`WAGO controller ${controller.id} has no MQTT server`);
1254
+ const incompatibility = compatibilityError({
1255
+ protocolVersion: controller.protocolVersion,
1256
+ capabilities: JSON.parse(controller.capabilities)
1257
+ });
1258
+ if (incompatibility) throw new import_common.ConflictException(`Cannot publish configuration: ${incompatibility}`);
1259
+ const settings = await this.getSettings();
1260
+ await this.context.mqtt.publish(
1261
+ controller.mqttServerId,
1262
+ configurationDesiredTopic(settings.operationalPrefix ?? "attraccess/wago", controller.hardwareId),
1263
+ JSON.stringify({
1264
+ protocolVersion: CONFIGURATION_PROTOCOL_VERSION,
1265
+ revision: revision.revision,
1266
+ contentHash: revision.contentHash,
1267
+ snapshot: JSON.parse(revision.snapshot)
1268
+ }),
1269
+ { qos: 1, retain: true }
1270
+ );
1271
+ revision.state = "published";
1272
+ return this.revisions.save(revision);
1273
+ }
575
1274
  connectivity(controller) {
576
1275
  if (controller.trustState === "untrusted") return "untrusted";
577
1276
  return controller.lastHeartbeatAt && Date.now() - Date.parse(controller.lastHeartbeatAt) <= STALE_AFTER_MS ? "online" : "stale";
@@ -653,6 +1352,43 @@ var WagoService = class {
653
1352
  if (this.claimLocks.get(id) === lock) this.claimLocks.delete(id);
654
1353
  }
655
1354
  }
1355
+ async withClaimConfigurationLock(operation) {
1356
+ const previous = this.claimConfigurationLock;
1357
+ let release;
1358
+ const lock = new Promise((resolve) => {
1359
+ release = resolve;
1360
+ });
1361
+ this.claimConfigurationLock = lock;
1362
+ await previous;
1363
+ try {
1364
+ return await operation();
1365
+ } finally {
1366
+ release();
1367
+ if (this.claimConfigurationLock === lock) this.claimConfigurationLock = Promise.resolve();
1368
+ }
1369
+ }
1370
+ async claimedController(id) {
1371
+ const controller = await this.controllers.findOneBy({ id });
1372
+ if (!controller || controller.trustState !== "claimed")
1373
+ throw new import_common.NotFoundException(`claimed WAGO controller ${id} not found`);
1374
+ if (!controller.mqttServerId) throw new import_common.ConflictException(`WAGO controller ${id} has no MQTT server`);
1375
+ return controller;
1376
+ }
1377
+ async withConfigurationLock(id, operation) {
1378
+ const previous = this.configurationLocks.get(id) ?? Promise.resolve();
1379
+ let release;
1380
+ const lock = new Promise((resolve) => {
1381
+ release = resolve;
1382
+ });
1383
+ this.configurationLocks.set(id, lock);
1384
+ await previous;
1385
+ try {
1386
+ return await operation();
1387
+ } finally {
1388
+ release();
1389
+ if (this.configurationLocks.get(id) === lock) this.configurationLocks.delete(id);
1390
+ }
1391
+ }
656
1392
  scheduleSubscriptionRetry() {
657
1393
  if (this.destroyed || this.subscriptionRetryTimer) return;
658
1394
  this.subscriptionRetryTimer = setTimeout(() => {
@@ -670,10 +1406,10 @@ WagoService = __decorateClass([
670
1406
  __decorateParam(0, (0, import_common.Inject)(PLUGIN_CONTEXT))
671
1407
  ], WagoService);
672
1408
  function hash(value) {
673
- return (0, import_node_crypto.createHash)("sha256").update(value).digest("hex");
1409
+ return (0, import_node_crypto2.createHash)("sha256").update(value).digest("hex");
674
1410
  }
675
1411
  function safeEqual(left, right) {
676
- return left.length === right.length && (0, import_node_crypto.timingSafeEqual)(Buffer.from(left), Buffer.from(right));
1412
+ return left.length === right.length && (0, import_node_crypto2.timingSafeEqual)(Buffer.from(left), Buffer.from(right));
677
1413
  }
678
1414
  function isValidHardwareId(hardwareId) {
679
1415
  return Boolean(hardwareId) && !/[/+#]/.test(hardwareId);
@@ -691,7 +1427,7 @@ var WagoControllerApi = class {
691
1427
  return this.wago.getSettings();
692
1428
  }
693
1429
  setSettings(body) {
694
- return this.wago.setDefaultMqttServer(body?.defaultMqttServerId ?? null);
1430
+ return this.wago.setSettings(body?.defaultMqttServerId, body?.operationalPrefix);
695
1431
  }
696
1432
  enrollment(body) {
697
1433
  const manualCredentials = body?.manualUsername || body?.manualPassword ? { username: body.manualUsername ?? "", password: body.manualPassword ?? "" } : void 0;
@@ -700,6 +1436,30 @@ var WagoControllerApi = class {
700
1436
  claim(id, body) {
701
1437
  return this.wago.claim(id, body?.name ?? "", body?.verifier ?? "", body?.mqttServerId);
702
1438
  }
1439
+ draft(id) {
1440
+ return this.wago.getDraft(id);
1441
+ }
1442
+ saveDraft(id, body) {
1443
+ return this.wago.saveDraft(id, body?.snapshot);
1444
+ }
1445
+ validateDraft(id) {
1446
+ return this.wago.validateDraft(id);
1447
+ }
1448
+ reviewDraft(id) {
1449
+ return this.wago.reviewDraft(id);
1450
+ }
1451
+ revisions(id, offset, limit) {
1452
+ return this.wago.revisionsFor(id, Number(offset), Number(limit));
1453
+ }
1454
+ publishDraft(id) {
1455
+ return this.wago.publishDraft(id);
1456
+ }
1457
+ rollback(id, revision) {
1458
+ return this.wago.rollback(id, revision);
1459
+ }
1460
+ previewRevision(id, revision) {
1461
+ return this.wago.previewRevision(id, revision);
1462
+ }
703
1463
  };
704
1464
  __decorateClass([
705
1465
  (0, import_common2.Get)("controllers")
@@ -720,8 +1480,45 @@ __decorateClass([
720
1480
  __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe)),
721
1481
  __decorateParam(1, (0, import_common2.Body)())
722
1482
  ], WagoControllerApi.prototype, "claim", 1);
1483
+ __decorateClass([
1484
+ (0, import_common2.Get)("controllers/:id/configuration/draft"),
1485
+ __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe))
1486
+ ], WagoControllerApi.prototype, "draft", 1);
1487
+ __decorateClass([
1488
+ (0, import_common2.Post)("controllers/:id/configuration/draft"),
1489
+ __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe)),
1490
+ __decorateParam(1, (0, import_common2.Body)())
1491
+ ], WagoControllerApi.prototype, "saveDraft", 1);
1492
+ __decorateClass([
1493
+ (0, import_common2.Post)("controllers/:id/configuration/validate"),
1494
+ __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe))
1495
+ ], WagoControllerApi.prototype, "validateDraft", 1);
1496
+ __decorateClass([
1497
+ (0, import_common2.Post)("controllers/:id/configuration/review"),
1498
+ __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe))
1499
+ ], WagoControllerApi.prototype, "reviewDraft", 1);
1500
+ __decorateClass([
1501
+ (0, import_common2.Get)("controllers/:id/configuration/revisions"),
1502
+ __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe)),
1503
+ __decorateParam(1, (0, import_common2.Query)("offset")),
1504
+ __decorateParam(2, (0, import_common2.Query)("limit"))
1505
+ ], WagoControllerApi.prototype, "revisions", 1);
1506
+ __decorateClass([
1507
+ (0, import_common2.Post)("controllers/:id/configuration/publish"),
1508
+ __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe))
1509
+ ], WagoControllerApi.prototype, "publishDraft", 1);
1510
+ __decorateClass([
1511
+ (0, import_common2.Post)("controllers/:id/configuration/rollback/:revision"),
1512
+ __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe)),
1513
+ __decorateParam(1, (0, import_common2.Param)("revision", import_common2.ParseIntPipe))
1514
+ ], WagoControllerApi.prototype, "rollback", 1);
1515
+ __decorateClass([
1516
+ (0, import_common2.Get)("controllers/:id/configuration/revisions/:revision/preview"),
1517
+ __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe)),
1518
+ __decorateParam(1, (0, import_common2.Param)("revision", import_common2.ParseIntPipe))
1519
+ ], WagoControllerApi.prototype, "previewRevision", 1);
723
1520
  WagoControllerApi = __decorateClass([
724
- (0, import_plugins_backend_sdk4.Auth)("resources.update"),
1521
+ (0, import_plugins_backend_sdk6.Auth)("resources.update"),
725
1522
  (0, import_common2.Controller)("wago"),
726
1523
  __decorateParam(0, (0, import_common2.Inject)(WagoService))
727
1524
  ], WagoControllerApi);
@@ -731,7 +1528,7 @@ var PLUGIN_CONTEXT2 = /* @__PURE__ */ Symbol.for("attraccess.plugin.context");
731
1528
  var WagoPluginModule = class {
732
1529
  };
733
1530
  var plugin = {
734
- entities: [WagoController, WagoSettings, WagoEnrollment],
1531
+ entities: [WagoController, WagoSettings, WagoEnrollment, WagoConfigurationDraft, WagoConfigurationRevision],
735
1532
  register(context) {
736
1533
  return {
737
1534
  module: WagoPluginModule,
@@ -21,6 +21,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
21
21
  // backend/migrations.ts
22
22
  var migrations_exports = {};
23
23
  __export(migrations_exports, {
24
+ AddWagoConfigurationRevisions1780000000002: () => AddWagoConfigurationRevisions1780000000002,
24
25
  AddWagoEnrollmentRevocation1780000000001: () => AddWagoEnrollmentRevocation1780000000001,
25
26
  CreateWagoControllers1780000000000: () => CreateWagoControllers1780000000000
26
27
  });
@@ -71,8 +72,35 @@ var AddWagoEnrollmentRevocation1780000000001 = class {
71
72
  await queryRunner.query('ALTER TABLE "plugin_wago_enrollments" DROP COLUMN "revoked_at"');
72
73
  }
73
74
  };
75
+
76
+ // backend/migrations/1780000000002-add-wago-configuration-revisions.ts
77
+ var AddWagoConfigurationRevisions1780000000002 = class {
78
+ constructor() {
79
+ __publicField(this, "name", "AddWagoConfigurationRevisions1780000000002");
80
+ }
81
+ async up(queryRunner) {
82
+ await queryRunner.query(`ALTER TABLE "plugin_wago_settings" ADD COLUMN "operational_prefix" varchar NOT NULL DEFAULT ('attraccess/wago')`);
83
+ await queryRunner.query(`CREATE TABLE "plugin_wago_configuration_drafts" (
84
+ "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "controller_id" integer NOT NULL,
85
+ "snapshot" text NOT NULL, "reviewed_hash" varchar, "updated_at" varchar NOT NULL,
86
+ CONSTRAINT "UQ_plugin_wago_configuration_drafts_controller_id" UNIQUE ("controller_id"))`);
87
+ await queryRunner.query(`CREATE TABLE "plugin_wago_configuration_revisions" (
88
+ "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "controller_id" integer NOT NULL,
89
+ "revision" integer NOT NULL, "snapshot" text NOT NULL, "content_hash" varchar NOT NULL,
90
+ "state" varchar NOT NULL, "rejection_errors" text, "published_at" varchar NOT NULL, "reported_at" varchar,
91
+ CONSTRAINT "UQ_plugin_wago_configuration_revisions_controller_revision" UNIQUE ("controller_id", "revision"))`);
92
+ await queryRunner.query('CREATE INDEX "IDX_plugin_wago_configuration_revisions_controller" ON "plugin_wago_configuration_revisions" ("controller_id", "revision")');
93
+ }
94
+ async down(queryRunner) {
95
+ await queryRunner.query('DROP INDEX "IDX_plugin_wago_configuration_revisions_controller"');
96
+ await queryRunner.query('DROP TABLE "plugin_wago_configuration_revisions"');
97
+ await queryRunner.query('DROP TABLE "plugin_wago_configuration_drafts"');
98
+ await queryRunner.query('ALTER TABLE "plugin_wago_settings" DROP COLUMN "operational_prefix"');
99
+ }
100
+ };
74
101
  // Annotate the CommonJS export names for ESM import in node:
75
102
  0 && (module.exports = {
103
+ AddWagoConfigurationRevisions1780000000002,
76
104
  AddWagoEnrollmentRevocation1780000000001,
77
105
  CreateWagoControllers1780000000000
78
106
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@attraccess/plugin-wago",
3
- "version": "0.1.0-nightly.925.2",
3
+ "version": "0.1.0-nightly.926.1",
4
4
  "description": "Official Attraccess integration for WAGO CC100 controllers.",
5
5
  "keywords": [
6
6
  "attraccess-plugin",