feat(templateengine): flatten native alarm sources (inherit/compose/override)

This commit is contained in:
Joseph Doherty
2026-05-29 16:00:10 -04:00
parent fc05ba1f6a
commit e5392d2c7b
2 changed files with 173 additions and 0 deletions
@@ -96,6 +96,13 @@ public class FlatteningService
// Step 7: Resolve alarm on-trigger script references to canonical names
ResolveAlarmScriptReferences(alarms, alarmScriptIds, scriptCanonicalById);
// Step 7b: Resolve native alarm source bindings (connection + source
// reference). Conditions under each source are discovered at runtime;
// flattening only resolves the binding through inherit/compose/override.
var nativeAlarmSources = ResolveInheritedNativeAlarmSources(templateChain, prefix: null);
ResolveComposedNativeAlarmSources(templateChain, compositionMap, composedTemplateChains, nativeAlarmSources);
ApplyInstanceNativeAlarmSourceOverrides(instance.NativeAlarmSourceOverrides, nativeAlarmSources);
// Step 8: Collect connection configurations for deployment packaging
var connections = new Dictionary<string, ConnectionConfig>();
foreach (var attr in attributes.Values)
@@ -125,6 +132,7 @@ public class FlatteningService
AreaId = instance.AreaId,
Attributes = attributes.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
Alarms = alarms.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
NativeAlarmSources = nativeAlarmSources.Values.OrderBy(s => s.CanonicalName, StringComparer.Ordinal).ToList(),
Scripts = scripts.Values.OrderBy(s => s.CanonicalName, StringComparer.Ordinal).ToList(),
Connections = connections.Count > 0 ? connections : null,
GeneratedAtUtc = DateTimeOffset.UtcNow
@@ -649,6 +657,137 @@ public class FlatteningService
}
}
/// <summary>
/// Resolves native alarm source bindings from an inheritance chain. Keys of
/// the returned dictionary are bare binding names (caller path-qualifies for
/// composed modules). Derived templates win unless the base binding is
/// locked; <see cref="TemplateNativeAlarmSource.IsInherited"/> placeholders
/// never shadow a live base binding.
/// </summary>
private static Dictionary<string, ResolvedNativeAlarmSource> ResolveInheritedNativeAlarmSources(
IReadOnlyList<Template> templateChain,
string? prefix)
{
var result = new Dictionary<string, ResolvedNativeAlarmSource>(StringComparer.Ordinal);
// Tracks bindings the base locked, so derived templates cannot override them.
var lockedNames = new HashSet<string>(StringComparer.Ordinal);
for (int i = templateChain.Count - 1; i >= 0; i--)
{
var template = templateChain[i];
var source = i == 0 ? "Template" : "Inherited";
foreach (var binding in template.NativeAlarmSources)
{
if (result.ContainsKey(binding.Name))
{
if (lockedNames.Contains(binding.Name))
continue;
// IsInherited rows on a derived template are placeholders that
// must not shadow the live base binding.
if (binding.IsInherited)
continue;
}
result[binding.Name] = new ResolvedNativeAlarmSource
{
CanonicalName = binding.Name,
ConnectionName = binding.ConnectionName,
SourceReference = binding.SourceReference,
ConditionFilter = binding.ConditionFilter,
Source = source
};
if (binding.IsLocked)
lockedNames.Add(binding.Name);
}
}
return result;
}
private static void ResolveComposedNativeAlarmSources(
IReadOnlyList<Template> templateChain,
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
Dictionary<string, ResolvedNativeAlarmSource> sources)
{
foreach (var template in templateChain)
{
if (!compositionMap.TryGetValue(template.Id, out var compositions))
continue;
foreach (var composition in compositions)
ResolveComposedNativeAlarmSourcesRecursive(
composition, composition.InstanceName,
compositionMap, composedTemplateChains, sources, new HashSet<int>());
}
}
/// <summary>
/// Recursively resolves native alarm source bindings of a composed module
/// and nested modules, path-qualifying each canonical name with the
/// accumulated <paramref name="prefix"/>. The source reference is a raw
/// connection address, so (unlike alarm trigger configs) it is not rewritten.
/// </summary>
private static void ResolveComposedNativeAlarmSourcesRecursive(
TemplateComposition composition,
string prefix,
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
Dictionary<string, ResolvedNativeAlarmSource> sources,
HashSet<int> visited)
{
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
return;
var composedSources = ResolveInheritedNativeAlarmSources(composedChain, prefix);
foreach (var (name, binding) in composedSources)
{
var canonicalName = $"{prefix}.{name}";
if (!sources.ContainsKey(canonicalName))
{
sources[canonicalName] = binding with
{
CanonicalName = canonicalName,
Source = "Composed"
};
}
}
foreach (var composedTemplate in composedChain)
{
if (!visited.Add(composedTemplate.Id))
continue;
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
continue;
foreach (var nested in nestedCompositions)
ResolveComposedNativeAlarmSourcesRecursive(
nested, $"{prefix}.{nested.InstanceName}",
compositionMap, composedTemplateChains, sources, visited);
}
}
private static void ApplyInstanceNativeAlarmSourceOverrides(
ICollection<InstanceNativeAlarmSourceOverride> overrides,
Dictionary<string, ResolvedNativeAlarmSource> sources)
{
foreach (var ovr in overrides)
{
if (!sources.TryGetValue(ovr.SourceCanonicalName, out var existing))
continue; // Cannot add new bindings via overrides.
sources[ovr.SourceCanonicalName] = existing with
{
ConnectionName = ovr.ConnectionNameOverride ?? existing.ConnectionName,
SourceReference = ovr.SourceReferenceOverride ?? existing.SourceReference,
ConditionFilter = ovr.ConditionFilterOverride ?? existing.ConditionFilter,
Source = "Override"
};
}
}
/// <summary>
/// Resolves scripts from an inheritance chain. The returned dictionary is
/// keyed by bare script name. <paramref name="scriptCanonicalById"/> is