fix(virtualtags): consume VirtualTag.TimerIntervalMs; add VirtualTagNoEffectiveTrigger gate (lmxopcua#783)
v2-ci / unit-tests (push) Waiting to run
v2-ci / unit-tests (push) Waiting to run
Choice: implement the timer (option a). The field was designed and promised as a working trigger: the entity doc says ChangeTriggered and TimerIntervalMs together realize the trigger model, the DB check constraint requires ChangeTriggered OR a timer >= 50, the AdminUI virtual-tag modal exposes "TimerIntervalMs (optional)" and UnsTreeService rejects a tag with neither, and the 2026-06-08 design specified "ChangeTriggered OR TimerIntervalMs>=50". Only the runtime never honoured it (the standalone TimerTriggerScheduler was deleted in #603), so a timer-only tag sat at BadWaitingForInitialData forever. - EquipmentVirtualTagPlan carries ChangeTriggered/TimerIntervalMs (in equality, so an edit respawns the child); both the composer and the artifact decode fill them. - VirtualTagActor schedules a repeating tick (PreStart, cancelled in PostStop, clamped to the 50 ms floor). ChangeTriggered=false makes the timer the only trigger. A tick during an in-flight async run (#779) is dropped, not stashed, so slow scripts never build a backlog of runs. - DraftValidator VirtualTagNoEffectiveTrigger (hard error, same severity as CalculationNoEffectiveTrigger): no timer and no change trigger with a literal ctx.GetTag read. Tests: VirtualTagActorTimerTests, DraftValidatorVirtualTagTriggerTests, artifact decode test. Mutation-checked: no scheduling, stashing ticks, no cancel in PostStop, and removing the validator call each fail a test. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -1416,6 +1416,12 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
|
||||
&& hEl.GetBoolean();
|
||||
|
||||
// Triggers (lmxopcua#783): entity defaults when absent/malformed — change-triggered, no timer.
|
||||
var changeTriggered = !(el.TryGetProperty("ChangeTriggered", out var ctEl)
|
||||
&& ctEl.ValueKind == JsonValueKind.False);
|
||||
int? timerMs = el.TryGetProperty("TimerIntervalMs", out var tmEl)
|
||||
&& tmEl.ValueKind == JsonValueKind.Number && tmEl.TryGetInt32(out var tmv) ? tmv : null;
|
||||
|
||||
// Substitute each {{equip}}/<RefName> with the owning equipment's backing RawPath BEFORE
|
||||
// extracting refs, so both Expression and DependencyRefs are machine-specific — byte-parity
|
||||
// with AddressSpaceComposer.Compose.
|
||||
@@ -1430,7 +1436,9 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
DataType: dataType ?? "BaseDataType",
|
||||
Expression: expanded,
|
||||
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
|
||||
Historize: historize));
|
||||
Historize: historize,
|
||||
ChangeTriggered: changeTriggered,
|
||||
TimerIntervalMs: timerMs));
|
||||
}
|
||||
|
||||
result.Sort((a, b) =>
|
||||
|
||||
Reference in New Issue
Block a user