From fbf08d3b15d8ad2d5b6549ae5629410e27b94ac8 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 21:05:22 +0700 Subject: [PATCH 01/12] feat: add demo mode activation and ten IED workspace --- MainWindow.Demo.cs | 193 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 MainWindow.Demo.cs diff --git a/MainWindow.Demo.cs b/MainWindow.Demo.cs new file mode 100644 index 000000000..136b8361b --- /dev/null +++ b/MainWindow.Demo.cs @@ -0,0 +1,193 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Input; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private const string DemoShortcutText = "Ctrl+Shift+D"; + private readonly DispatcherTimer _demoTimer = new(DispatcherPriority.Background); + private readonly Random _demoRandom = new(61850); + private readonly List _demoPointStates = new(); + private readonly List _demoGooseStates = new(); + private bool _isDemoMode; + private int _demoTick; + private long _demoEventSequence = 5000; + + public bool IsDemoMode => _isDemoMode; + public Visibility DemoModeVisibility => _isDemoMode ? Visibility.Visible : Visibility.Collapsed; + public string DemoModeText => $"DEMO MODE • {DemoShortcutText}"; + + private void MainWindow_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (Keyboard.Modifiers != (ModifierKeys.Control | ModifierKeys.Shift) || e.Key != Key.D) + return; + + e.Handled = true; + ToggleDemoMode(); + } + + private void ToggleDemoMode() + { + if (_isDemoMode) + { + DeactivateDemoMode(); + return; + } + + if (Devices.Any(device => device.IsConnected || device.IsMonitoring || device.IsBusy) || IsGooseCapturing) + { + MessageBox.Show( + this, + "Disconnect every live IED and stop GOOSE capture before entering Demo Mode.", + "Demo Mode", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + if (Devices.Count > 0 || GlobalPoints.Count > 0 || Events.Count > 0) + { + var choice = MessageBox.Show( + this, + "Demo Mode replaces the current offline workspace with synthetic substation data. Save the current project first if it must be retained.\n\nContinue?", + "Load Demo Workspace", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (choice != MessageBoxResult.Yes) + return; + } + + ActivateDemoMode(); + } + + private void ActivateDemoMode() + { + ClearWorkspaceForDemo(); + _isDemoMode = true; + _demoTick = 0; + _demoEventSequence = 5000; + Title = "ArIED 61850 — Synthetic Substation Demo"; + + InstallGoosePresentationWorkspace(); + BuildDemoDevicesAndLivePoints(); + BuildDemoEventHistory(); + BuildDemoGooseWorkspace(); + BuildDemoDiagnostics(); + + SelectedDevice = Devices.FirstOrDefault(); + MainTabs.SelectedIndex = 0; + UpdateNavigationVisuals(0, animate: false); + + _demoTimer.Stop(); + _demoTimer.Interval = TimeSpan.FromMilliseconds(1100); + _demoTimer.Tick -= DemoTimer_Tick; + _demoTimer.Tick += DemoTimer_Tick; + _demoTimer.Start(); + + Raise(nameof(IsDemoMode)); + Raise(nameof(DemoModeVisibility)); + Raise(nameof(DemoModeText)); + RaiseWorkspaceCounts(); + SetStatus($"DEMO MODE active • 10 synthetic IEDs • {GlobalPoints.Count:N0} live values • {GooseStreams.Count:N0} GOOSE publishers • press {DemoShortcutText} to exit."); + } + + private void DeactivateDemoMode() + { + _demoTimer.Stop(); + IsGooseCapturing = false; + _isDemoMode = false; + Title = "ArIED 61850 — Smart IED Explorer & Monitor"; + ClearWorkspaceForDemo(); + AddLog("INFO", "Demo", "Synthetic substation demo closed. No network sessions were created."); + Raise(nameof(IsDemoMode)); + Raise(nameof(DemoModeVisibility)); + Raise(nameof(DemoModeText)); + RaiseWorkspaceCounts(); + SetStatus($"Demo workspace cleared. Press {DemoShortcutText} to load it again."); + } + + private void ClearWorkspaceForDemo() + { + _demoTimer.Stop(); + SelectedDevice = null; + + foreach (var device in Devices) + DetachSignalHandlers(device.Signals); + + Devices.Clear(); + GlobalPoints.Clear(); + Events.Clear(); + Logs.Clear(); + _signalOwners.Clear(); + _pointIndex.Clear(); + _controlFeedbackIndex.Clear(); + _pendingPointSnapshots.Clear(); + _reportPulseUntil.Clear(); + _pointHighlightUntil.Clear(); + while (_pendingEvents.TryDequeue(out _)) { } + while (_pendingDiagnostics.TryDequeue(out _)) { } + + _demoPointStates.Clear(); + _demoGooseStates.Clear(); + ResetGooseView(resetCounters: true); + ResetGooseTimelineUi(); + GooseAdapters.Clear(); + SelectedGooseAdapter = null; + } + + private void BuildDemoDevicesAndLivePoints() + { + var deviceSpecs = new[] + { + new DemoDeviceSpec("BCU_INCOMER_150KV", "BCU • 150 kV line incomer", "192.168.10.11", "3 LD • 29 LN • 186 DO • 1,042 DA", DemoDeviceRole.Bcu), + new DemoDeviceSpec("BCU_TRAFO_BAY_TR1", "BCU • transformer HV bay", "192.168.10.12", "3 LD • 31 LN • 194 DO • 1,108 DA", DemoDeviceRole.Bcu), + new DemoDeviceSpec("PROT_OCR_INCOMER_20KV", "OCR/GFR • 20 kV incomer", "192.168.10.21", "2 LD • 26 LN • 171 DO • 936 DA", DemoDeviceRole.Ocr), + new DemoDeviceSpec("PROT_OCR_FEEDER_F01", "OCR/GFR • 20 kV feeder", "192.168.10.22", "2 LD • 24 LN • 158 DO • 874 DA", DemoDeviceRole.Ocr), + new DemoDeviceSpec("PROT_LINE_DIFF_L01", "87L line differential", "192.168.10.31", "3 LD • 34 LN • 221 DO • 1,294 DA", DemoDeviceRole.LineDiff), + new DemoDeviceSpec("PROT_DISTANCE_L02", "21 distance protection", "192.168.10.32", "3 LD • 33 LN • 216 DO • 1,247 DA", DemoDeviceRole.Distance), + new DemoDeviceSpec("PROT_TRAFO_DIFF_TR1", "87T transformer differential", "192.168.10.41", "3 LD • 38 LN • 248 DO • 1,462 DA", DemoDeviceRole.TrafoDiff), + new DemoDeviceSpec("PROT_BUSBAR_DIFF_BB1", "87B busbar differential", "192.168.10.51", "4 LD • 46 LN • 312 DO • 1,856 DA", DemoDeviceRole.BusbarDiff), + new DemoDeviceSpec("PROT_CAPBANK_CB1", "capacitor-bank protection", "192.168.10.61", "2 LD • 25 LN • 164 DO • 902 DA", DemoDeviceRole.CapBank), + new DemoDeviceSpec("BCU_BUS_COUPLER_150KV", "BCU • 150 kV bus coupler", "192.168.10.71", "3 LD • 30 LN • 191 DO • 1,081 DA", DemoDeviceRole.Coupler) + }; + + for (var deviceIndex = 0; deviceIndex < deviceSpecs.Length; deviceIndex++) + { + var spec = deviceSpecs[deviceIndex]; + var device = new Iec61850MonitorDevice + { + DeviceId = $"demo-{deviceIndex + 1:00}-{spec.Name.ToLowerInvariant()}", + Name = spec.Name, + IdentitySource = $"DEMO • live MMS discovery • {spec.Description}", + LogicalDeviceSummary = spec.ModelSummary, + IpAddress = spec.IpAddress, + Port = 102, + Status = "Monitoring • BRCB active", + Detail = $"Synthetic {spec.Description} session. Buffered reports, MMS validation reads and GOOSE bindings are generated locally; no packets are transmitted.", + AcquisitionMode = deviceIndex % 3 == 0 + ? "Buffered Report • dchg/qchg/dupd" + : deviceIndex % 3 == 1 + ? "Unbuffered Report • dchg + integrity" + : "Static DataSet report • MMS verification", + HasDiscoveryCache = true, + IsConnected = true, + IsMonitoring = true, + HasReportStream = true, + IsDemo = true + }; + + var seeds = BuildDemoSignalSeeds(spec.Role, deviceIndex); + foreach (var seed in seeds) + AddDemoPoint(device, seed, deviceIndex); + + device.RecountSelectedSignals(); + device.AddUnreadEvents(1 + deviceIndex % 4); + Devices.Add(device); + } + } +} From 8864e6db4b0ea7fce61df25ae30a4ce2c50c83c1 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 21:06:44 +0700 Subject: [PATCH 02/12] feat: add realistic protection and measurement demo signals --- MainWindow.DemoSignals.cs | 175 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 MainWindow.DemoSignals.cs diff --git a/MainWindow.DemoSignals.cs b/MainWindow.DemoSignals.cs new file mode 100644 index 000000000..fd5687316 --- /dev/null +++ b/MainWindow.DemoSignals.cs @@ -0,0 +1,175 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Input; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private IReadOnlyList BuildDemoSignalSeeds(DemoDeviceRole role, int index) + { + var common = new List + { + Position("Breaker position", "XCBR1.Pos.stVal", index % 4 == 3 ? "Open [01]" : "Closed [10]"), + Boolean("Trip circuit healthy", "GGIO1.Ind1.stVal", true, eventEligible: false), + Boolean("Protection enabled", "LLN0.Mod.stVal", true, eventEligible: false), + State("Control authority", "CSWI1.Loc.stVal", new[] { "Remote", "Local" }, "Remote", eventEligible: false), + Analog("Phase A current", "MMXU1.A.phsA.cVal.mag.f", "A", 620 + index * 38, 34), + Analog("Phase B current", "MMXU1.A.phsB.cVal.mag.f", "A", 608 + index * 36, 32), + Analog("Phase C current", "MMXU1.A.phsC.cVal.mag.f", "A", 631 + index * 35, 36), + Analog("Bus voltage", "MMXU1.PhV.phsA.cVal.mag.f", "kV", index is 2 or 3 or 8 ? 20.4 : 149.7, index is 2 or 3 or 8 ? 0.22 : 0.55) + }; + + common.AddRange(role switch + { + DemoDeviceRole.Bcu => new[] + { + Boolean("Open interlock release", "CILO1.EnaOpn.stVal", true, false), + Boolean("Close interlock release", "CILO1.EnaCls.stVal", true, false), + Counter("Breaker operation count", "XCBR1.OpCnt.stVal", 184 + index * 11), + Boolean("Bay alarm summary", "GGIO1.Alm1.stVal", false, true) + }, + DemoDeviceRole.Ocr => new[] + { + Boolean("Overcurrent stage 1 pickup", "PTOC1.Str.general", false, true), + Boolean("Overcurrent stage 2 trip", "PTOC2.Op.general", false, true), + Boolean("Earth-fault pickup", "PTOC3.Str.general", false, true), + Boolean("Master trip", "PTRC1.Tr.general", false, true) + }, + DemoDeviceRole.LineDiff => new[] + { + Boolean("Line differential pickup", "PDIF1.Str.general", false, true), + Boolean("Line differential trip", "PDIF1.Op.general", false, true), + Boolean("Teleprotection receive", "PSCH1.Op.general", false, true), + Analog("Differential current", "PDIF1.Idiff.mag.f", "A", 0.18, 0.08) + }, + DemoDeviceRole.Distance => new[] + { + Boolean("Distance zone 1 pickup", "PDIS1.Str.general", false, true), + Boolean("Distance zone 2 pickup", "PDIS2.Str.general", false, true), + Boolean("Power swing blocking", "RPSB1.BlkZn.general", false, true), + Analog("Positive-sequence impedance", "MMXU2.Z.phsA.cVal.mag.f", "Ω", 38.6, 0.9) + }, + DemoDeviceRole.TrafoDiff => new[] + { + Boolean("Transformer differential pickup", "PDIF1.Str.general", false, true), + Boolean("Transformer differential trip", "PDIF1.Op.general", false, true), + Boolean("Inrush restraint", "PHAR1.Str.general", false, true), + Analog("Winding temperature", "STMP1.Tmp.mag.f", "°C", 57.8, 1.7) + }, + DemoDeviceRole.BusbarDiff => new[] + { + Boolean("Busbar differential pickup", "PDIF1.Str.general", false, true), + Boolean("Busbar zone 1 trip", "PDIF1.Op.general", false, true), + Boolean("Breaker-failure initiate", "RBRF1.OpEx.general", false, true), + Analog("Zone differential current", "PDIF1.Idiff.mag.f", "A", 0.11, 0.06) + }, + DemoDeviceRole.CapBank => new[] + { + Boolean("Capacitor unbalance pickup", "PTOC1.Str.general", false, true), + Boolean("Overvoltage trip", "PTOV1.Op.general", false, true), + Boolean("Undervoltage blocking", "PTUV1.Str.general", false, true), + Analog("Reactive power", "MMXU1.VAr.threePh.cVal.mag.f", "MVAr", 18.4, 0.8) + }, + DemoDeviceRole.Coupler => new[] + { + Boolean("Synchrocheck release", "RSYN1.Rel.stVal", true, false), + Boolean("Bus 1 voltage healthy", "GGIO1.Ind2.stVal", true, false), + Boolean("Bus 2 voltage healthy", "GGIO1.Ind3.stVal", true, false), + Analog("Phase-angle difference", "RSYN1.DifAng.mag.f", "°", 1.8, 0.6) + }, + _ => Array.Empty() + }); + + return common; + } + + private void AddDemoPoint(Iec61850MonitorDevice device, DemoSignalSeed seed, int deviceIndex) + { + var reference = $"{device.Name}LD0/{seed.Path}"; + var sidecarRoot = BuildSidecarRoot(reference); + var dataSet = $"{device.Name}LD0/LLN0$DataSet$dsOperational"; + var rcb = $"{device.Name}LD0/LLN0$BR$brcbOperational01"; + var timestamp = DateTime.Now.AddMilliseconds(-_demoRandom.Next(20, 780)); + + var signal = new SignalDefinition + { + Name = seed.Name, + ObjectReference = reference, + DisplayReference = Iec61850MonitorPoint.StripIedNamePrefix(reference, device.Name), + FunctionalConstraint = seed.FunctionalConstraint, + DataType = seed.DataType, + Category = seed.Category, + Unit = seed.Unit, + Confidence = "High", + DataSetReference = dataSet, + ReportControlReference = rcb, + ReportCoverageReason = "Configured operational DataSet with active report control block.", + QualityReference = sidecarRoot + ".q", + TimestampReference = sidecarRoot + ".t", + Source = "DEMO • live discovery", + IsSelected = true, + IsReportCapable = true, + ReportCoverage = deviceIndex % 2 == 0 ? "Buffered Report • dchg/qchg" : "Unbuffered Report • dchg/dupd", + Value = seed.InitialValue, + Quality = "Good • validity=good", + DeviceTimestamp = timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture), + ProbeStatus = "Read OK", + Timestamp = timestamp + }; + + var point = new Iec61850MonitorPoint + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + IpAddress = device.IpAddress, + SignalName = seed.Name, + IecReference = reference, + QualityReference = sidecarRoot + ".q", + TimestampReference = sidecarRoot + ".t", + FunctionalConstraint = seed.FunctionalConstraint, + IecDataType = seed.DataType, + Category = seed.Category, + Unit = seed.Unit, + DataSetReference = dataSet, + ReportControlReference = rcb, + PollingIntervalMs = 1000, + Value = seed.InitialValue, + Quality = "Good • validity=good", + DeviceTimestamp = timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture), + SourceMode = deviceIndex % 2 == 0 ? "Buffered Report • dchg" : "Unbuffered Report • dupd", + Reason = seed.EventEligible ? "dchg" : "integrity", + Status = "Live", + Sequence = 1000 + _demoPointStates.Count + }; + + signal.PropertyChanged += Signal_PropertyChanged; + _signalOwners[signal] = device; + device.Signals.Add(signal); + device.Points.Add(point); + GlobalPoints.Add(point); + _pointIndex[point.PointKey] = point; + _demoPointStates.Add(new DemoPointState(device, signal, point, seed)); + } + + private void BuildDemoEventHistory() + { + var candidates = _demoPointStates.Where(state => state.Seed.EventEligible).ToArray(); + var events = new List(); + var now = DateTime.Now; + + for (var index = 0; index < 54; index++) + { + var state = candidates[index % candidates.Length]; + var values = state.Seed.DiscreteValues ?? new[] { "false", "true" }; + var oldValue = values[index % values.Length]; + var newValue = values[(index + 1) % values.Length]; + var eventTime = now.AddSeconds(-(54 - index) * 17 - _demoRandom.Next(0, 8)); + events.Add(CreateDemoEvent(state, oldValue, newValue, eventTime)); + } + + Events.AddRange(events.OrderBy(item => item.DeviceTimestamp, StringComparer.Ordinal)); + } +} From 5484c3f1aaebd08ce9b6703e43acf01f279c3e14 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 21:07:31 +0700 Subject: [PATCH 03/12] feat: add synthetic GOOSE publishers and diagnostic history --- MainWindow.DemoGooseData.cs | 122 ++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 MainWindow.DemoGooseData.cs diff --git a/MainWindow.DemoGooseData.cs b/MainWindow.DemoGooseData.cs new file mode 100644 index 000000000..c348c3c45 --- /dev/null +++ b/MainWindow.DemoGooseData.cs @@ -0,0 +1,122 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Input; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private void BuildDemoGooseWorkspace() + { + GooseAdapters.ReplaceAll(new[] + { + new GooseAdapterOption + { + Index = 1, + Name = "demo://station-bus", + FriendlyName = "Station Bus VLAN 100 (Synthetic)", + Description = "ArIED built-in demonstration adapter", + MacAddress = "02:00:5E:61:85:00" + } + }); + SelectedGooseAdapter = GooseAdapters[0]; + IsGooseCapturing = true; + GooseBindingText = "DEMO model binding • 6 publishers • ordered DataSet leaves resolved from synthetic IED models"; + GooseStatusText = "DEMO • synthetic read-only GOOSE capture is running; no Ethernet frames are transmitted or received."; + + var streamSpecs = new[] + { + DemoGooseSpec("gcb-incomer", "BCU_INCOMER_150KV", "0x1001", "01:0C:CD:01:00:01", 100, "gcbTripStatus", "dsTripStatus", + ("Breaker position", "XCBR1.Pos.stVal", "DPC / Dbpos", "Closed [10]"), + ("Master trip", "PTRC1.Tr.general", "ACT / BOOLEAN", "false"), + ("Close interlock release", "CILO1.EnaCls.stVal", "SPS / BOOLEAN", "true"), + ("Control authority", "CSWI1.Loc.stVal", "ENS / INT32", "Remote")), + DemoGooseSpec("gcb-linediff", "PROT_LINE_DIFF_L01", "0x1101", "01:0C:CD:01:01:01", 110, "gcbLineProtection", "dsLineProtection", + ("Line differential pickup", "PDIF1.Str.general", "ACD / BOOLEAN", "false"), + ("Line differential trip", "PDIF1.Op.general", "ACT / BOOLEAN", "false"), + ("Teleprotection receive", "PSCH1.Op.general", "ACT / BOOLEAN", "false"), + ("Breaker position", "XCBR1.Pos.stVal", "DPC / Dbpos", "Closed [10]")), + DemoGooseSpec("gcb-trafodiff", "PROT_TRAFO_DIFF_TR1", "0x1201", "01:0C:CD:01:02:01", 120, "gcbTransformerTrip", "dsTransformerTrip", + ("Transformer differential pickup", "PDIF1.Str.general", "ACD / BOOLEAN", "false"), + ("Transformer differential trip", "PDIF1.Op.general", "ACT / BOOLEAN", "false"), + ("Inrush restraint", "PHAR1.Str.general", "ACD / BOOLEAN", "false"), + ("HV breaker position", "XCBR1.Pos.stVal", "DPC / Dbpos", "Closed [10]")), + DemoGooseSpec("gcb-busbar", "PROT_BUSBAR_DIFF_BB1", "0x1301", "01:0C:CD:01:03:01", 130, "gcbBusbarTrip", "dsBusbarTrip", + ("Busbar differential pickup", "PDIF1.Str.general", "ACD / BOOLEAN", "false"), + ("Busbar zone 1 trip", "PDIF1.Op.general", "ACT / BOOLEAN", "false"), + ("Breaker-failure initiate", "RBRF1.OpEx.general", "ACT / BOOLEAN", "false"), + ("Zone healthy", "GGIO1.Ind1.stVal", "SPS / BOOLEAN", "true")), + DemoGooseSpec("gcb-coupler", "BCU_BUS_COUPLER_150KV", "0x1401", "01:0C:CD:01:04:01", 140, "gcbCouplerInterlock", "dsCouplerInterlock", + ("Coupler position", "XCBR1.Pos.stVal", "DPC / Dbpos", "Open [01]"), + ("Synchrocheck release", "RSYN1.Rel.stVal", "SPS / BOOLEAN", "true"), + ("Bus 1 voltage healthy", "GGIO1.Ind2.stVal", "SPS / BOOLEAN", "true"), + ("Bus 2 voltage healthy", "GGIO1.Ind3.stVal", "SPS / BOOLEAN", "true")), + DemoGooseSpec("gcb-feeder", "PROT_OCR_FEEDER_F01", "0x1501", "01:0C:CD:01:05:01", 150, "gcbFeederProtection", "dsFeederProtection", + ("Overcurrent pickup", "PTOC1.Str.general", "ACD / BOOLEAN", "false"), + ("Earth-fault pickup", "PTOC3.Str.general", "ACD / BOOLEAN", "false"), + ("Master trip", "PTRC1.Tr.general", "ACT / BOOLEAN", "false"), + ("Breaker position", "XCBR1.Pos.stVal", "DPC / Dbpos", "Closed [10]")) + }; + + var baseTime = DateTimeOffset.Now.AddMinutes(-4); + for (var index = 0; index < streamSpecs.Length; index++) + { + var state = new DemoGooseStreamState(streamSpecs[index], stateNumber: 12 + index * 3, sequenceNumber: 510 + index * 71, packetCount: 8200 + index * 1260); + var row = new GooseStreamRow { StreamKey = state.Spec.StreamKey }; + state.Row = row; + row.Apply(BuildDemoGooseSnapshot(state, changedIndex: -1, diagnostics: string.Empty, baseTime.AddSeconds(index * 4))); + GooseStreams.Add(row); + _gooseStreamIndex[row.StreamKey] = row; + _demoGooseStates.Add(state); + } + + for (var index = 0; index < 30; index++) + { + var state = _demoGooseStates[index % _demoGooseStates.Count]; + var timestamp = baseTime.AddSeconds(index * 8.2); + var leaf = state.Leaves[index % state.Leaves.Count]; + GooseEvents.Add(new GooseEventRow + { + StreamKey = state.Spec.StreamKey, + Timestamp = timestamp, + DeltaText = index < _demoGooseStates.Count ? "-" : "8.200 s", + EventText = index % 11 == 0 ? "Warning" : "State change", + EventTone = index % 11 == 0 ? "Warning" : "Change", + Publisher = state.Spec.IedName, + StateSequenceText = $"{state.StateNumber + index / _demoGooseStates.Count} / 0", + Summary = index % 11 == 0 + ? "TAL supervision recovered after one delayed retransmission" + : $"{leaf.Name}: {ToggleDemoValue(leaf.Value)}" + }); + } + + SelectedGooseStream = GooseStreams.FirstOrDefault(); + SelectedGooseEvent = GooseEvents.LastOrDefault(); + GooseCapturedFrames = _demoGooseStates.Sum(state => state.PacketCount) + 412; + GooseFrames = GooseCapturedFrames; + GooseOtherFrames = 0; + Raise(nameof(GooseCounterText)); + Raise(nameof(GooseNoStreamsVisibility)); + Raise(nameof(GooseNoLeafValuesVisibility)); + RaiseGoosePresentationState(); + } + + private void BuildDemoDiagnostics() + { + var now = DateTime.Now; + var logs = new List + { + new() { Time = now.AddMinutes(-8), Level = "INFO", Source = "Demo", Message = "Synthetic substation workspace initialized. Network transport is disabled." }, + new() { Time = now.AddMinutes(-7.8), Level = "INFO", Source = "MMS", Message = "10 independent IEC 61850 associations established on TCP/102 (synthetic)." }, + new() { Time = now.AddMinutes(-7.6), Level = "INFO", Source = "Discovery", Message = "IEDName, LD/LN/DO/DA, DataSets, RCBs and GSEControl models resolved for all demo IEDs." }, + new() { Time = now.AddMinutes(-7.4), Level = "INFO", Source = "Reporting", Message = "6 BRCB and 4 URCB sessions enabled; dchg/qchg/dupd and integrity reasons validated." }, + new() { Time = now.AddMinutes(-7.2), Level = "INFO", Source = "GOOSE", Message = "6 station-bus publishers bound to ordered DataSet leaves on VLANs 100-150." }, + new() { Time = now.AddMinutes(-4.1), Level = "WARN", Source = "GOOSE", Message = "One delayed retransmission exceeded the expected interval; TAL supervision recovered without state loss." }, + new() { Time = now.AddMinutes(-3.9), Level = "INFO", Source = "GOOSE", Message = "Publisher sequence returned to normal; stNum and sqNum continuity verified." }, + new() { Time = now.AddMinutes(-1.2), Level = "INFO", Source = "Reporting", Message = "Periodic integrity report completed for PROT_TRAFO_DIFF_TR1; 12 values confirmed good." } + }; + Logs.AddRange(logs); + } +} From fadb7317c14c0e80f3b00781f60beb3d6fda78bd Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 21:08:13 +0700 Subject: [PATCH 04/12] feat: animate demo live values and sequence of events --- MainWindow.DemoProcessRuntime.cs | 110 +++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 MainWindow.DemoProcessRuntime.cs diff --git a/MainWindow.DemoProcessRuntime.cs b/MainWindow.DemoProcessRuntime.cs new file mode 100644 index 000000000..578a5ac8a --- /dev/null +++ b/MainWindow.DemoProcessRuntime.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Input; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private void DemoTimer_Tick(object? sender, EventArgs e) + { + if (!_isDemoMode || _shutdownStarted) + return; + + _demoTick++; + UpdateDemoAnalogValues(); + if (_demoTick % 3 == 0) + GenerateDemoProcessEvent(); + UpdateDemoGooseTraffic(); + RaiseWorkspaceCounts(); + } + + private void UpdateDemoAnalogValues() + { + var analogStates = _demoPointStates.Where(state => state.Seed.Kind == DemoValueKind.Analog).ToArray(); + if (analogStates.Length == 0) + return; + + var updateCount = Math.Min(12, analogStates.Length); + for (var index = 0; index < updateCount; index++) + { + var state = analogStates[(_demoTick * 7 + index * 5) % analogStates.Length]; + var phase = (_demoTick + state.Point.Sequence % 17) * 0.21; + var noise = (_demoRandom.NextDouble() - 0.5) * state.Seed.Variation * 0.18; + var value = state.Seed.BaseValue + Math.Sin(phase) * state.Seed.Variation * 0.55 + noise; + var formatted = FormatDemoAnalog(value, state.Seed.Unit); + var timestamp = DateTime.Now.AddMilliseconds(-_demoRandom.Next(8, 95)); + + state.Point.Value = formatted; + state.Point.Quality = "Good • validity=good"; + state.Point.DeviceTimestamp = timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + state.Point.SourceMode = _demoTick % 5 == 0 ? "MMS validation read" : "Buffered Report • dupd"; + state.Point.Reason = _demoTick % 5 == 0 ? "verification" : "dupd"; + state.Point.Sequence++; + state.Signal.Value = formatted; + state.Signal.Quality = state.Point.Quality; + state.Signal.DeviceTimestamp = state.Point.DeviceTimestamp; + state.Signal.Timestamp = timestamp; + state.Device.ReportPulseActive = true; + _reportPulseUntil[state.Device.DeviceId] = DateTime.UtcNow.AddMilliseconds(520); + } + } + + private void GenerateDemoProcessEvent() + { + var candidates = _demoPointStates.Where(state => state.Seed.EventEligible && state.Seed.DiscreteValues is { Length: > 1 }).ToArray(); + if (candidates.Length == 0) + return; + + var state = candidates[(_demoTick / 3) % candidates.Length]; + var values = state.Seed.DiscreteValues!; + var currentIndex = Array.FindIndex(values, value => value.Equals(state.Point.Value, StringComparison.OrdinalIgnoreCase)); + if (currentIndex < 0) + currentIndex = 0; + var nextValue = values[(currentIndex + 1) % values.Length]; + var oldValue = state.Point.Value; + var timestamp = DateTime.Now.AddMilliseconds(-_demoRandom.Next(5, 45)); + + state.Point.Value = nextValue; + state.Point.Quality = "Good • validity=good"; + state.Point.DeviceTimestamp = timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + state.Point.SourceMode = "Buffered Report • dchg"; + state.Point.Reason = "dchg"; + state.Point.Sequence++; + state.Point.IsRecentlyChanged = true; + state.Signal.Value = nextValue; + state.Signal.Quality = state.Point.Quality; + state.Signal.DeviceTimestamp = state.Point.DeviceTimestamp; + state.Signal.Timestamp = timestamp; + state.Device.ReportPulseActive = true; + _reportPulseUntil[state.Device.DeviceId] = DateTime.UtcNow.AddMilliseconds(650); + _pointHighlightUntil[state.Point.PointKey] = DateTime.UtcNow.AddSeconds(3); + + var demoEvent = CreateDemoEvent(state, oldValue, nextValue, timestamp); + Events.AddRange(new[] { demoEvent }); + Events.TrimStart(10000); + if (MainTabs.SelectedIndex != 2) + state.Device.AddUnreadEvents(1); + LastStatusText = $"DEMO EVENT • {state.Device.Name} / {state.Point.SignalName} = {nextValue}"; + } + + private Iec61850EventEntry CreateDemoEvent(DemoPointState state, string oldValue, string newValue, DateTime timestamp) + => new() + { + Sequence = ++_demoEventSequence, + DeviceId = state.Device.DeviceId, + PointKey = state.Point.PointKey, + DeviceTimestamp = timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture), + DeviceName = state.Device.Name, + IpAddress = state.Device.IpAddress, + SignalName = state.Point.SignalName, + IecReference = state.Point.IecReference, + OldValue = oldValue, + NewValue = newValue, + Quality = "Good • validity=good", + SourceMode = "Buffered Report • dchg", + Reason = "dchg" + }; +} From 112aecb7db51e6d93e0a223e4d43a15f6e5c5aff Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 21:13:50 +0700 Subject: [PATCH 05/12] feat: map synthetic GOOSE streams to ordered DataSet leaves --- MainWindow.DemoGooseSnapshot.cs | 70 +++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 MainWindow.DemoGooseSnapshot.cs diff --git a/MainWindow.DemoGooseSnapshot.cs b/MainWindow.DemoGooseSnapshot.cs new file mode 100644 index 000000000..27fcfd7d1 --- /dev/null +++ b/MainWindow.DemoGooseSnapshot.cs @@ -0,0 +1,70 @@ +using System.Globalization; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private GooseStreamSnapshot BuildDemoGooseSnapshot(DemoGooseStreamState state, int changedIndex, string diagnostics, DateTimeOffset timestamp) + { + var leaves = state.Leaves.Select((leaf, index) => new GooseLeafValueSnapshot( + index + 1, + index, + leaf.Name, + $"{state.Spec.IedName}LD0/{leaf.Path}", + leaf.Path.Contains(".mag.", StringComparison.OrdinalIgnoreCase) ? "MX" : "ST", + leaf.TypeText.Split('/')[0].Trim(), + leaf.TypeText.Contains('/') ? leaf.TypeText.Split('/')[1].Trim() : string.Empty, + leaf.Value, + index == changedIndex ? leaf.PreviousValue : leaf.Value, + index == changedIndex, + "DEMO model")).ToArray(); + + return new GooseStreamSnapshot( + state.Spec.StreamKey, + state.Spec.AppId, + $"{state.Spec.IedName}LD0/LLN0$GO${state.Spec.ControlBlock}", + $"{state.Spec.IedName}_{state.Spec.ControlBlock}", + $"{state.Spec.IedName}LD0/LLN0$DataSet${state.Spec.DataSet}", + state.Spec.SourceMac, + state.Spec.DestinationMac, + $"VID {state.Spec.Vlan} / PCP 4", + state.StateNumber.ToString(CultureInfo.InvariantCulture), + state.SequenceNumber.ToString(CultureInfo.InvariantCulture), + changedIndex >= 0 ? "StateChange" : "Retransmission", + "2000 ms", + "1", + state.Spec.IedName, + "DEMO model", + diagnostics, + timestamp.ToLocalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture), + state.PacketCount, + changedIndex >= 0 ? 1 : 0, + false, + false, + leaves); + } + + private static DemoGooseStreamSpec DemoGooseSpec( + string streamKey, + string iedName, + string appId, + string destinationMac, + int vlan, + string controlBlock, + string dataSet, + params (string Name, string Path, string TypeText, string InitialValue)[] leaves) + { + var lastOctet = (Math.Abs((long)iedName.GetHashCode(StringComparison.Ordinal)) % 180) + 20; + return new DemoGooseStreamSpec( + streamKey, + iedName, + appId, + $"02:00:5E:61:85:{lastOctet:X2}", + destinationMac, + vlan, + controlBlock, + dataSet, + leaves.Select(leaf => new DemoGooseLeafState(leaf.Name, leaf.Path, leaf.TypeText, leaf.InitialValue)).ToArray()); + } +} From 857ee48296a30e3edaf3ba7954b283c9b76890f3 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 21:14:20 +0700 Subject: [PATCH 06/12] feat: add deterministic demo scenario models --- MainWindow.DemoTypes.cs | 128 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 MainWindow.DemoTypes.cs diff --git a/MainWindow.DemoTypes.cs b/MainWindow.DemoTypes.cs new file mode 100644 index 000000000..875601e30 --- /dev/null +++ b/MainWindow.DemoTypes.cs @@ -0,0 +1,128 @@ +using System.Globalization; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private static string ToggleDemoValue(string value) + { + var normalized = value.Trim(); + if (normalized.Equals("false", StringComparison.OrdinalIgnoreCase)) return "true"; + if (normalized.Equals("true", StringComparison.OrdinalIgnoreCase)) return "false"; + if (normalized.Contains("Closed", StringComparison.OrdinalIgnoreCase)) return "Open [01]"; + if (normalized.Contains("Open", StringComparison.OrdinalIgnoreCase)) return "Closed [10]"; + if (normalized.Equals("Remote", StringComparison.OrdinalIgnoreCase)) return "Local"; + if (normalized.Equals("Local", StringComparison.OrdinalIgnoreCase)) return "Remote"; + return normalized; + } + + private static string BuildSidecarRoot(string reference) + { + foreach (var suffix in new[] { ".stVal", ".mag.f", ".general" }) + { + if (reference.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + return reference[..^suffix.Length]; + } + var lastDot = reference.LastIndexOf('.'); + return lastDot > 0 ? reference[..lastDot] : reference; + } + + private static string FormatDemoAnalog(double value, string unit) + { + var decimals = Math.Abs(value) < 10 ? "0.00" : "0.0"; + return string.IsNullOrWhiteSpace(unit) + ? value.ToString(decimals, CultureInfo.InvariantCulture) + : $"{value.ToString(decimals, CultureInfo.InvariantCulture)} {unit}"; + } + + private static DemoSignalSeed Position(string name, string path, string initial) + => new(name, path, "ST", "Dbpos", "Status", string.Empty, initial, DemoValueKind.Discrete, 0, 0, new[] { "Open [01]", "Closed [10]" }, true); + + private static DemoSignalSeed Boolean(string name, string path, bool initial, bool eventEligible) + => new(name, path, "ST", "BOOLEAN", "Status", string.Empty, initial ? "true" : "false", DemoValueKind.Discrete, 0, 0, new[] { "false", "true" }, eventEligible); + + private static DemoSignalSeed State(string name, string path, string[] values, string initial, bool eventEligible) + => new(name, path, "ST", "INT32", "Status", string.Empty, initial, DemoValueKind.Discrete, 0, 0, values, eventEligible); + + private static DemoSignalSeed Counter(string name, string path, int initial) + => new(name, path, "ST", "INT32", "Counter", string.Empty, initial.ToString(CultureInfo.InvariantCulture), DemoValueKind.Counter, initial, 0, null, false); + + private static DemoSignalSeed Analog(string name, string path, string unit, double baseValue, double variation) + => new(name, path, "MX", "FLOAT32", "Measurement", unit, FormatDemoAnalog(baseValue, unit), DemoValueKind.Analog, baseValue, variation, null, false); + + private enum DemoDeviceRole { Bcu, Ocr, LineDiff, Distance, TrafoDiff, BusbarDiff, CapBank, Coupler } + private enum DemoValueKind { Discrete, Analog, Counter } + + private sealed record DemoDeviceSpec(string Name, string Description, string IpAddress, string ModelSummary, DemoDeviceRole Role); + + private sealed record DemoSignalSeed( + string Name, + string Path, + string FunctionalConstraint, + string DataType, + string Category, + string Unit, + string InitialValue, + DemoValueKind Kind, + double BaseValue, + double Variation, + string[]? DiscreteValues, + bool EventEligible); + + private sealed record DemoPointState( + Iec61850MonitorDevice Device, + SignalDefinition Signal, + Iec61850MonitorPoint Point, + DemoSignalSeed Seed); + + private sealed record DemoGooseStreamSpec( + string StreamKey, + string IedName, + string AppId, + string SourceMac, + string DestinationMac, + int Vlan, + string ControlBlock, + string DataSet, + IReadOnlyList Leaves); + + private sealed class DemoGooseLeafState + { + public DemoGooseLeafState(string name, string path, string typeText, string value) + { + Name = name; + Path = path; + TypeText = typeText; + Value = value; + PreviousValue = value; + } + + public string Name { get; } + public string Path { get; } + public string TypeText { get; } + public string Value { get; set; } + public string PreviousValue { get; set; } + } + + private sealed class DemoGooseStreamState + { + public DemoGooseStreamState(DemoGooseStreamSpec spec, int stateNumber, int sequenceNumber, long packetCount) + { + Spec = spec; + StateNumber = stateNumber; + SequenceNumber = sequenceNumber; + PacketCount = packetCount; + Leaves = spec.Leaves.Select(leaf => new DemoGooseLeafState(leaf.Name, leaf.Path, leaf.TypeText, leaf.Value)).ToList(); + LastTimelineTimestamp = DateTimeOffset.Now.AddSeconds(-8); + } + + public DemoGooseStreamSpec Spec { get; } + public GooseStreamRow Row { get; set; } = null!; + public List Leaves { get; } + public int StateNumber { get; set; } + public int SequenceNumber { get; set; } + public long PacketCount { get; set; } + public DateTimeOffset LastTimelineTimestamp { get; set; } + } +} From 113e5fe31d1dc6ea8a05112ec6887e0f1173df98 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 21:16:15 +0700 Subject: [PATCH 07/12] fix: keep demo runtime focused on live values and SOE --- MainWindow.DemoProcessRuntime.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/MainWindow.DemoProcessRuntime.cs b/MainWindow.DemoProcessRuntime.cs index 578a5ac8a..94851b2d6 100644 --- a/MainWindow.DemoProcessRuntime.cs +++ b/MainWindow.DemoProcessRuntime.cs @@ -1,7 +1,4 @@ using System.Globalization; -using System.Windows; -using System.Windows.Input; -using System.Windows.Threading; using ArIED61850Tester.Models; namespace ArIED61850Tester; @@ -17,7 +14,6 @@ private void DemoTimer_Tick(object? sender, EventArgs e) UpdateDemoAnalogValues(); if (_demoTick % 3 == 0) GenerateDemoProcessEvent(); - UpdateDemoGooseTraffic(); RaiseWorkspaceCounts(); } From 49c72dec426cee88f0774321bfb4b998d9d06f87 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 21:16:43 +0700 Subject: [PATCH 08/12] chore: add one-shot demo shell patch --- scripts/apply-demo-shell-patch.py | 94 +++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 scripts/apply-demo-shell-patch.py diff --git a/scripts/apply-demo-shell-patch.py b/scripts/apply-demo-shell-patch.py new file mode 100644 index 000000000..c81efd00e --- /dev/null +++ b/scripts/apply-demo-shell-patch.py @@ -0,0 +1,94 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str, label: str) -> None: + file = Path(path) + text = file.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise RuntimeError(f"{label}: expected one match, found {count}") + file.write_text(text.replace(old, new, 1), encoding="utf-8") + + +replace_once( + "MainWindow.xaml.cs", + ''' public string HeaderStatusText => $"{Devices.Count} IED • {_runtime.MonitoringDeviceCount} monitoring"; + public string DeviceCountText => $"{Devices.Count} device(s)"; + public string RuntimeSummaryText => $"Connected {_runtime.ConnectedDeviceCount} • Monitoring {_runtime.MonitoringDeviceCount} • Values {GlobalPoints.Count} • Events {Events.Count}"; + public string ConnectionInsightText => $"{_runtime.ConnectedDeviceCount} connected / {Devices.Count} discovered"; + public string MonitoringInsightText => $"{_runtime.MonitoringDeviceCount} monitoring / {GlobalPoints.Count} values";''', + ''' public string HeaderStatusText => $"{Devices.Count} IED • {(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} monitoring"; + public string DeviceCountText => $"{Devices.Count} device(s)"; + public string RuntimeSummaryText => $"Connected {(IsDemoMode ? Devices.Count(device => device.IsConnected) : _runtime.ConnectedDeviceCount)} • Monitoring {(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} • Values {GlobalPoints.Count} • Events {Events.Count}"; + public string ConnectionInsightText => $"{(IsDemoMode ? Devices.Count(device => device.IsConnected) : _runtime.ConnectedDeviceCount)} connected / {Devices.Count} discovered"; + public string MonitoringInsightText => $"{(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} monitoring / {GlobalPoints.Count} values";''', + "demo display counters", +) + +replace_once( + "MainWindow.xaml", + 'Icon="Assets/app-icon.ico" Closing="Window_Closing">', + 'Icon="Assets/app-icon.ico" Closing="Window_Closing" PreviewKeyDown="MainWindow_PreviewKeyDown">', + "demo shortcut handler", +) + +replace_once( + "MainWindow.xaml", + ''' + + + + + _hasReportStream; + set + { + if (Set(ref _hasReportStream, value)) + RefreshComputed(); + } + } +''' +replace_once( + "Models/MonitorModels.cs", + report_property, + report_property + ''' + public bool IsDemo + { + get => _isDemo; + set + { + if (Set(ref _isDemo, value)) + RefreshComputed(); + } + } +''', + "demo model property", +) + +replacements = { + "public bool IsActionEnabled => !IsBusy;": "public bool IsActionEnabled => !IsBusy && !IsDemo;", + "public bool CanEditSignals => SignalCount > 0 && !IsBusy && !IsMonitoring;": "public bool CanEditSignals => SignalCount > 0 && !IsBusy && !IsMonitoring && !IsDemo;", + "public bool CanStartMonitor => IsConnected && SelectedLiveSignalCount > 0 && !IsBusy;": "public bool CanStartMonitor => IsConnected && SelectedLiveSignalCount > 0 && !IsBusy && !IsDemo;", + "public bool CanStartOrStopMonitor => !IsBusy && (IsMonitoring || SelectedLiveSignalCount > 0);": "public bool CanStartOrStopMonitor => !IsBusy && !IsDemo && (IsMonitoring || SelectedLiveSignalCount > 0);", + "public bool CanPlayAction => !IsBusy && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0));": "public bool CanPlayAction => !IsBusy && !IsDemo && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0));", + "public bool CanStopAction => !IsBusy && (IsConnected || IsMonitoring);": "public bool CanStopAction => !IsBusy && !IsDemo && (IsConnected || IsMonitoring);", + "public bool CanRescan => !IsBusy && !IsMonitoring;": "public bool CanRescan => !IsBusy && !IsMonitoring && !IsDemo;", + "public bool CanSaveScl => !IsBusy && (SclWorkspace != null || LiveDiscoveryModel != null);": "public bool CanSaveScl => !IsBusy && !IsDemo && (SclWorkspace != null || LiveDiscoveryModel != null);", +} +for old, new in replacements.items(): + replace_once("Models/MonitorModels.cs", old, new, old) From ea591f97690ca67cc915694bb0b3a6bc060ace92 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 21:16:58 +0700 Subject: [PATCH 09/12] ci: apply demo shell integration --- .github/workflows/apply-demo-shell-patch.yml | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/apply-demo-shell-patch.yml diff --git a/.github/workflows/apply-demo-shell-patch.yml b/.github/workflows/apply-demo-shell-patch.yml new file mode 100644 index 000000000..211856c88 --- /dev/null +++ b/.github/workflows/apply-demo-shell-patch.yml @@ -0,0 +1,35 @@ +name: Apply demo shell patch + +on: + push: + branches: + - feat/realistic-demo-mock + paths: + - scripts/apply-demo-shell-patch.py + - .github/workflows/apply-demo-shell-patch.yml + +permissions: + contents: write + +jobs: + apply: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feat/realistic-demo-mock + fetch-depth: 0 + - name: Apply demo UI and read-only model changes + run: python3 scripts/apply-demo-shell-patch.py + - name: Commit generated source changes + shell: bash + run: | + if git diff --quiet -- MainWindow.xaml.cs MainWindow.xaml Models/MonitorModels.cs; then + echo "No generated changes were required." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add MainWindow.xaml.cs MainWindow.xaml Models/MonitorModels.cs + git commit -m "feat: expose keyboard demo mode and read-only demo UI" + git push origin HEAD:feat/realistic-demo-mock From 284dacebf60e03f358c8baa3a3ef67a8ac550871 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:17:11 +0000 Subject: [PATCH 10/12] feat: expose keyboard demo mode and read-only demo UI --- MainWindow.xaml | 7 ++++++- MainWindow.xaml.cs | 8 ++++---- Models/MonitorModels.cs | 27 +++++++++++++++++++-------- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/MainWindow.xaml b/MainWindow.xaml index c611a8f29..4a0211ba3 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -5,7 +5,7 @@ Height="900" Width="1480" MinHeight="760" MinWidth="1180" WindowStartupLocation="CenterScreen" Background="{StaticResource AppBackgroundGradient}" FontFamily="Aptos, Segoe UI Variable Text, Segoe UI, Calibri" - Icon="Assets/app-icon.ico" Closing="Window_Closing"> + Icon="Assets/app-icon.ico" Closing="Window_Closing" PreviewKeyDown="MainWindow_PreviewKeyDown"> @@ -63,6 +63,11 @@ + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index e1085d89a..bf831cc0d 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -93,11 +93,11 @@ public Iec61850MonitorDevice? SelectedDevice } } - public string HeaderStatusText => $"{Devices.Count} IED • {_runtime.MonitoringDeviceCount} monitoring"; + public string HeaderStatusText => $"{Devices.Count} IED • {(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} monitoring"; public string DeviceCountText => $"{Devices.Count} device(s)"; - public string RuntimeSummaryText => $"Connected {_runtime.ConnectedDeviceCount} • Monitoring {_runtime.MonitoringDeviceCount} • Values {GlobalPoints.Count} • Events {Events.Count}"; - public string ConnectionInsightText => $"{_runtime.ConnectedDeviceCount} connected / {Devices.Count} discovered"; - public string MonitoringInsightText => $"{_runtime.MonitoringDeviceCount} monitoring / {GlobalPoints.Count} values"; + public string RuntimeSummaryText => $"Connected {(IsDemoMode ? Devices.Count(device => device.IsConnected) : _runtime.ConnectedDeviceCount)} • Monitoring {(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} • Values {GlobalPoints.Count} • Events {Events.Count}"; + public string ConnectionInsightText => $"{(IsDemoMode ? Devices.Count(device => device.IsConnected) : _runtime.ConnectedDeviceCount)} connected / {Devices.Count} discovered"; + public string MonitoringInsightText => $"{(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} monitoring / {GlobalPoints.Count} values"; public string EventInsightText => $"{Events.Count} event(s)"; public Visibility DiagnosticsAlertVisibility => _hasUnreadDiagnosticError ? Visibility.Visible : Visibility.Collapsed; public Visibility EmptyExplorerVisibility => SelectedDevice == null ? Visibility.Visible : Visibility.Collapsed; diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 9e1f6879b..91e37c5d2 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -18,6 +18,7 @@ public sealed class Iec61850MonitorDevice : ObservableObject private bool _isMonitoring; private bool _isActive; private bool _hasReportStream; + private bool _isDemo; private bool _reportPulseActive; private string _busyStage = "Preparing IEC 61850 session…"; private double _discoveryProgressPercent; @@ -289,6 +290,16 @@ public bool HasReportStream } } + public bool IsDemo + { + get => _isDemo; + set + { + if (Set(ref _isDemo, value)) + RefreshComputed(); + } + } + public bool ReportPulseActive { get => _reportPulseActive; @@ -391,14 +402,14 @@ public void MarkDiscoveryFailed(string message) public int SelectedControlSignalCount => _selectedControlSignalCount; public bool IsBulkSignalSelectionUpdate => _isBulkSignalSelectionUpdate; public int PointCount => Points.Count; - public bool IsActionEnabled => !IsBusy; - public bool CanEditSignals => SignalCount > 0 && !IsBusy && !IsMonitoring; - public bool CanStartMonitor => IsConnected && SelectedLiveSignalCount > 0 && !IsBusy; - public bool CanStartOrStopMonitor => !IsBusy && (IsMonitoring || SelectedLiveSignalCount > 0); - public bool CanPlayAction => !IsBusy && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0)); - public bool CanStopAction => !IsBusy && (IsConnected || IsMonitoring); - public bool CanRescan => !IsBusy && !IsMonitoring; - public bool CanSaveScl => !IsBusy && (SclWorkspace != null || LiveDiscoveryModel != null); + public bool IsActionEnabled => !IsBusy && !IsDemo; + public bool CanEditSignals => SignalCount > 0 && !IsBusy && !IsMonitoring && !IsDemo; + public bool CanStartMonitor => IsConnected && SelectedLiveSignalCount > 0 && !IsBusy && !IsDemo; + public bool CanStartOrStopMonitor => !IsBusy && !IsDemo && (IsMonitoring || SelectedLiveSignalCount > 0); + public bool CanPlayAction => !IsBusy && !IsDemo && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0)); + public bool CanStopAction => !IsBusy && !IsDemo && (IsConnected || IsMonitoring); + public bool CanRescan => !IsBusy && !IsMonitoring && !IsDemo; + public bool CanSaveScl => !IsBusy && !IsDemo && (SclWorkspace != null || LiveDiscoveryModel != null); public string SaveSclToolTip => SclWorkspace != null ? $"Save {Name} from the opened SCL design model" : LiveDiscoveryModel != null From 7c5bfc8185b9ccdd471640231508047ffa5dd642 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 21:19:14 +0700 Subject: [PATCH 11/12] chore: remove one-shot demo shell workflow --- .github/workflows/apply-demo-shell-patch.yml | 35 -------------------- 1 file changed, 35 deletions(-) delete mode 100644 .github/workflows/apply-demo-shell-patch.yml diff --git a/.github/workflows/apply-demo-shell-patch.yml b/.github/workflows/apply-demo-shell-patch.yml deleted file mode 100644 index 211856c88..000000000 --- a/.github/workflows/apply-demo-shell-patch.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Apply demo shell patch - -on: - push: - branches: - - feat/realistic-demo-mock - paths: - - scripts/apply-demo-shell-patch.py - - .github/workflows/apply-demo-shell-patch.yml - -permissions: - contents: write - -jobs: - apply: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feat/realistic-demo-mock - fetch-depth: 0 - - name: Apply demo UI and read-only model changes - run: python3 scripts/apply-demo-shell-patch.py - - name: Commit generated source changes - shell: bash - run: | - if git diff --quiet -- MainWindow.xaml.cs MainWindow.xaml Models/MonitorModels.cs; then - echo "No generated changes were required." - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add MainWindow.xaml.cs MainWindow.xaml Models/MonitorModels.cs - git commit -m "feat: expose keyboard demo mode and read-only demo UI" - git push origin HEAD:feat/realistic-demo-mock From 1bdc5e9783b7d662468e912b132e220235d0d245 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 16 Jul 2026 21:19:26 +0700 Subject: [PATCH 12/12] chore: remove one-shot demo shell helper --- scripts/apply-demo-shell-patch.py | 94 ------------------------------- 1 file changed, 94 deletions(-) delete mode 100644 scripts/apply-demo-shell-patch.py diff --git a/scripts/apply-demo-shell-patch.py b/scripts/apply-demo-shell-patch.py deleted file mode 100644 index c81efd00e..000000000 --- a/scripts/apply-demo-shell-patch.py +++ /dev/null @@ -1,94 +0,0 @@ -from pathlib import Path - - -def replace_once(path: str, old: str, new: str, label: str) -> None: - file = Path(path) - text = file.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise RuntimeError(f"{label}: expected one match, found {count}") - file.write_text(text.replace(old, new, 1), encoding="utf-8") - - -replace_once( - "MainWindow.xaml.cs", - ''' public string HeaderStatusText => $"{Devices.Count} IED • {_runtime.MonitoringDeviceCount} monitoring"; - public string DeviceCountText => $"{Devices.Count} device(s)"; - public string RuntimeSummaryText => $"Connected {_runtime.ConnectedDeviceCount} • Monitoring {_runtime.MonitoringDeviceCount} • Values {GlobalPoints.Count} • Events {Events.Count}"; - public string ConnectionInsightText => $"{_runtime.ConnectedDeviceCount} connected / {Devices.Count} discovered"; - public string MonitoringInsightText => $"{_runtime.MonitoringDeviceCount} monitoring / {GlobalPoints.Count} values";''', - ''' public string HeaderStatusText => $"{Devices.Count} IED • {(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} monitoring"; - public string DeviceCountText => $"{Devices.Count} device(s)"; - public string RuntimeSummaryText => $"Connected {(IsDemoMode ? Devices.Count(device => device.IsConnected) : _runtime.ConnectedDeviceCount)} • Monitoring {(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} • Values {GlobalPoints.Count} • Events {Events.Count}"; - public string ConnectionInsightText => $"{(IsDemoMode ? Devices.Count(device => device.IsConnected) : _runtime.ConnectedDeviceCount)} connected / {Devices.Count} discovered"; - public string MonitoringInsightText => $"{(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} monitoring / {GlobalPoints.Count} values";''', - "demo display counters", -) - -replace_once( - "MainWindow.xaml", - 'Icon="Assets/app-icon.ico" Closing="Window_Closing">', - 'Icon="Assets/app-icon.ico" Closing="Window_Closing" PreviewKeyDown="MainWindow_PreviewKeyDown">', - "demo shortcut handler", -) - -replace_once( - "MainWindow.xaml", - ''' - - - - - _hasReportStream; - set - { - if (Set(ref _hasReportStream, value)) - RefreshComputed(); - } - } -''' -replace_once( - "Models/MonitorModels.cs", - report_property, - report_property + ''' - public bool IsDemo - { - get => _isDemo; - set - { - if (Set(ref _isDemo, value)) - RefreshComputed(); - } - } -''', - "demo model property", -) - -replacements = { - "public bool IsActionEnabled => !IsBusy;": "public bool IsActionEnabled => !IsBusy && !IsDemo;", - "public bool CanEditSignals => SignalCount > 0 && !IsBusy && !IsMonitoring;": "public bool CanEditSignals => SignalCount > 0 && !IsBusy && !IsMonitoring && !IsDemo;", - "public bool CanStartMonitor => IsConnected && SelectedLiveSignalCount > 0 && !IsBusy;": "public bool CanStartMonitor => IsConnected && SelectedLiveSignalCount > 0 && !IsBusy && !IsDemo;", - "public bool CanStartOrStopMonitor => !IsBusy && (IsMonitoring || SelectedLiveSignalCount > 0);": "public bool CanStartOrStopMonitor => !IsBusy && !IsDemo && (IsMonitoring || SelectedLiveSignalCount > 0);", - "public bool CanPlayAction => !IsBusy && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0));": "public bool CanPlayAction => !IsBusy && !IsDemo && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0));", - "public bool CanStopAction => !IsBusy && (IsConnected || IsMonitoring);": "public bool CanStopAction => !IsBusy && !IsDemo && (IsConnected || IsMonitoring);", - "public bool CanRescan => !IsBusy && !IsMonitoring;": "public bool CanRescan => !IsBusy && !IsMonitoring && !IsDemo;", - "public bool CanSaveScl => !IsBusy && (SclWorkspace != null || LiveDiscoveryModel != null);": "public bool CanSaveScl => !IsBusy && !IsDemo && (SclWorkspace != null || LiveDiscoveryModel != null);", -} -for old, new in replacements.items(): - replace_once("Models/MonitorModels.cs", old, new, old)