alarms-over-gateway: full pipeline (wnwrap consumer + dispatcher + IPC + auto-subscribe + ack-by-name + live smoke) #118
@@ -87,35 +87,97 @@ a result — `RaiseAlarmRecordReceived` is `internal` for tests and
|
||||
never gets invoked at runtime. Until A.2 lands a WM_APP pump,
|
||||
`MX_EVENT_FAMILY_ON_ALARM_TRANSITION` cannot carry events.
|
||||
|
||||
## Implications for A.2
|
||||
## Live runtime probe — 2026-05-01
|
||||
|
||||
The original plan banner was right: A.2 needs a hidden message-only
|
||||
window inside the worker's STA whose hWnd is passed to
|
||||
`RegisterConsumer`, with a `WindowProc` that intercepts the AVEVA
|
||||
WM_APP message and routes change-enumeration into
|
||||
`MxAccessAlarmEventSink.EnqueueTransition`. Open questions before
|
||||
implementation:
|
||||
`MxGateway.Worker.Tests.AlarmClientWmProbeTests.ProbeAlarmClientWmMessages`
|
||||
is a Skip-gated runtime probe that creates a real message-only
|
||||
window, calls `AlarmClient.RegisterConsumer(hWnd, …)` +
|
||||
`Subscribe(@"\Galaxy!", …)`, and pumps for 20s while logging every
|
||||
window message that arrives. Run results below — this turned the
|
||||
"WM_APP pump" design assumption upside down.
|
||||
|
||||
1. **WM_APP message ID.** Not in the public surface — needs either
|
||||
AVEVA's C++ Toolkit reference (canonical doc per `gateway.md`) or
|
||||
a runtime probe (subclass a window, log every WM arriving while a
|
||||
live alarm is fired, identify the AVEVA one). Worth doing once on
|
||||
the dev rig and checking the result in.
|
||||
2. **`wParam` / `lParam` semantics.** Probably none — the pattern is
|
||||
"got poked; pull state via `GetStatistics`." Confirm during the
|
||||
probe.
|
||||
3. **Threading.** AVEVA almost certainly delivers the WM on the
|
||||
thread that owns the window. The worker's STA is the natural
|
||||
home; the existing `StaRuntime` already runs a pump there. If
|
||||
AVEVA assumes a UI thread (MTA-incompatible call paths inside
|
||||
`GetStatistics`), the alarm path may need its own STA.
|
||||
4. **Subscription scope.** `AlarmClient.Subscribe(szSubscription,
|
||||
…)` takes an AVEVA-syntax string for the alarm provider (e.g.
|
||||
`"\Galaxy!"` for a whole Galaxy or `"\Galaxy!Group.Tag"` for a
|
||||
subset). The configured Galaxy name is already known to the
|
||||
worker via the existing data session — reuse it.
|
||||
**`RegisterConsumer` and `Subscribe` both returned 0 (success).** The
|
||||
calls are valid against the deployed assembly; no parameter pinning
|
||||
needed.
|
||||
|
||||
**A registered-message-class WM (ID `0xC275` in this OS session)
|
||||
fired every ~1s after `Subscribe` completed.** Constant
|
||||
`wParam = 0x00001100`, constant `lParam = 0x079E46D8` (looks like a
|
||||
stable pointer into AVEVA-internal state) for all 20 hits. The
|
||||
constant payload across hits with no Galaxy alarm being fired
|
||||
suggests this is a **heartbeat/keepalive**, not a per-change
|
||||
notification.
|
||||
|
||||
**Critically: this WM is delivered to AVEVA's own internal window
|
||||
(`hwnd=0x18032E`) — NOT to the consumer's `hWnd` we passed in.** The
|
||||
consumer window's `WndProc` received only the standard creation
|
||||
sequence (`WM_GETMINMAXINFO`, `WM_NCCREATE`, `WM_NCCALCSIZE`,
|
||||
`WM_CREATE`) and the destruction sequence (`WM_NCDESTROY`,
|
||||
`WM_DESTROY`, `WM_NCCALCSIZE`) — nothing in between. AVEVA's
|
||||
notification path runs entirely against AVEVA's internal window;
|
||||
it never forwards to the user-supplied hWnd.
|
||||
|
||||
The message ID itself is dynamic (a `RegisterWindowMessage`
|
||||
allocation in the >= 0xC000 range), so it cannot be hard-coded —
|
||||
each consumer process must call `RegisterWindowMessage` with the
|
||||
correct *string* and use whatever ID the OS returns.
|
||||
|
||||
## What this means for A.2
|
||||
|
||||
The "WM_APP pump on the user hWnd" design — what the original plan
|
||||
banner described and what the previous version of this doc
|
||||
recommended — does not match how AVEVA actually delivers
|
||||
notifications. The hWnd parameter to `RegisterConsumer` does not
|
||||
appear to receive any of AVEVA's alarm traffic; it's likely used
|
||||
only as a registration identity (and perhaps as a parent for modal
|
||||
dialogs).
|
||||
|
||||
Two viable A.2 designs given the probe data:
|
||||
|
||||
1. **Polling.** Just call `GetStatistics` on a timer (e.g. every
|
||||
500ms in the worker's STA) and react to the change set it
|
||||
reports. No window plumbing needed. Trade-off: latency floor =
|
||||
poll period; modest CPU floor because the call is cheap. Matches
|
||||
the heartbeat-style WM 0xC275 semantics — AVEVA itself runs a
|
||||
poll loop internally.
|
||||
2. **Hook AVEVA's internal window.** Discover AVEVA's own window
|
||||
(`hwnd=0x18032E` in the probe), `SetWindowsHookEx` or
|
||||
`SetWindowSubclass` on it, and intercept WM 0xC275 on AVEVA's
|
||||
thread. Higher fidelity, near-zero latency, but invasive,
|
||||
fragile across AVEVA upgrades, and requires running on the same
|
||||
process / thread as the AVEVA window. Probably a non-starter
|
||||
without further AVEVA documentation.
|
||||
|
||||
**Recommendation:** the polling path (option 1) is cheaper to
|
||||
implement, more robust against AVEVA-internal change, and
|
||||
acceptable for a typical alarm cadence. The worker's existing STA
|
||||
already provides a thread-affinitized timer surface. The unanswered
|
||||
question is whether `GetStatistics` can be safely called outside
|
||||
AVEVA's own message-pump thread — confirmable by extending the
|
||||
probe to fire `GetStatistics` on its own thread and check the
|
||||
result.
|
||||
|
||||
## Open follow-up probes
|
||||
|
||||
Each can be added to `AlarmClientWmProbeTests` as a separate
|
||||
Skip-gated test:
|
||||
|
||||
1. **Fire a real Galaxy alarm during the pump window.** Confirms
|
||||
whether the WM 0xC275 cadence changes (becomes per-change rather
|
||||
than periodic) and whether `GetStatistics` returns a non-empty
|
||||
`ChangeCodes / ChangePos / hAlarm` triple.
|
||||
2. **Call `GetStatistics` on a different thread from the
|
||||
`RegisterConsumer` thread** to test threading affinity.
|
||||
3. **Hook AVEVA's internal window** to log what WMs it actually
|
||||
processes (would resolve option 2 above).
|
||||
4. **Decompile `aaAlarmManagedClient.dll`'s IL** for the
|
||||
`RegisterConsumer` method to find what `RegisterWindowMessage`
|
||||
string is used and whether there's a callback-registration
|
||||
surface on `WNAL_Register` that the managed client wraps. The
|
||||
alarmlst.dll strings (`WNAL_CallBack`, "Invalid callbacks" error)
|
||||
suggest the underlying C API takes callbacks, but the managed
|
||||
wrapper exposes none of them.
|
||||
|
||||
PR A.5's `Subscribe` / `AcknowledgeByGuid` / `SnapshotActiveAlarms`
|
||||
are correct — they're pull-style and don't depend on the missing
|
||||
event surface. The event-subscription wiring is what has to be
|
||||
replaced.
|
||||
are correct — they're pull-style and don't depend on the
|
||||
notification mechanism.
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using AlarmMgrDataProviderCOM;
|
||||
using aaAlarmManagedClient;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace MxGateway.Worker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime probe — registers as an AlarmClient consumer with a real
|
||||
/// hidden message-only window, subscribes to a Galaxy alarm provider,
|
||||
/// and logs every Win32 message that arrives during a fixed pump
|
||||
/// window. The intent is to identify the WM_APP / RegisterWindowMessage
|
||||
/// ID that AVEVA's alarm provider posts when alarms change, plus the
|
||||
/// <c>wParam</c>/<c>lParam</c> semantics on each.
|
||||
///
|
||||
/// Skip-gated by default; flip Skip=null and run against the live dev
|
||||
/// rig to capture output. Requires:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A reachable Galaxy with at least one alarmable object.</description></item>
|
||||
/// <item><description>The configured Galaxy expression below to match a real provider (default <c>"\\Galaxy"</c> — adjust if needed).</description></item>
|
||||
/// <item><description>An alarm trigger during the pump window (raise / ack / clear something in the Galaxy via System Platform IDE) — without one, only ambient activity is captured.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class AlarmClientWmProbeTests : IDisposable
|
||||
{
|
||||
// Probe configuration. Override in the constructor below if needed.
|
||||
private const string SubscriptionExpression = @"\Galaxy!";
|
||||
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(20);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CreateWindowExW")]
|
||||
private static extern IntPtr CreateWindowEx(
|
||||
int dwExStyle, string lpClassName, string lpWindowName,
|
||||
int dwStyle, int X, int Y, int nWidth, int nHeight,
|
||||
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern ushort RegisterClassW(ref WNDCLASSW lpWndClass);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool UnregisterClassW(string lpClassName, IntPtr hInstance);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DefWindowProcW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool TranslateMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern IntPtr GetModuleHandle(string lpModuleName);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern uint RegisterWindowMessage(string lpString);
|
||||
|
||||
private const int HWND_MESSAGE = -3;
|
||||
private const uint PM_REMOVE = 0x0001;
|
||||
|
||||
private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct WNDCLASSW
|
||||
{
|
||||
public uint style;
|
||||
public IntPtr lpfnWndProc;
|
||||
public int cbClsExtra;
|
||||
public int cbWndExtra;
|
||||
public IntPtr hInstance;
|
||||
public IntPtr hIcon;
|
||||
public IntPtr hCursor;
|
||||
public IntPtr hbrBackground;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string? lpszMenuName;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MSG
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public int x;
|
||||
public int y;
|
||||
}
|
||||
|
||||
private readonly ITestOutputHelper output;
|
||||
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
|
||||
private readonly Stopwatch elapsed = Stopwatch.StartNew();
|
||||
private GCHandle wndProcHandle;
|
||||
private IntPtr probeWindow = IntPtr.Zero;
|
||||
private string? registeredClass;
|
||||
|
||||
public AlarmClientWmProbeTests(ITestOutputHelper output)
|
||||
{
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (with live Galaxy) to capture AVEVA WM_APP message IDs")]
|
||||
public void ProbeAlarmClientWmMessages()
|
||||
{
|
||||
// 1. Pre-resolve a few candidate RegisterWindowMessage strings so any
|
||||
// matches in the captured log can be labeled. None of these is
|
||||
// confirmed; we record what each resolves to so the actual AVEVA
|
||||
// message ID (whatever it turns out to be) can be cross-referenced.
|
||||
string[] candidateNames =
|
||||
{
|
||||
"WW_AlarmConsumer", "WW_AlarmManager", "WW_Alarm",
|
||||
"WNAL_AlarmChange", "WNAL_AlarmChanges", "WNAL_AlarmNotify",
|
||||
"WNAL_Notify", "WNAL_ChangeNotification",
|
||||
"AlarmManager.Notify", "AlarmManagerNotify",
|
||||
"ArchestrA.AlarmChange", "AVEVA.AlarmNotify",
|
||||
"aaAlarmManagedClient.Notify",
|
||||
"GotAlarmChanges", "OnAlarmChanges",
|
||||
};
|
||||
foreach (string name in candidateNames)
|
||||
{
|
||||
uint id = RegisterWindowMessage(name);
|
||||
output.WriteLine($"RegisterWindowMessage(\"{name}\") -> 0x{id:X4} ({id})");
|
||||
}
|
||||
output.WriteLine("");
|
||||
|
||||
// 2. Spin up a single STA-affinitized thread, create a hidden message-
|
||||
// only window owned by it, run RegisterConsumer + Subscribe against
|
||||
// that window's hWnd, then pump messages on that thread for the
|
||||
// configured duration. Threading discipline matches the worker's
|
||||
// StaRuntime model.
|
||||
Exception? threadException = null;
|
||||
var pumpDone = new ManualResetEventSlim(false);
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
RunProbe();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
threadException = ex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
pumpDone.Set();
|
||||
}
|
||||
});
|
||||
thread.IsBackground = false;
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
pumpDone.Wait();
|
||||
thread.Join();
|
||||
|
||||
// 3. Drain the log to xunit output regardless of outcome — partial
|
||||
// captures are still informative.
|
||||
output.WriteLine("");
|
||||
output.WriteLine($"Captured {log.Count} log line(s):");
|
||||
while (log.TryDequeue(out string? line))
|
||||
{
|
||||
output.WriteLine(line);
|
||||
}
|
||||
|
||||
if (threadException != null)
|
||||
{
|
||||
throw threadException;
|
||||
}
|
||||
}
|
||||
|
||||
private void RunProbe()
|
||||
{
|
||||
// 3a. Register a window class and create a message-only window.
|
||||
WndProc wndProc = ProbeWndProc;
|
||||
wndProcHandle = GCHandle.Alloc(wndProc); // keep delegate alive
|
||||
|
||||
registeredClass = "MxGatewayAlarmProbe_" + Guid.NewGuid().ToString("N");
|
||||
var cls = new WNDCLASSW
|
||||
{
|
||||
style = 0,
|
||||
lpfnWndProc = Marshal.GetFunctionPointerForDelegate(wndProc),
|
||||
hInstance = GetModuleHandle(null!),
|
||||
lpszClassName = registeredClass,
|
||||
};
|
||||
ushort atom = RegisterClassW(ref cls);
|
||||
if (atom == 0)
|
||||
{
|
||||
int err = Marshal.GetLastWin32Error();
|
||||
Log($"RegisterClass failed err=0x{err:X8}");
|
||||
return;
|
||||
}
|
||||
Log($"RegisterClass ok atom=0x{atom:X4} class={registeredClass}");
|
||||
|
||||
probeWindow = CreateWindowEx(
|
||||
dwExStyle: 0, lpClassName: registeredClass, lpWindowName: "AlarmProbe",
|
||||
dwStyle: 0, X: 0, Y: 0, nWidth: 0, nHeight: 0,
|
||||
hWndParent: (IntPtr)HWND_MESSAGE, hMenu: IntPtr.Zero,
|
||||
hInstance: cls.hInstance, lpParam: IntPtr.Zero);
|
||||
if (probeWindow == IntPtr.Zero)
|
||||
{
|
||||
int err = Marshal.GetLastWin32Error();
|
||||
Log($"CreateWindowEx(HWND_MESSAGE) failed err=0x{err:X8}");
|
||||
return;
|
||||
}
|
||||
Log($"Created message-only window hWnd=0x{probeWindow.ToInt64():X}");
|
||||
|
||||
// 3b. Create the AlarmClient and try the lifecycle. RegisterConsumer
|
||||
// accepts an int hWnd — narrow the IntPtr (sufficient on x86).
|
||||
AlarmClient? client = null;
|
||||
try
|
||||
{
|
||||
client = new AlarmClient();
|
||||
int register = client.RegisterConsumer(
|
||||
hWnd: probeWindow.ToInt32(),
|
||||
szProductName: "AlarmProbe",
|
||||
szApplicationName: "AlarmProbe.Tests",
|
||||
szVersion: "1.0",
|
||||
bRetainHiddenAlarms: false);
|
||||
Log($"RegisterConsumer -> {register}");
|
||||
|
||||
int subscribe = client.Subscribe(
|
||||
szSubscription: SubscriptionExpression,
|
||||
wFromPri: 1, wToPri: 999,
|
||||
QueryType: eQueryType.qtSummary,
|
||||
SortFlags: eSortFlags.sfReturnNewestFirst,
|
||||
FilterMask: eAlarmFilterState.asNone,
|
||||
FilterSpecification: eAlarmFilterState.asNone);
|
||||
Log($"Subscribe('{SubscriptionExpression}') -> {subscribe}");
|
||||
|
||||
// 3c. Pump for the configured duration. Log every message we see
|
||||
// (filtered light to avoid noise from WM_PAINT / WM_TIMER /
|
||||
// WM_GETICON spam from typical pumps).
|
||||
DateTime deadline = DateTime.UtcNow + PumpDuration;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
while (PeekMessage(out MSG msg, IntPtr.Zero, 0, 0, PM_REMOVE))
|
||||
{
|
||||
LogIfInteresting(msg);
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessage(ref msg);
|
||||
}
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
|
||||
Log($"Pump duration {PumpDuration.TotalSeconds:F0}s elapsed; deregistering.");
|
||||
|
||||
try { int dereg = client.DeregisterConsumer(); Log($"DeregisterConsumer -> {dereg}"); }
|
||||
catch (Exception ex) { Log($"DeregisterConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { client?.Dispose(); } catch { /* swallow */ }
|
||||
if (probeWindow != IntPtr.Zero)
|
||||
{
|
||||
DestroyWindow(probeWindow);
|
||||
probeWindow = IntPtr.Zero;
|
||||
}
|
||||
if (registeredClass != null)
|
||||
{
|
||||
UnregisterClassW(registeredClass, GetModuleHandle(null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LogIfInteresting(MSG m)
|
||||
{
|
||||
// Filter out the highest-volume noise (timer ticks, paint, mouse moves
|
||||
// from a desktop session). Keep WM_USER..WM_APP+ entirely; those are
|
||||
// the candidates for the AVEVA-registered message.
|
||||
const uint WM_PAINT = 0x000F;
|
||||
const uint WM_TIMER = 0x0113;
|
||||
const uint WM_MOUSEMOVE = 0x0200;
|
||||
const uint WM_NCMOUSEMOVE = 0x00A0;
|
||||
if (m.message == WM_PAINT || m.message == WM_TIMER ||
|
||||
m.message == WM_MOUSEMOVE || m.message == WM_NCMOUSEMOVE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string interpreted = InterpretMessageId(m.message);
|
||||
Log(string.Format(
|
||||
"WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8} hwnd=0x{4:X}",
|
||||
m.message, interpreted,
|
||||
m.wParam.ToInt64() & 0xFFFFFFFF, m.lParam.ToInt64() & 0xFFFFFFFF,
|
||||
m.hwnd.ToInt64()));
|
||||
}
|
||||
|
||||
private static string InterpretMessageId(uint id)
|
||||
{
|
||||
if (id < 0x0400) return "WM_<system>";
|
||||
if (id < 0x8000) return $"WM_USER+0x{id - 0x0400:X4}";
|
||||
if (id < 0xC000) return $"WM_APP+0x{id - 0x8000:X4}";
|
||||
return $"RegisterWindowMessage_0x{id:X4}";
|
||||
}
|
||||
|
||||
private IntPtr ProbeWndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
// Log every WM that lands on the probe window itself.
|
||||
string interpreted = InterpretMessageId(msg);
|
||||
Log(string.Format(
|
||||
"WndProc WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8}",
|
||||
msg, interpreted,
|
||||
wParam.ToInt64() & 0xFFFFFFFF, lParam.ToInt64() & 0xFFFFFFFF));
|
||||
return DefWindowProcW(hWnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
private void Log(string line)
|
||||
{
|
||||
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (wndProcHandle.IsAllocated) wndProcHandle.Free();
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,17 @@
|
||||
<ProjectReference Include="..\MxGateway.Worker\MxGateway.Worker.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="aaAlarmManagedClient">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
<SpecificVersion>false</SpecificVersion>
|
||||
</Reference>
|
||||
<Reference Include="IAlarmMgrDataProvider">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\IAlarmMgrDataProvider.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
<SpecificVersion>false</SpecificVersion>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user