@attraccess/plugin-wago 0.1.0-nightly.966.1 → 0.1.0-nightly.970.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
@@ -52485,6 +52485,23 @@ async function auditCommissioning(context, principal, id, action, operation, suc
52485
52485
  var import_node_crypto3 = require("node:crypto");
52486
52486
  var import_common2 = require("@nestjs/common");
52487
52487
 
52488
+ // channel-behavior.ts
52489
+ function outputBehavior(channel) {
52490
+ if (!channel.capabilities.includes("output")) return null;
52491
+ return channel.capabilities.includes("pulse") || channel.pulse !== void 0 ? "pulsed" : "switched";
52492
+ }
52493
+ function supportsOutputAction(channel, action) {
52494
+ const behavior = outputBehavior(channel);
52495
+ return behavior === "switched" ? action === "set" : behavior === "pulsed" && action === "pulse" && !pulseBehaviorError(channel.capabilities, channel.pulse);
52496
+ }
52497
+ function pulseBehaviorError(capabilities, pulse) {
52498
+ if (!capabilities.includes("pulse") && pulse === void 0) return void 0;
52499
+ const duration = pulse && typeof pulse === "object" && !Array.isArray(pulse) ? pulse.durationMs : void 0;
52500
+ if (!capabilities.includes("output") || !capabilities.includes("pulse") || typeof duration !== "number" || !Number.isSafeInteger(duration) || duration <= 0)
52501
+ return "Pulsed behavior requires output and pulse capabilities and a positive integer durationMs.";
52502
+ return void 0;
52503
+ }
52504
+
52488
52505
  // backend/configuration.ts
52489
52506
  var import_node_crypto2 = require("node:crypto");
52490
52507
 
@@ -52579,6 +52596,9 @@ function commandTopic(prefix, hardwareId) {
52579
52596
  function configurationReportedWildcardTopic(prefix) {
52580
52597
  return configurationReportedTopic(prefix, "+");
52581
52598
  }
52599
+ function operationalWildcardTopic(prefix) {
52600
+ return `${normalizeOperationalPrefix(prefix)}/v${CONFIGURATION_PROTOCOL_VERSION}/controllers/+/#`;
52601
+ }
52582
52602
  function parseOperationalMessage(prefix, topic, payload) {
52583
52603
  const root = `${normalizeOperationalPrefix(prefix)}/v${CONFIGURATION_PROTOCOL_VERSION}/controllers/`;
52584
52604
  if (!topic.startsWith(root)) return null;
@@ -53117,7 +53137,6 @@ function validateSnapshot(snapshot) {
53117
53137
  validateGuard(channel.guard, `${path}.guard`, capabilities, channelIds, errors);
53118
53138
  validateFeedback(channel.feedback, `${path}.feedback`, capabilities, channel.id, channelsById, errors);
53119
53139
  validateMeasurement(channel.measurement, `${path}.measurement`, capabilities, errors);
53120
- validateProfile(channel.profile, capabilities, path, errors);
53121
53140
  });
53122
53141
  return errors;
53123
53142
  }
@@ -53239,17 +53258,11 @@ function validateRange(value, path, capabilities, errors) {
53239
53258
  errors.push({ path, code: "unsupported_field", message: "range requires input or measurement capability" });
53240
53259
  }
53241
53260
  function validatePulse(value, path, capabilities, errors) {
53261
+ const message = pulseBehaviorError([...capabilities], value);
53262
+ if (message) errors.push({ path, code: "invalid_pulse", message });
53242
53263
  if (value === void 0) return;
53243
53264
  if (!record(value, path, errors)) return;
53244
53265
  exactKeys(value, path, ["durationMs"], errors);
53245
- if (!Number.isSafeInteger(value.durationMs) || value.durationMs <= 0)
53246
- errors.push({
53247
- path: `${path}.durationMs`,
53248
- code: "invalid_duration",
53249
- message: "durationMs must be a positive integer"
53250
- });
53251
- if (!capabilities.has("pulse"))
53252
- errors.push({ path, code: "unsupported_field", message: "pulse requires pulse capability" });
53253
53266
  }
53254
53267
  function validateGuard(value, path, capabilities, channelIds, errors) {
53255
53268
  if (value === void 0) return;
@@ -53297,22 +53310,6 @@ function validateFeedback(value, path, capabilities, currentChannelId, channelsB
53297
53310
  if (!capabilities.has("feedback"))
53298
53311
  errors.push({ path, code: "unsupported_field", message: "feedback requires feedback capability" });
53299
53312
  }
53300
- function validateProfile(value, capabilities, path, errors) {
53301
- const required = {
53302
- "metered-switched-load": ["output", "measurement"],
53303
- "pulsed-lock-bank": ["output", "pulse"],
53304
- "guarded-enable-request": ["output", "guard"],
53305
- "generic-digital-output": ["output"],
53306
- "generic-monitored-input": ["input"]
53307
- };
53308
- required[value]?.filter((capability) => !capabilities.has(capability)).forEach(
53309
- (capability) => errors.push({
53310
- path: `${path}.capabilities`,
53311
- code: "missing_capability",
53312
- message: `${value} requires ${capability} capability`
53313
- })
53314
- );
53315
- }
53316
53313
  function presetChannel(application) {
53317
53314
  const base2 = {
53318
53315
  id: application.channelId,
@@ -53944,12 +53941,18 @@ var WagoCommandHandler = class {
53944
53941
  };
53945
53942
  }
53946
53943
  if (channel) {
53947
- const actions = channel.capabilities.includes("output") ? [
53948
- { const: "set", title: "Set state" },
53949
- ...channel.capabilities.includes("pulse") ? [{ const: "pulse", title: "Pulse" }] : []
53950
- ] : [];
53951
- properties.action = { type: "string", title: "Operation", oneOf: actions, refreshesSchema: true };
53952
- if (config.action === "set") properties.value = { type: "boolean", title: "State", default: false };
53944
+ const pulsed = outputBehavior(channel) === "pulsed";
53945
+ const action = pulsed ? "pulse" : "set";
53946
+ properties.action = {
53947
+ type: "string",
53948
+ title: "Operation",
53949
+ oneOf: [{ const: action, title: pulsed ? "Trigger pulse" : "Turn on / turn off" }],
53950
+ // Never silently reinterpret an existing incompatible flow action.
53951
+ ...config.action === void 0 ? { default: action } : {},
53952
+ refreshesSchema: true,
53953
+ description: pulsed ? channel.pulse ? `Turns on for ${channel.pulse.durationMs} ms, then off automatically. Change the duration in the controller configuration.` : "This pulsed channel is missing its duration. Correct and publish its controller configuration." : "Stays on or off until another command or the configured disconnect policy changes it."
53954
+ };
53955
+ if (!pulsed) properties.value = { type: "boolean", title: "Output on", default: false };
53953
53956
  properties.expectedConfigurationRevision = {
53954
53957
  type: "number",
53955
53958
  title: "Configuration revision",
@@ -54030,8 +54033,13 @@ var WagoCommandHandler = class {
54030
54033
  if (!channel) return [{ field: "channelId", message: "The selected Logical Channel no longer exists." }];
54031
54034
  if (!channel.capabilities.includes("output"))
54032
54035
  return [{ field: "channelId", message: "The selected Logical Channel no longer supports output commands." }];
54033
- if (action === "pulse" && !channel.capabilities.includes("pulse"))
54034
- return [{ field: "action", message: "The selected Logical Channel no longer supports pulses." }];
54036
+ if (!supportsOutputAction(channel, action))
54037
+ return [
54038
+ {
54039
+ field: "action",
54040
+ message: outputBehavior(channel) === "pulsed" ? action === "set" ? "This channel is configured as pulsed. Reopen this node and explicitly select Trigger pulse, or change the channel to switched behavior and publish it." : "This pulsed channel has invalid pulse settings. Correct and publish its controller configuration." : "This channel is configured as switched. Reopen this node and explicitly select Turn on / turn off, or change the channel to pulsed behavior and publish it."
54041
+ }
54042
+ ];
54035
54043
  return [];
54036
54044
  }
54037
54045
  async execute(config, commandId = (0, import_node_crypto4.randomUUID)()) {
@@ -54744,9 +54752,21 @@ var WagoService = class {
54744
54752
  await this.claimedController(controllerId);
54745
54753
  return this.drafts.findOneBy({ controllerId });
54746
54754
  }
54747
- async saveDraft(controllerId, snapshot, metadata2, principal) {
54755
+ async getConfigurationBaseline(controllerId) {
54756
+ await this.claimedController(controllerId);
54757
+ const [revision] = await this.revisions.find({
54758
+ where: { controllerId, state: "applied" },
54759
+ order: { revision: "DESC" },
54760
+ take: 1
54761
+ });
54762
+ return revision ?? null;
54763
+ }
54764
+ async saveDraft(controllerId, snapshot, metadata2, principal, expectedDraft) {
54748
54765
  return this.withConfigurationLock(controllerId, async () => {
54749
54766
  const previous = await this.getDraft(controllerId);
54767
+ if (expectedDraft !== void 0 && (expectedDraft === null ? previous !== null : !previous || expectedDraft.snapshot !== previous.snapshot || expectedDraft.updatedAt !== previous.updatedAt || expectedDraft.presetProvenance !== previous.presetProvenance)) {
54768
+ throw new import_common3.ConflictException("Saved draft changed. Reload it before saving your edits.");
54769
+ }
54750
54770
  const validatedMetadata = metadata2 === void 0 ? void 0 : editorMetadata(metadata2);
54751
54771
  const previousMetadata = this.metadataFromProvenance(previous?.presetProvenance);
54752
54772
  const before = previous ? JSON.parse(previous.snapshot) : null;
@@ -61908,6 +61928,9 @@ var WagoControllerApi = class {
61908
61928
  draft(id) {
61909
61929
  return this.wago.getDraft(id);
61910
61930
  }
61931
+ baseline(id) {
61932
+ return this.wago.getConfigurationBaseline(id);
61933
+ }
61911
61934
  presets() {
61912
61935
  return this.wago.presets();
61913
61936
  }
@@ -61927,7 +61950,7 @@ var WagoControllerApi = class {
61927
61950
  );
61928
61951
  }
61929
61952
  saveDraft(id, body, request) {
61930
- return this.wago.saveDraft(id, body?.snapshot, body?.metadata, wagoAuditPrincipal(request));
61953
+ return this.wago.saveDraft(id, body?.snapshot, body?.metadata, wagoAuditPrincipal(request), body?.expectedDraft);
61931
61954
  }
61932
61955
  validateDraft(id, body) {
61933
61956
  return this.wago.validateDraft(id, body?.snapshot);
@@ -62096,6 +62119,10 @@ __decorateClass([
62096
62119
  (0, import_common9.Get)("controllers/:id/configuration/draft"),
62097
62120
  __decorateParam(0, (0, import_common9.Param)("id", import_common9.ParseIntPipe))
62098
62121
  ], WagoControllerApi.prototype, "draft", 1);
62122
+ __decorateClass([
62123
+ (0, import_common9.Get)("controllers/:id/configuration/baseline"),
62124
+ __decorateParam(0, (0, import_common9.Param)("id", import_common9.ParseIntPipe))
62125
+ ], WagoControllerApi.prototype, "baseline", 1);
62099
62126
  __decorateClass([
62100
62127
  (0, import_common9.Get)("configuration/presets")
62101
62128
  ], WagoControllerApi.prototype, "presets", 1);
@@ -62641,8 +62668,7 @@ WagoDiagnosticsController = __decorateClass([
62641
62668
 
62642
62669
  // backend/wago-command-node.ts
62643
62670
  var WAGO_COMMAND_NODE_TYPE = "plugin.wago.command";
62644
- function createWagoCommandNode(context) {
62645
- const service = () => context.get(WagoService);
62671
+ function createWagoCommandNode(service) {
62646
62672
  return {
62647
62673
  type: WAGO_COMMAND_NODE_TYPE,
62648
62674
  label: "WAGO command",
@@ -62661,10 +62687,559 @@ function createWagoCommandNode(context) {
62661
62687
  };
62662
62688
  }
62663
62689
 
62664
- // backend/plugin.ts
62690
+ // backend/wago-flow.service.ts
62691
+ var import_common13 = require("@nestjs/common");
62692
+
62693
+ // backend/wago-state-nodes.ts
62694
+ var WAGO_EVENT_NODE_TYPE = "plugin.wago.event-received";
62695
+ function createWagoStateNodes(service) {
62696
+ return [
62697
+ {
62698
+ type: WAGO_EVENT_NODE_TYPE,
62699
+ label: "WAGO event received",
62700
+ description: "Start a flow when a channel reports state, a measurement, or a fault. Data is available in wago.",
62701
+ isInput: true,
62702
+ inputs: [],
62703
+ outputs: ["output"],
62704
+ resolveConfigSchema: (config) => service().resolveConfigSchema(config, "event"),
62705
+ validateConfig: (config, context) => service().validateConfig(config, "event", context)
62706
+ },
62707
+ {
62708
+ type: "plugin.wago.read-state",
62709
+ label: "WAGO read state",
62710
+ description: "Read the latest channel state or measurement into wago. Unavailable data uses the unavailable output.",
62711
+ inputs: ["input"],
62712
+ outputs: ["output", "unavailable"],
62713
+ resolveConfigSchema: (config) => service().resolveConfigSchema(config, "read"),
62714
+ validateConfig: (config, context) => service().validateConfig(config, "read", context),
62715
+ execute: async (node, input) => {
62716
+ const state = service().read(node.data);
62717
+ const wago = state ? service().payload(state) : { available: false };
62718
+ return { payload: { ...input, wago }, outputHandle: wago.available ? "output" : "unavailable" };
62719
+ }
62720
+ },
62721
+ {
62722
+ type: "plugin.wago.wait-for-state",
62723
+ label: "WAGO wait for state",
62724
+ description: "Wait for an available channel value to match, then continue with the value in wago.",
62725
+ inputs: ["input"],
62726
+ outputs: ["output", "timeout"],
62727
+ resolveConfigSchema: (config) => service().resolveConfigSchema(config, "wait"),
62728
+ validateConfig: (config, context) => service().validateConfig(config, "wait", context),
62729
+ execute: async (node, input) => {
62730
+ const state = await service().wait(node.data);
62731
+ return {
62732
+ payload: { ...input, wago: state ? service().payload(state) : { available: false } },
62733
+ outputHandle: state ? "output" : "timeout"
62734
+ };
62735
+ }
62736
+ }
62737
+ ];
62738
+ }
62739
+
62740
+ // backend/wago-flow.service.ts
62665
62741
  var PLUGIN_CONTEXT5 = /* @__PURE__ */ Symbol.for("attraccess.plugin.context");
62742
+ var STALE_AFTER_MS2 = 9e4;
62743
+ var MAX_CACHE_ENTRIES = 2e3;
62744
+ var MAX_PENDING_DISPATCHES = 100;
62745
+ var MAX_TIMEOUT_MS = 2147483647;
62746
+ var MAX_RETIRED_STREAMS = 128;
62747
+ var WagoFlowService = class {
62748
+ constructor(context) {
62749
+ __publicField(this, "context", context);
62750
+ __publicField(this, "cache", /* @__PURE__ */ new Map());
62751
+ __publicField(this, "controllerByHardwareId", /* @__PURE__ */ new Map());
62752
+ __publicField(this, "streams", /* @__PURE__ */ new Map());
62753
+ __publicField(this, "offlineControllers", /* @__PURE__ */ new Set());
62754
+ __publicField(this, "unavailableHardware", /* @__PURE__ */ new Set());
62755
+ __publicField(this, "unavailableConfiguration", /* @__PURE__ */ new Set());
62756
+ __publicField(this, "appliedConfigurations", /* @__PURE__ */ new Map());
62757
+ __publicField(this, "channelCache", /* @__PURE__ */ new Map());
62758
+ __publicField(this, "waiters", /* @__PURE__ */ new Set());
62759
+ __publicField(this, "waitersByKey", /* @__PURE__ */ new Map());
62760
+ __publicField(this, "subscriptions", []);
62761
+ __publicField(this, "dispatches", []);
62762
+ __publicField(this, "lastDispatchAtByNode", /* @__PURE__ */ new Map());
62763
+ __publicField(this, "dispatching", false);
62764
+ __publicField(this, "messageQueue", Promise.resolve());
62765
+ __publicField(this, "refreshTimer", null);
62766
+ }
62767
+ // Plugin registration precedes the host datasource; resolve repositories only when used.
62768
+ get controllers() {
62769
+ return this.context.getRepository(WagoController);
62770
+ }
62771
+ get revisions() {
62772
+ return this.context.getRepository(WagoConfigurationRevision);
62773
+ }
62774
+ get settings() {
62775
+ return this.context.getRepository(WagoSettings);
62776
+ }
62777
+ async onModuleInit() {
62778
+ await this.refresh();
62779
+ this.refreshTimer = setInterval(
62780
+ () => void this.refresh().catch(
62781
+ (error) => this.context.logger.warn(`Could not refresh WAGO flow subscriptions: ${String(error)}`)
62782
+ ),
62783
+ 6e4
62784
+ );
62785
+ }
62786
+ onModuleDestroy() {
62787
+ if (this.refreshTimer) clearInterval(this.refreshTimer);
62788
+ this.subscriptions.splice(0).forEach((subscription) => subscription.unsubscribe());
62789
+ this.waiters.forEach((wake) => wake(void 0, true));
62790
+ this.waiters.clear();
62791
+ this.waitersByKey.clear();
62792
+ }
62793
+ async refresh() {
62794
+ const settings = await this.settings.findOneBy({ id: 1 });
62795
+ if (!settings) return;
62796
+ const controllers = await this.controllers.find({ where: { trustState: "claimed" } });
62797
+ const controllerIds = new Set(controllers.map((controller) => controller.id));
62798
+ for (const id of this.offlineControllers) if (!controllerIds.has(id)) this.offlineControllers.delete(id);
62799
+ for (const id of this.unavailableHardware) if (!controllerIds.has(id)) this.unavailableHardware.delete(id);
62800
+ for (const id of this.unavailableConfiguration)
62801
+ if (!controllerIds.has(id)) this.unavailableConfiguration.delete(id);
62802
+ for (const id of this.appliedConfigurations.keys())
62803
+ if (!controllerIds.has(id)) this.appliedConfigurations.delete(id);
62804
+ for (const id of this.streams.keys()) if (!controllerIds.has(id)) this.streams.delete(id);
62805
+ this.channelCache.clear();
62806
+ const revisions = await this.loadLatestAppliedRevisions(controllers.map((controller) => controller.id));
62807
+ for (const revision of revisions) this.cacheChannels(revision);
62808
+ for (const controller of controllers)
62809
+ if (!this.channelCache.has(controller.id)) this.channelCache.set(controller.id, []);
62810
+ const validChannels = new Map(
62811
+ controllers.map((controller) => [
62812
+ controller.id,
62813
+ new Set((this.channelCache.get(controller.id) ?? []).map((channel) => channel.id))
62814
+ ])
62815
+ );
62816
+ for (const [key, state] of this.cache)
62817
+ if (!validChannels.get(state.controllerId)?.has(state.channelId)) this.cache.delete(key);
62818
+ const serverIds = new Set(
62819
+ controllers.map((controller) => controller.mqttServerId ?? settings.defaultMqttServerId).filter(Boolean)
62820
+ );
62821
+ const controllerByHardwareId = new Map(
62822
+ controllers.map(
62823
+ (controller) => [
62824
+ controller.hardwareId,
62825
+ { controller, serverId: controller.mqttServerId ?? settings.defaultMqttServerId }
62826
+ ]
62827
+ ).filter(([, entry]) => Boolean(entry.serverId))
62828
+ );
62829
+ const replacements = [];
62830
+ try {
62831
+ for (const serverId of serverIds)
62832
+ replacements.push(
62833
+ await this.context.mqtt.subscribe(
62834
+ serverId,
62835
+ operationalWildcardTopic(settings.operationalPrefix),
62836
+ (message) => this.onMessage(serverId, settings.operationalPrefix, message.topic, message.payload)
62837
+ )
62838
+ );
62839
+ } catch (error) {
62840
+ replacements.forEach((subscription) => subscription.unsubscribe());
62841
+ throw error;
62842
+ }
62843
+ this.subscriptions.splice(0).forEach((subscription) => subscription.unsubscribe());
62844
+ this.subscriptions.push(...replacements);
62845
+ this.controllerByHardwareId = controllerByHardwareId;
62846
+ }
62847
+ async resolveConfigSchema(config, kind, validationContext = /* @__PURE__ */ new Map()) {
62848
+ const controllers = await this.cached(
62849
+ validationContext,
62850
+ "wago-flow-controllers",
62851
+ () => this.controllers.find({ where: { trustState: "claimed" }, order: { name: "ASC" } })
62852
+ );
62853
+ const selected = typeof config.controllerId === "number" ? controllers.find((controller) => controller.id === config.controllerId) : void 0;
62854
+ const revision = selected ? await this.cached(validationContext, `wago-applied-revision:${selected.id}`, async () => {
62855
+ const [latest] = await this.revisions.find({
62856
+ where: { controllerId: selected.id, state: "applied" },
62857
+ order: { revision: "DESC" },
62858
+ take: 1
62859
+ });
62860
+ return latest ?? null;
62861
+ }) : null;
62862
+ const channels = revision ? JSON.parse(revision.snapshot).logicalChannels : [];
62863
+ let names = {};
62864
+ try {
62865
+ names = JSON.parse(revision?.presetProvenance ?? "null")?.editor?.names ?? {};
62866
+ } catch {
62867
+ }
62868
+ const channel = channels.find((item) => item.id === config.channelId);
62869
+ const properties = {
62870
+ controllerId: {
62871
+ type: "number",
62872
+ title: "Controller",
62873
+ refreshesSchema: true,
62874
+ oneOf: controllers.map((controller) => ({
62875
+ const: controller.id,
62876
+ title: controller.name ?? controller.hardwareId
62877
+ })),
62878
+ description: selected && !revision ? "Publish a configuration and wait for the controller to apply it first." : void 0
62879
+ },
62880
+ channelId: {
62881
+ type: "string",
62882
+ title: "Logical Channel",
62883
+ refreshesSchema: true,
62884
+ oneOf: channels.map((channel2) => ({
62885
+ const: channel2.id,
62886
+ title: typeof names[channel2.id] === "string" ? names[channel2.id] : channel2.id
62887
+ }))
62888
+ }
62889
+ };
62890
+ if (kind === "event") {
62891
+ properties.category = {
62892
+ type: "string",
62893
+ title: "Event category",
62894
+ oneOf: this.categories(channel).map((category) => ({ const: category, title: category }))
62895
+ };
62896
+ properties.minimumIntervalMs = { type: "number", title: "Minimum interval (ms)", minimum: 0, default: 0 };
62897
+ if (channel?.capabilities.includes("measurement"))
62898
+ properties.minimumChange = { type: "number", title: "Minimum change (wire units)", minimum: 0 };
62899
+ }
62900
+ if (kind !== "event")
62901
+ properties.category = {
62902
+ type: "string",
62903
+ title: "State category",
62904
+ refreshesSchema: true,
62905
+ oneOf: this.readCategories(channel).map((category) => ({ const: category, title: category }))
62906
+ };
62907
+ if (kind === "wait") {
62908
+ properties.equals = {
62909
+ type: config.category === "measurement" ? "number" : "boolean",
62910
+ title: config.category === "measurement" ? "Equals (wire value)" : "Equals"
62911
+ };
62912
+ properties.timeoutMs = {
62913
+ type: "number",
62914
+ title: "Timeout (ms)",
62915
+ minimum: 1,
62916
+ maximum: MAX_TIMEOUT_MS,
62917
+ default: 3e4
62918
+ };
62919
+ }
62920
+ return {
62921
+ dynamic: true,
62922
+ type: "object",
62923
+ properties,
62924
+ required: ["controllerId", "channelId", "category", ...kind === "wait" ? ["equals", "timeoutMs"] : []]
62925
+ };
62926
+ }
62927
+ cached(context, key, load) {
62928
+ if (!context.has(key)) context.set(key, load());
62929
+ return context.get(key);
62930
+ }
62931
+ async validateConfig(config, kind, context = /* @__PURE__ */ new Map()) {
62932
+ const schema = await this.resolveConfigSchema(config, kind, context);
62933
+ const properties = schema.properties;
62934
+ const errors = [];
62935
+ for (const [field2, message] of [
62936
+ ["controllerId", "Select a claimed WAGO controller."],
62937
+ ["channelId", "Select a channel from the applied controller configuration."],
62938
+ ["category", "Select a category supported by this channel."]
62939
+ ]) {
62940
+ if (!properties[field2]?.oneOf?.some((choice) => choice.const === config[field2])) errors.push({ field: field2, message });
62941
+ }
62942
+ const numericFields = kind === "event" ? ["minimumIntervalMs", "minimumChange"] : kind === "wait" ? ["timeoutMs"] : [];
62943
+ for (const field2 of numericFields) {
62944
+ const value = config[field2];
62945
+ if (value !== void 0 && (typeof value !== "number" || !Number.isFinite(value) || value < (field2 === "timeoutMs" ? 1 : 0) || field2 === "timeoutMs" && value > MAX_TIMEOUT_MS))
62946
+ errors.push({
62947
+ field: field2,
62948
+ message: field2 === "timeoutMs" ? "Enter a valid positive timeout." : "Enter a non-negative number."
62949
+ });
62950
+ }
62951
+ if (kind === "wait" && (config.category === "measurement" ? typeof config.equals !== "number" || !Number.isFinite(config.equals) : typeof config.equals !== "boolean"))
62952
+ errors.push({ field: "equals", message: "Enter a matching value for the selected state category." });
62953
+ return errors;
62954
+ }
62955
+ read(config) {
62956
+ if (typeof config.controllerId !== "number" || typeof config.channelId !== "string") return null;
62957
+ if (typeof config.category === "string")
62958
+ return this.cache.get(this.cacheKey(config.controllerId, config.channelId, config.category)) ?? null;
62959
+ const entries = [...this.cache.values()].filter(
62960
+ (state) => state.controllerId === config.controllerId && state.channelId === config.channelId && (!config.category || state.category === config.category)
62961
+ );
62962
+ return entries.sort((left, right) => right.receivedAt - left.receivedAt)[0] ?? null;
62963
+ }
62964
+ async wait(config) {
62965
+ if (typeof config.controllerId !== "number" || typeof config.channelId !== "string" || typeof config.category !== "string")
62966
+ return null;
62967
+ const current = this.read(config);
62968
+ if (current && this.matchesCondition(current, config)) return current;
62969
+ const timeoutMs = typeof config.timeoutMs === "number" && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 ? Math.min(config.timeoutMs, MAX_TIMEOUT_MS) : 3e4;
62970
+ const waiterKey = this.cacheKey(config.controllerId, config.channelId, config.category);
62971
+ return new Promise((resolve2) => {
62972
+ const cleanup = () => {
62973
+ clearTimeout(timer);
62974
+ this.waiters.delete(wake);
62975
+ const waiters2 = this.waitersByKey.get(waiterKey);
62976
+ waiters2?.delete(wake);
62977
+ if (!waiters2?.size) this.waitersByKey.delete(waiterKey);
62978
+ };
62979
+ const wake = (state, cancel = false) => {
62980
+ if (!cancel && (!state || !this.matchesCondition(state, config))) return;
62981
+ cleanup();
62982
+ resolve2(cancel ? null : state);
62983
+ };
62984
+ const timer = setTimeout(() => {
62985
+ cleanup();
62986
+ resolve2(null);
62987
+ }, timeoutMs);
62988
+ this.waiters.add(wake);
62989
+ const waiters = this.waitersByKey.get(waiterKey) ?? /* @__PURE__ */ new Set();
62990
+ waiters.add(wake);
62991
+ this.waitersByKey.set(waiterKey, waiters);
62992
+ });
62993
+ }
62994
+ onMessage(serverId, prefix, topic, payload) {
62995
+ const queued = this.messageQueue.catch(() => void 0).then(() => this.processMessage(serverId, prefix, topic, payload));
62996
+ this.messageQueue = queued;
62997
+ return queued;
62998
+ }
62999
+ async processMessage(serverId, prefix, topic, payload) {
63000
+ let parsed;
63001
+ try {
63002
+ parsed = parseOperationalMessage(prefix, topic, payload);
63003
+ } catch (error) {
63004
+ this.context.logger.warn(`Ignoring invalid WAGO event: ${String(error)}`);
63005
+ return;
63006
+ }
63007
+ if (!parsed) return;
63008
+ const { hardwareId, message: event } = parsed;
63009
+ const entry = this.controllerByHardwareId.get(hardwareId);
63010
+ if (!entry || entry.serverId !== serverId) return;
63011
+ const { controller } = entry;
63012
+ const eventTime = Date.parse(event.timestamp);
63013
+ if (eventTime > Date.now()) {
63014
+ this.context.logger.warn(`Ignoring future-dated WAGO event for ${controller.hardwareId}`);
63015
+ return;
63016
+ }
63017
+ const channels = await this.channels(controller.id);
63018
+ let stream = this.streams.get(controller.id);
63019
+ if (stream?.exhausted) return;
63020
+ if (!stream || stream.active !== event.streamId) {
63021
+ if (event.category !== "state" || stream?.retired.has(event.streamId) || stream && (!event.connected || Date.now() - eventTime > STALE_AFTER_MS2 || eventTime <= stream.latestSourceTime)) {
63022
+ this.context.logger.warn(`Ignoring unestablished or retired WAGO stream for ${controller.hardwareId}`);
63023
+ return;
63024
+ }
63025
+ if (stream && stream.retired.size >= MAX_RETIRED_STREAMS) {
63026
+ stream.exhausted = true;
63027
+ for (const state of this.cache.values()) if (state.controllerId === controller.id) state.invalidated = true;
63028
+ this.context.logger.warn(
63029
+ `WAGO stream history exhausted for ${controller.hardwareId}; refusing further samples`
63030
+ );
63031
+ return;
63032
+ }
63033
+ const retired = stream?.retired ?? /* @__PURE__ */ new Set();
63034
+ if (stream) retired.add(stream.active);
63035
+ stream = {
63036
+ active: event.streamId,
63037
+ latestSourceTime: eventTime,
63038
+ sampleNotBefore: eventTime,
63039
+ retired,
63040
+ sequences: /* @__PURE__ */ new Map()
63041
+ };
63042
+ this.streams.set(controller.id, stream);
63043
+ for (const state of this.cache.values()) if (state.controllerId === controller.id) state.invalidated = true;
63044
+ }
63045
+ const previous = stream.sequences.get(event.category);
63046
+ if (previous !== void 0 && event.sequence <= previous) {
63047
+ this.context.logger.warn(`Ignoring duplicate or out-of-order WAGO event for ${controller.hardwareId}`);
63048
+ return;
63049
+ }
63050
+ if (previous !== void 0 && event.sequence > previous + 1)
63051
+ this.context.logger.warn(
63052
+ `WAGO event sequence gap for ${controller.hardwareId}: ${previous} to ${event.sequence}`
63053
+ );
63054
+ stream.sequences.set(event.category, event.sequence);
63055
+ stream.latestSourceTime = Math.max(stream.latestSourceTime, eventTime);
63056
+ if (event.category === "state") {
63057
+ const wasUnavailable = this.offlineControllers.has(controller.id) || this.unavailableHardware.has(controller.id) || this.unavailableConfiguration.has(controller.id) || stream.stateTimestamp !== void 0 && Date.now() - stream.stateTimestamp > STALE_AFTER_MS2;
63058
+ stream.stateTimestamp = eventTime;
63059
+ if (event.connected) this.offlineControllers.delete(controller.id);
63060
+ else this.offlineControllers.add(controller.id);
63061
+ if (event.readiness?.hardwareAvailable === false) this.unavailableHardware.add(controller.id);
63062
+ else if (event.readiness?.hardwareAvailable === true) this.unavailableHardware.delete(controller.id);
63063
+ const applied = this.appliedConfigurations.get(controller.id);
63064
+ if (applied && event.revision === applied.revision && event.contentHash === applied.contentHash)
63065
+ this.unavailableConfiguration.delete(controller.id);
63066
+ else this.unavailableConfiguration.add(controller.id);
63067
+ if (wasUnavailable || !event.connected || this.unavailableHardware.has(controller.id) || this.unavailableConfiguration.has(controller.id))
63068
+ stream.sampleNotBefore = Math.max(stream.sampleNotBefore, eventTime);
63069
+ for (const state of this.cache.values()) {
63070
+ if (state.controllerId !== controller.id) continue;
63071
+ if (wasUnavailable || !event.connected || this.unavailableHardware.has(controller.id) || this.unavailableConfiguration.has(controller.id) || state.category === "state")
63072
+ state.invalidated = true;
63073
+ }
63074
+ for (const channel of channels) {
63075
+ const value = channel.capabilities.includes("input") && Object.hasOwn(event.inputs ?? {}, channel.id) ? event.inputs[channel.id] : channel.capabilities.includes("output") && Object.hasOwn(event.outputs, channel.id) ? event.outputs[channel.id] : void 0;
63076
+ if (typeof value === "boolean") this.store(controller, channel.id, event, value);
63077
+ }
63078
+ } else if ("channelId" in event) {
63079
+ const channel = channels.find((channel2) => channel2.id === event.channelId);
63080
+ if (!channel || event.category === "measurement" && !channel.capabilities.includes("measurement")) return;
63081
+ this.store(controller, event.channelId, event, event.category === "measurement" ? event.value : event);
63082
+ }
63083
+ }
63084
+ store(controller, channelId, event, value) {
63085
+ const state = {
63086
+ controllerId: controller.id,
63087
+ hardwareId: controller.hardwareId,
63088
+ channelId,
63089
+ category: event.category,
63090
+ value,
63091
+ timestamp: event.timestamp,
63092
+ sequence: event.sequence,
63093
+ streamId: event.streamId,
63094
+ ...event.category === "measurement" ? { unit: event.unit, kind: event.kind } : {},
63095
+ ...event.category === "state" ? { revision: event.revision, contentHash: event.contentHash } : {},
63096
+ receivedAt: Date.now(),
63097
+ offline: this.offlineControllers.has(controller.id),
63098
+ invalidated: this.unavailableHardware.has(controller.id) || this.unavailableConfiguration.has(controller.id) || Date.parse(event.timestamp) < (this.streams.get(controller.id)?.sampleNotBefore ?? 0)
63099
+ };
63100
+ const cacheKey = this.cacheKey(controller.id, channelId, event.category);
63101
+ const previous = this.cache.get(cacheKey);
63102
+ this.cache.set(cacheKey, state);
63103
+ if (this.cache.size > MAX_CACHE_ENTRIES) {
63104
+ const oldest = this.cache.keys().next().value;
63105
+ if (oldest) this.cache.delete(oldest);
63106
+ }
63107
+ this.waitersByKey.get(cacheKey)?.forEach((wake) => wake(state));
63108
+ if (this.dispatches.length >= MAX_PENDING_DISPATCHES) {
63109
+ this.context.logger.warn(`Dropping excess WAGO flow dispatch for ${controller.hardwareId}`);
63110
+ return;
63111
+ }
63112
+ this.dispatches.push({ state, previous });
63113
+ if (!this.dispatching) void this.dispatch();
63114
+ }
63115
+ async channels(controllerId) {
63116
+ const cached = this.channelCache.get(controllerId);
63117
+ if (cached) return cached;
63118
+ const [revision] = await this.revisions.find({
63119
+ where: { controllerId, state: "applied" },
63120
+ order: { revision: "DESC" },
63121
+ take: 1
63122
+ });
63123
+ if (!revision) {
63124
+ this.channelCache.set(controllerId, []);
63125
+ return [];
63126
+ }
63127
+ return this.cacheChannels(revision);
63128
+ }
63129
+ async loadLatestAppliedRevisions(controllerIds) {
63130
+ if (!controllerIds.length) return [];
63131
+ return this.revisions.createQueryBuilder("revision").innerJoin(
63132
+ (query) => query.subQuery().select("latest.controllerId", "controllerId").addSelect("MAX(latest.revision)", "revision").from(WagoConfigurationRevision, "latest").where("latest.controllerId IN (:...controllerIds)", { controllerIds }).andWhere("latest.state = :state", { state: "applied" }).groupBy("latest.controllerId"),
63133
+ "latest",
63134
+ "latest.controllerId = revision.controllerId AND latest.revision = revision.revision"
63135
+ ).where("revision.state = :state", { state: "applied" }).getMany();
63136
+ }
63137
+ cacheChannels(revision) {
63138
+ try {
63139
+ const channels = JSON.parse(revision.snapshot).logicalChannels;
63140
+ const previous = this.appliedConfigurations.get(revision.controllerId);
63141
+ if (previous?.revision !== revision.revision || previous?.contentHash !== revision.contentHash) {
63142
+ this.unavailableConfiguration.add(revision.controllerId);
63143
+ for (const state of this.cache.values())
63144
+ if (state.controllerId === revision.controllerId) state.invalidated = true;
63145
+ }
63146
+ this.appliedConfigurations.set(revision.controllerId, {
63147
+ revision: revision.revision,
63148
+ contentHash: revision.contentHash
63149
+ });
63150
+ this.channelCache.set(revision.controllerId, channels);
63151
+ return channels;
63152
+ } catch {
63153
+ this.channelCache.set(revision.controllerId, []);
63154
+ return [];
63155
+ }
63156
+ }
63157
+ cacheKey(controllerId, channelId, category) {
63158
+ return `${controllerId}:${channelId}:${category}`;
63159
+ }
63160
+ categories(channel) {
63161
+ return [
63162
+ ...channel?.capabilities.some((capability) => capability === "input" || capability === "output") ? ["state"] : [],
63163
+ ...channel?.capabilities.includes("measurement") ? ["measurement"] : [],
63164
+ "fault"
63165
+ ];
63166
+ }
63167
+ readCategories(channel) {
63168
+ return [
63169
+ ...channel?.capabilities.some((capability) => capability === "input" || capability === "output") ? ["state"] : [],
63170
+ ...channel?.capabilities.includes("measurement") ? ["measurement"] : []
63171
+ ];
63172
+ }
63173
+ matchesEvent(config, nodeId, state, previous) {
63174
+ if (config.controllerId !== state.controllerId || config.channelId !== state.channelId || config.category !== state.category)
63175
+ return false;
63176
+ if (typeof config.minimumChange === "number" && typeof state.value === "number" && typeof previous?.value === "number" && previous.streamId === state.streamId && previous.unit === state.unit && previous.kind === state.kind && Math.abs(state.value - previous.value) < config.minimumChange)
63177
+ return false;
63178
+ if (typeof config.minimumIntervalMs === "number") {
63179
+ const lastDispatchAt = this.lastDispatchAtByNode.get(nodeId);
63180
+ if (lastDispatchAt !== void 0 && state.receivedAt - lastDispatchAt < config.minimumIntervalMs) return false;
63181
+ this.lastDispatchAtByNode.set(nodeId, state.receivedAt);
63182
+ }
63183
+ return true;
63184
+ }
63185
+ matchesCondition(state, config) {
63186
+ return this.freshness(state).available && (config.equals === void 0 || state.value === config.equals);
63187
+ }
63188
+ freshness(state) {
63189
+ const age = Date.now() - Date.parse(state.timestamp);
63190
+ const stale = !Number.isFinite(age) || age < 0 || age > STALE_AFTER_MS2;
63191
+ const offline = state.offline === true || this.offlineControllers.has(state.controllerId);
63192
+ const stream = this.streams.get(state.controllerId);
63193
+ const stateTimestamp = stream?.stateTimestamp;
63194
+ const connectionStale = stateTimestamp === void 0 || Date.now() - stateTimestamp > STALE_AFTER_MS2;
63195
+ return {
63196
+ stale,
63197
+ offline,
63198
+ connectionStale,
63199
+ available: !stale && !offline && !connectionStale && stream?.active === state.streamId && !stream.exhausted && Date.parse(state.timestamp) >= stream.sampleNotBefore && !state.invalidated && !this.unavailableHardware.has(state.controllerId) && !this.unavailableConfiguration.has(state.controllerId)
63200
+ };
63201
+ }
63202
+ payload(state) {
63203
+ const payload = { ...state };
63204
+ delete payload.invalidated;
63205
+ return { ...payload, ...this.freshness(state) };
63206
+ }
63207
+ async dispatch() {
63208
+ this.dispatching = true;
63209
+ while (this.dispatches.length) {
63210
+ const dispatch = this.dispatches.shift();
63211
+ if (!dispatch) continue;
63212
+ const { state, previous } = dispatch;
63213
+ const stream = this.streams.get(state.controllerId);
63214
+ if (stream?.active !== state.streamId || stream.exhausted) continue;
63215
+ try {
63216
+ await this.context.flows.trigger(
63217
+ WAGO_EVENT_NODE_TYPE,
63218
+ (config, nodeId) => this.matchesEvent(config, nodeId, state, previous),
63219
+ { wago: this.payload(state) }
63220
+ );
63221
+ } catch (error) {
63222
+ this.context.logger.warn(`Could not trigger WAGO flows: ${String(error)}`);
63223
+ }
63224
+ }
63225
+ this.dispatching = false;
63226
+ }
63227
+ };
63228
+ WagoFlowService = __decorateClass([
63229
+ (0, import_common13.Injectable)(),
63230
+ __decorateParam(0, (0, import_common13.Inject)(PLUGIN_CONTEXT5))
63231
+ ], WagoFlowService);
63232
+
63233
+ // backend/plugin.ts
63234
+ var PLUGIN_CONTEXT6 = /* @__PURE__ */ Symbol.for("attraccess.plugin.context");
62666
63235
  var WagoPluginModule = class {
62667
63236
  };
63237
+ var flowServices = /* @__PURE__ */ new WeakMap();
63238
+ function services(context) {
63239
+ const result = flowServices.get(context);
63240
+ if (!result) throw new Error("WAGO flow services are not initialized.");
63241
+ return result;
63242
+ }
62668
63243
  var plugin = {
62669
63244
  entities: [
62670
63245
  WagoController,
@@ -62677,14 +63252,31 @@ var plugin = {
62677
63252
  WagoManagementEntity,
62678
63253
  WagoCommissioningLeaseEntity
62679
63254
  ],
62680
- flowNodes: (context) => [createWagoCommandNode(context)],
63255
+ flowNodes: (context) => [
63256
+ createWagoCommandNode(() => services(context).command),
63257
+ ...createWagoStateNodes(() => services(context).state)
63258
+ ],
62681
63259
  register(context) {
62682
63260
  return {
62683
63261
  module: WagoPluginModule,
62684
63262
  controllers: [WagoControllerApi, WagoArtifactsController, WagoDiagnosticsController],
62685
63263
  providers: [
62686
- { provide: PLUGIN_CONTEXT5, useValue: context },
63264
+ { provide: PLUGIN_CONTEXT6, useValue: context },
62687
63265
  WagoService,
63266
+ WagoFlowService,
63267
+ {
63268
+ provide: "wago-flow-services",
63269
+ inject: [WagoService, WagoFlowService],
63270
+ useFactory: (command, state) => {
63271
+ const bound = { command, state };
63272
+ flowServices.set(context, bound);
63273
+ return {
63274
+ onModuleDestroy: () => {
63275
+ if (flowServices.get(context) === bound) flowServices.delete(context);
63276
+ }
63277
+ };
63278
+ }
63279
+ },
62688
63280
  WagoRuntimeArtifactsService,
62689
63281
  WagoArtifactUploadInterceptor,
62690
63282
  WagoCommissioningReadiness,