-
Notifications
You must be signed in to change notification settings - Fork 5
Add realistic ten-IED synthetic demo workspace #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fbf08d3
8864e6d
5484c3f
fadb731
112aecb
857ee48
113e5fe
49c72de
ea591f9
284dace
7c5bfc8
1bdc5e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<DemoPointState> _demoPointStates = new(); | ||
| private readonly List<DemoGooseStreamState> _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); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In demo mode, if a user clicks GOOSE Stop and then Start, this reuses the real Useful? React with 👍 / 👎. |
||
| 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<DiagnosticEntry> | ||
| { | ||
| 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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While the demo timer is running, the always-visible Add IED/Open SCL/Open Project controls remain enabled, and Add IED reaches
AddOrDiscoverEndpointAsync→ConnectAndConfigureDeviceAsync→_runtime.ConnectAndDiscoverAsync. That lets a user start real MMS sessions inside the advertised read-only demo; subsequent demo ticks still mutate events/status from the old synthetic state, and exiting demo clearsDeviceswithout stopping any runtime session added during demo. Guard workspace-mutating actions withIsDemoModeor force demo deactivation before they run.Useful? React with 👍 / 👎.