From 49c055694105eca4414ec8fb996711c16ec21093 Mon Sep 17 00:00:00 2001 From: Piotr Antosik Date: Sat, 4 Apr 2026 00:16:29 +0200 Subject: [PATCH] Add support for access code read operations - Implemented commands and message structures for reading access codes, attributes, and configurations. - Added `IPanelAccessCodeService` with methods for managing access codes. - Updated navigation and UI to support access codes page. - Enhanced binary serialization sorting for inheritance depth. - Extended session state to track access code data. --- NeoHub/NeoHub/Components/Layout/NavMenu.razor | 10 +- .../NeoHub/Components/Pages/AccessCodes.razor | 179 ++++++++ NeoHub/NeoHub/Program.cs | 1 + .../NeoHub/Services/AccessCodeReadResult.cs | 8 + .../Handlers/PanelConfigurationHandler.cs | 5 +- .../Services/IPanelAccessCodeService.cs | 7 + .../NeoHub/Services/Models/AccessCodeState.cs | 22 + NeoHub/NeoHub/Services/Models/SessionState.cs | 5 +- .../NeoHub/Services/PanelAccessCodeService.cs | 390 ++++++++++++++++++ NeoHub/TLink/ITv2/MessageReceiver.cs | 32 ++ .../AccessCodeAttributeReadRequest.cs | 20 + .../AccessCodeAttributeReadResponse.cs | 13 + ...ccessCodePartitionAssignmentReadRequest.cs | 20 + ...cessCodePartitionAssignmentReadResponse.cs | 13 + .../ITv2/Messages/AccessCodeReadRequest.cs | 20 + .../ITv2/Messages/AccessCodeReadResponse.cs | 14 + .../UserCodeConfigurationReadRequest.cs | 20 + .../UserCodeConfigurationReadResponse.cs | 13 + .../TLink/Serialization/BinarySerializer.cs | 14 +- 19 files changed, 798 insertions(+), 8 deletions(-) create mode 100644 NeoHub/NeoHub/Components/Pages/AccessCodes.razor create mode 100644 NeoHub/NeoHub/Services/AccessCodeReadResult.cs create mode 100644 NeoHub/NeoHub/Services/IPanelAccessCodeService.cs create mode 100644 NeoHub/NeoHub/Services/Models/AccessCodeState.cs create mode 100644 NeoHub/NeoHub/Services/PanelAccessCodeService.cs create mode 100644 NeoHub/TLink/ITv2/Messages/AccessCodeAttributeReadRequest.cs create mode 100644 NeoHub/TLink/ITv2/Messages/AccessCodeAttributeReadResponse.cs create mode 100644 NeoHub/TLink/ITv2/Messages/AccessCodePartitionAssignmentReadRequest.cs create mode 100644 NeoHub/TLink/ITv2/Messages/AccessCodePartitionAssignmentReadResponse.cs create mode 100644 NeoHub/TLink/ITv2/Messages/AccessCodeReadRequest.cs create mode 100644 NeoHub/TLink/ITv2/Messages/AccessCodeReadResponse.cs create mode 100644 NeoHub/TLink/ITv2/Messages/UserCodeConfigurationReadRequest.cs create mode 100644 NeoHub/TLink/ITv2/Messages/UserCodeConfigurationReadResponse.cs diff --git a/NeoHub/NeoHub/Components/Layout/NavMenu.razor b/NeoHub/NeoHub/Components/Layout/NavMenu.razor index ba3ecc0..f400e33 100644 --- a/NeoHub/NeoHub/Components/Layout/NavMenu.razor +++ b/NeoHub/NeoHub/Components/Layout/NavMenu.razor @@ -22,6 +22,10 @@ Panel Config + + @@ -35,8 +39,8 @@ @@ -57,5 +61,3 @@ } } } - - diff --git a/NeoHub/NeoHub/Components/Pages/AccessCodes.razor b/NeoHub/NeoHub/Components/Pages/AccessCodes.razor new file mode 100644 index 0000000..5426fa6 --- /dev/null +++ b/NeoHub/NeoHub/Components/Pages/AccessCodes.razor @@ -0,0 +1,179 @@ +@page "/access-codes" +@using NeoHub.Services +@using NeoHub.Services.Models +@using DSC.TLink.ITv2.MediatR +@inject IPanelAccessCodeService AccessCodeService +@inject IPanelStateService PanelState +@inject IITv2SessionManager SessionManager +@inject ISessionMonitor SessionMonitor +@inject ISnackbar Snackbar +@implements IDisposable + +Access Codes + + + Access Codes + + @if (_sessionId is null) + { + No active panel session. + } + else + { + + + Session: @_sessionId + + + + @(_reading ? "Reading..." : "Read Access Codes") + + + + @if (_lastResult is not null) + { + + @if (_lastResult.Success) + { + Read completed: @_lastResult.ReadCount OK, @_lastResult.FailedCount failed. + } + else + { + Read failed: @_lastResult.ErrorMessage + } + + } + + + @if (_session?.AccessCodes.Any() == true) + { + + + + + # + Label + Code + Length + Proximity Tag + Partitions + Updated + + + + @foreach (var item in _session.AccessCodes.Values.OrderBy(x => x.UserIndex)) + { + + @item.UserIndex + @(string.IsNullOrWhiteSpace(item.Label) ? "-" : item.Label) + @FormatCode(item) + @(item.CodeLength?.ToString() ?? "-") + @(item.HasProximityTag ? "Yes" : "-") + @(item.Partitions.Count == 0 ? "-" : string.Join(",", item.Partitions)) + @item.LastUpdated.ToLocalTime().ToString("HH:mm:ss") + + } + + + + } + else + { + No access codes loaded yet. + } + } + + +@code { + private string? _sessionId; + private SessionState? _session; + private bool _reading; + private bool _revealCodes; + private AccessCodeReadResult? _lastResult; + private bool _disposed; + + protected override void OnInitialized() + { + RefreshSession(); + SessionMonitor.SessionsChanged += OnSessionsChanged; + PanelState.SessionStateChanged += OnSessionStateChanged; + } + + private void OnSessionsChanged() + { + InvokeAsync(() => + { + if (_disposed) return; + RefreshSession(); + StateHasChanged(); + }); + } + + private void OnSessionStateChanged(object? sender, SessionStateChangedEventArgs e) + { + if (_sessionId == null || !string.Equals(_sessionId, e.SessionId, StringComparison.OrdinalIgnoreCase)) + return; + + InvokeAsync(() => + { + if (_disposed) return; + _session = PanelState.GetSession(_sessionId); + StateHasChanged(); + }); + } + + private void RefreshSession() + { + var active = SessionManager.GetActiveSessions().ToList(); + if (_sessionId == null || !active.Contains(_sessionId)) + _sessionId = active.FirstOrDefault(); + + _session = _sessionId is null ? null : PanelState.GetSession(_sessionId); + } + + private async Task ReadAccessCodesAsync() + { + if (_sessionId is null) + return; + + _reading = true; + _lastResult = null; + StateHasChanged(); + + try + { + _lastResult = await AccessCodeService.ReadAllAsync(_sessionId, CancellationToken.None); + _session = PanelState.GetSession(_sessionId); + + if (_lastResult.Success) + Snackbar.Add("Access codes read finished", Severity.Success); + else + Snackbar.Add($"Access code read failed: {_lastResult.ErrorMessage}", Severity.Error); + } + finally + { + _reading = false; + StateHasChanged(); + } + } + + private string FormatCode(AccessCodeState code) + { + if (string.IsNullOrEmpty(code.CodeValue)) + return "-"; + if (_revealCodes) + return code.CodeValue; + return new string('*', Math.Max(4, code.CodeValue.Length)); + } + + public void Dispose() + { + _disposed = true; + SessionMonitor.SessionsChanged -= OnSessionsChanged; + PanelState.SessionStateChanged -= OnSessionStateChanged; + } +} diff --git a/NeoHub/NeoHub/Program.cs b/NeoHub/NeoHub/Program.cs index 55b4d83..ad9b76f 100644 --- a/NeoHub/NeoHub/Program.cs +++ b/NeoHub/NeoHub/Program.cs @@ -68,6 +68,7 @@ public static void Main(string[] args) // Application services builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/NeoHub/NeoHub/Services/AccessCodeReadResult.cs b/NeoHub/NeoHub/Services/AccessCodeReadResult.cs new file mode 100644 index 0000000..a7afaf1 --- /dev/null +++ b/NeoHub/NeoHub/Services/AccessCodeReadResult.cs @@ -0,0 +1,8 @@ +namespace NeoHub.Services +{ + public record AccessCodeReadResult(bool Success, string? ErrorMessage = null) + { + public int ReadCount { get; init; } + public int FailedCount { get; init; } + } +} diff --git a/NeoHub/NeoHub/Services/Handlers/PanelConfigurationHandler.cs b/NeoHub/NeoHub/Services/Handlers/PanelConfigurationHandler.cs index 11aeecd..93907aa 100644 --- a/NeoHub/NeoHub/Services/Handlers/PanelConfigurationHandler.cs +++ b/NeoHub/NeoHub/Services/Handlers/PanelConfigurationHandler.cs @@ -70,13 +70,14 @@ public async Task Handle(SessionConnectedNotification notification, Cancellation } _logger.LogInformation( - "Panel capabilities: {MaxZones} zones, {MaxPartitions} partitions", - capabilities.MaxZones, capabilities.MaxPartitions); + "Panel capabilities: {MaxZones} zones, {MaxPartitions} partitions, {MaxUsers} users", + capabilities.MaxZones, capabilities.MaxPartitions, capabilities.MaxUsers); _panelState.UpdateSession(sessionId, s => { s.MaxZones = capabilities.MaxZones; s.MaxPartitions = capabilities.MaxPartitions; + s.MaxUsers = capabilities.MaxUsers; }); var connectionSettings = _connectionSettings.CurrentValue.Connections diff --git a/NeoHub/NeoHub/Services/IPanelAccessCodeService.cs b/NeoHub/NeoHub/Services/IPanelAccessCodeService.cs new file mode 100644 index 0000000..1cecb6a --- /dev/null +++ b/NeoHub/NeoHub/Services/IPanelAccessCodeService.cs @@ -0,0 +1,7 @@ +namespace NeoHub.Services +{ + public interface IPanelAccessCodeService + { + Task ReadAllAsync(string sessionId, CancellationToken ct); + } +} diff --git a/NeoHub/NeoHub/Services/Models/AccessCodeState.cs b/NeoHub/NeoHub/Services/Models/AccessCodeState.cs new file mode 100644 index 0000000..d27a54c --- /dev/null +++ b/NeoHub/NeoHub/Services/Models/AccessCodeState.cs @@ -0,0 +1,22 @@ +namespace NeoHub.Services.Models +{ + /// + /// In-memory representation of a panel access code slot. + /// Contains sensitive data (full code value) and must not be persisted. + /// + public class AccessCodeState + { + public int UserIndex { get; set; } + public string? Label { get; set; } + public string? CodeValue { get; set; } + public int? CodeLength { get; set; } + public bool IsActive { get; set; } + public bool HasProximityTag { get; set; } + public List Partitions { get; set; } = new(); + public byte[] RawAccessCode { get; set; } = Array.Empty(); + public byte[] RawAttributes { get; set; } = Array.Empty(); + public byte[] RawPartitionAssignments { get; set; } = Array.Empty(); + public byte[] RawConfiguration { get; set; } = Array.Empty(); + public DateTime LastUpdated { get; set; } = DateTime.UtcNow; + } +} diff --git a/NeoHub/NeoHub/Services/Models/SessionState.cs b/NeoHub/NeoHub/Services/Models/SessionState.cs index 5c4f6d3..6ffa084 100644 --- a/NeoHub/NeoHub/Services/Models/SessionState.cs +++ b/NeoHub/NeoHub/Services/Models/SessionState.cs @@ -12,8 +12,11 @@ public class SessionState public ConnectionPhase ConnectionPhase { get; set; } public int MaxZones { get; set; } public int MaxPartitions { get; set; } + public int MaxUsers { get; set; } public Dictionary Partitions { get; } = new(); public Dictionary Zones { get; } = new(); + public Dictionary AccessCodes { get; } = new(); + public DateTime? AccessCodesLastReadAt { get; set; } /// /// Installer configuration data read via SectionRead. @@ -69,4 +72,4 @@ public class SessionState public DateTime LastUpdated { get; set; } = DateTime.UtcNow; } -} \ No newline at end of file +} diff --git a/NeoHub/NeoHub/Services/PanelAccessCodeService.cs b/NeoHub/NeoHub/Services/PanelAccessCodeService.cs new file mode 100644 index 0000000..55ac2e1 --- /dev/null +++ b/NeoHub/NeoHub/Services/PanelAccessCodeService.cs @@ -0,0 +1,390 @@ +using DSC.TLink.ITv2; +using DSC.TLink.ITv2.Enumerations; +using DSC.TLink.ITv2.MediatR; +using DSC.TLink.ITv2.Messages; +using MediatR; +using Microsoft.Extensions.Options; +using NeoHub.Services.Settings; + +namespace NeoHub.Services +{ + public class PanelAccessCodeService : IPanelAccessCodeService + { + private readonly IMediator _mediator; + private readonly IPanelStateService _panelState; + private readonly IOptionsMonitor _appSettings; + private readonly ILogger _logger; + + /// + /// How long to wait for the panel to confirm programming mode entry (0x0702 LeadIn) + /// after sending ConfigurationEnter (0x0704). + /// + private static readonly TimeSpan ProgrammingModeTimeout = TimeSpan.FromSeconds(10); + + public PanelAccessCodeService( + IMediator mediator, + IPanelStateService panelState, + IOptionsMonitor appSettings, + ILogger logger) + { + _mediator = mediator; + _panelState = panelState; + _appSettings = appSettings; + _logger = logger; + } + + public async Task ReadAllAsync(string sessionId, CancellationToken ct) + { + var session = _panelState.GetSession(sessionId); + if (session == null) + return new AccessCodeReadResult(false, "Session not found"); + + if (session.MaxUsers <= 0) + return new AccessCodeReadResult(false, "Panel reported zero users"); + + _logger.LogInformation("Reading access codes for session {SessionId}, max users: {MaxUsers}", sessionId, session.MaxUsers); + + // Access code commands (0x0736/37/38/3C) are sent as direct ITv2 commands in AccessCodeProgramming mode. + var masterCode = _appSettings.CurrentValue.DefaultAccessCode; + if (string.IsNullOrWhiteSpace(masterCode)) + return new AccessCodeReadResult(false, "Master code (DefaultAccessCode) not configured"); + + if (!await EnterAccessCodeModeAsync(sessionId, masterCode, ct)) + return new AccessCodeReadResult(false, "Failed to enter programming mode"); + + try + { + return await ReadAllUsersAsync(sessionId, session, ct); + } + finally + { + await ExitConfigModeAsync(sessionId, ct); + } + } + + private async Task ReadAllUsersAsync( + string sessionId, Models.SessionState session, CancellationToken ct) + { + var readCount = 0; + var failedCount = 0; + + for (int userIndex = 1; userIndex <= session.MaxUsers; userIndex++) + { + var state = session.AccessCodes.TryGetValue(userIndex, out var existing) + ? existing + : new Models.AccessCodeState { UserIndex = userIndex }; + + bool ok = true; + + var accessCode = await SendRequestAsync( + sessionId, + new AccessCodeReadRequest { UserNumberStart = userIndex, NumberOfUsers = 1 }, + ct); + if (accessCode == null) + { + ok = false; + } + else + { + state.RawAccessCode = accessCode.Data; + state.CodeValue = TryExtractCodeDigits(accessCode.Data); + state.CodeLength = state.CodeValue?.Length; + } + + var attr = await SendRequestAsync( + sessionId, + new AccessCodeAttributeReadRequest { UserNumberStart = userIndex, NumberOfUsers = 1 }, + ct); + if (attr != null) + { + state.RawAttributes = attr.Data; + state.IsActive = IsUserActive(attr.Data); + } + + var partitions = await SendRequestAsync( + sessionId, + new AccessCodePartitionAssignmentReadRequest { UserNumberStart = userIndex, NumberOfUsers = 1 }, + ct); + if (partitions != null) + { + state.RawPartitionAssignments = partitions.Data; + state.Partitions = DecodePartitionAssignments(partitions.Data); + } + + var config = await SendRequestAsync( + sessionId, + new UserCodeConfigurationReadRequest { UserNumberStart = userIndex, NumberOfUsers = 1 }, + ct); + if (config != null) + { + state.RawConfiguration = config.Data; + state.HasProximityTag = ParseHasProximityTag(config.Data); + state.Label = BuildLabel(userIndex, state); + } + + state.LastUpdated = DateTime.UtcNow; + session.AccessCodes[userIndex] = state; + + if (ok) readCount++; + else failedCount++; + } + + session.AccessCodesLastReadAt = DateTime.UtcNow; + _panelState.UpdateSession(sessionId, _ => { }); + + return new AccessCodeReadResult(true) + { + ReadCount = readCount, + FailedCount = failedCount, + }; + } + + /// + /// Enters programming mode for access code operations by sending ConfigurationEnter (0x0704) + /// with ProgrammingMode.AccessCodeProgramming and the panel's master code, + /// then waits for the panel to confirm via the ProgrammingLeadInOut (0x0702) notification. + /// Access code commands (0x0736/37/38/3C) require AccessCodeProgramming mode specifically โ€” + /// InstallersProgramming returns NotInCorrectProgrammingMode. + /// + private async Task EnterAccessCodeModeAsync(string sessionId, string masterCode, CancellationToken ct) + { + var session = _panelState.GetSession(sessionId); + if (session == null) + return false; + + // If panel is already in programming mode, exit first to avoid "partition busy" + if (session.IsInProgrammingMode) + { + _logger.LogDebug("Panel already in programming mode, exiting first before access code read"); + await ExitConfigModeAsync(sessionId, ct); + // Brief pause to let panel process the exit + await Task.Delay(500, ct); + } + + _logger.LogInformation("Entering AccessCodeProgramming mode (0x0704, ProgrammingMode=AccessCodeProgramming, using master code)"); + + var enterResponse = await _mediator.Send(new SessionCommand + { + SessionID = sessionId, + MessageData = new ConfigurationEnter + { + Partition = 1, + ProgrammingMode = ProgrammingMode.AccessCodeProgramming, + AccessCode = masterCode, + ReadWrite = ConfigurationEnter.ReadWriteAccessEnum.ReadOnlyMode + } + }, ct); + + if (!enterResponse.Success) + { + _logger.LogWarning("Failed to enter Access Code Programming mode: {Error}", enterResponse.ErrorMessage); + return false; + } + + _logger.LogDebug("ConfigurationEnter (0x0704) accepted, waiting for ProgrammingLeadIn (0x0702)..."); + + // Wait for the panel to confirm programming mode via 0x0702 LeadIn notification. + // The ProgrammingLeadInOutHandler sets session.IsInProgrammingMode = true. + var deadline = DateTime.UtcNow + ProgrammingModeTimeout; + while (!session.IsInProgrammingMode && DateTime.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(100, ct); + } + + if (!session.IsInProgrammingMode) + { + _logger.LogWarning("Timed out waiting for ProgrammingLeadIn (0x0702) after {Timeout}s", ProgrammingModeTimeout.TotalSeconds); + // Try to exit cleanly even though we timed out + await ExitConfigModeAsync(sessionId, ct); + return false; + } + + _logger.LogInformation("Panel confirmed Access Code Programming mode (0x0702 LeadIn received)"); + return true; + } + + /// + /// Exits configuration mode by sending ConfigurationExit (0x0701). + /// Always best-effort โ€” logs but does not throw on failure. + /// + private async Task ExitConfigModeAsync(string sessionId, CancellationToken ct) + { + try + { + _logger.LogDebug("Sending ConfigurationExit (0x0701)"); + await _mediator.Send(new SessionCommand + { + SessionID = sessionId, + MessageData = new ConfigurationExit { Partition = 1 } + }, ct); + _logger.LogDebug("Exited configuration mode"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to exit configuration mode (0x0701)"); + } + } + + private async Task SendRequestAsync(string sessionId, IMessageData request, CancellationToken ct) + where T : class, IMessageData + { + // Requests extend CommandMessageBase โ€” ITv2Session sends them directly and waits for response (cmd | 0x4000). + _logger.LogDebug("Sending direct command {Command}", request.GetType().Name); + + var response = await _mediator.Send(new SessionCommand + { + SessionID = sessionId, + MessageData = request + }, ct); + + if (!response.Success) + { + _logger.LogWarning("Access-code command {Command} failed: {Error}", request.GetType().Name, response.ErrorMessage); + return null; + } + + return response.MessageData as T; + } + + // โ”€โ”€ Response parsing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // + // All four access-code response payloads (0x4736/37/38/3C) share a common + // header structure when requesting a single user at a time: + // + // [NumberOfRecords : 1 byte] always 0x01 + // [UserNumber : 1 byte] + // [Unknown : 1 byte] always 0x01 + // [Unknown : 1 byte] always 0x01 + // [DataLength : 1 byte] length of the data portion that follows + // [Data : DataLength bytes] + // + // The 5-byte header must be skipped before parsing the actual data. + + private const int ResponseHeaderSize = 5; + + /// + /// Extracts a BCD-encoded PIN code from an AccessCodeRead (0x4736) response payload. + /// Each data byte encodes two decimal digits (high nibble, low nibble). + /// A byte value of 0xAA indicates an empty/unset code slot. + /// + private static string? TryExtractCodeDigits(byte[] payload) + { + if (payload == null || payload.Length < ResponseHeaderSize + 1) + return null; + + int dataLen = payload[4]; + if (dataLen == 0 || payload.Length < ResponseHeaderSize + dataLen) + return null; + + var sb = new System.Text.StringBuilder(dataLen * 2); + for (int i = ResponseHeaderSize; i < ResponseHeaderSize + dataLen; i++) + { + byte b = payload[i]; + + // 0xAA = empty/unset sentinel (displays as "AAAA" in DLS5) + if (b == 0xAA) + return null; + + int high = (b >> 4) & 0x0F; + int low = b & 0x0F; + + // Valid BCD digits are 0-9 + if (high > 9 || low > 9) + return null; + + sb.Append((char)('0' + high)); + sb.Append((char)('0' + low)); + } + + return sb.Length > 0 ? sb.ToString() : null; + } + + /// + /// Checks if a user slot is active based on AccessCodeAttributeRead (0x4737) response. + /// A non-zero attribute byte indicates an active/configured user. + /// Known values: 0x0C = active with options, 0x00 = empty/disabled. + /// + private static bool IsUserActive(byte[] payload) + { + if (payload == null || payload.Length < ResponseHeaderSize + 1) + return false; + + int dataLen = payload[4]; + if (dataLen == 0 || payload.Length < ResponseHeaderSize + dataLen) + return false; + + return payload[ResponseHeaderSize] != 0x00; + } + + /// + /// Decodes partition assignments from an AccessCodePartitionAssignmentRead (0x4738) response. + /// The data byte(s) after the header are a bitmask: bit N (LSB-first) = partition N+1. + /// + private static List DecodePartitionAssignments(byte[] payload) + { + var result = new List(); + if (payload == null || payload.Length < ResponseHeaderSize + 1) + return result; + + int dataLen = payload[4]; + if (dataLen == 0 || payload.Length < ResponseHeaderSize + dataLen) + return result; + + for (int i = 0; i < dataLen; i++) + { + byte bitmap = payload[ResponseHeaderSize + i]; + for (int bit = 0; bit < 8; bit++) + { + if ((bitmap & (1 << bit)) != 0) + { + int partition = i * 8 + bit + 1; + if (partition <= byte.MaxValue) + result.Add((byte)partition); + } + } + } + + return result; + } + + /// + /// Checks if the user has a proximity tag (keyfob) from UserCodeConfigurationRead (0x473C). + /// Known values: 0x00 = none, 0x01 = standard access code (PIN only), 0x02 = proximity tag. + /// + private static bool ParseHasProximityTag(byte[] payload) + { + if (payload == null || payload.Length < ResponseHeaderSize + 1) + return false; + + int dataLen = payload[4]; + if (dataLen == 0 || payload.Length < ResponseHeaderSize + dataLen) + return false; + + return payload[ResponseHeaderSize] == 0x02; + } + + /// + /// Builds a human-readable label for the user based on their configuration. + /// Mirrors the DLS5 display where user 1 is "Master" and others show their type. + /// + private static string BuildLabel(int userIndex, Models.AccessCodeState state) + { + if (userIndex == 1) + return "Master"; + + var parts = new List(); + + if (state.CodeValue != null) + parts.Add("PIN"); + + if (state.HasProximityTag) + parts.Add("Proximity Tag"); + + if (parts.Count > 0) + return string.Join(" + ", parts); + + return state.IsActive ? "Active" : ""; + } + } +} diff --git a/NeoHub/TLink/ITv2/MessageReceiver.cs b/NeoHub/TLink/ITv2/MessageReceiver.cs index 49989b9..580082a 100644 --- a/NeoHub/TLink/ITv2/MessageReceiver.cs +++ b/NeoHub/TLink/ITv2/MessageReceiver.cs @@ -62,6 +62,21 @@ public static MessageReceiver CreateCommandReceiver(byte senderSequence, byte co public bool TryReceive(ITv2Packet packet) { + if (packet.Message is CommandError globalError && globalError.NackCommand == _senderCommandType) + { + _tcs.TrySetResult(packet.Message); + return true; + } + + // Some panel commands ACK on the original transaction and then deliver + // the actual response as a new inbound transaction (different receiver sequence). + // Accept command-type response globally by command id. + if (_commandSequence is not null && packet.Message is not SimpleAck && packet.Message.Command == _receiveCommandType) + { + _tcs.TrySetResult(packet.Message); + return true; + } + if (packet.ReceiverSequence == _senderSequence) { if (packet.Message is CommandError errorMessage && errorMessage.NackCommand == _senderCommandType) @@ -92,6 +107,23 @@ public bool TryReceive(ITv2Packet packet) public bool TryReceiveSubMessage(IMessageData message) { + if (_commandSequence is not null) + { + if (message is CommandError error && error.NackCommand == _senderCommandType) + { + _tcs.TrySetResult(error); + return true; + } + + // Async command responses can arrive as non-ICommandMessage payloads + // embedded inside MultipleMessagePacket. Match by response command id. + if (message.Command == _receiveCommandType) + { + _tcs.TrySetResult(message); + return true; + } + } + if (_commandSequence is not null && message is ICommandMessage cmd && cmd.CommandSequence == _commandSequence) { _tcs.TrySetResult(cmd); diff --git a/NeoHub/TLink/ITv2/Messages/AccessCodeAttributeReadRequest.cs b/NeoHub/TLink/ITv2/Messages/AccessCodeAttributeReadRequest.cs new file mode 100644 index 0000000..634e672 --- /dev/null +++ b/NeoHub/TLink/ITv2/Messages/AccessCodeAttributeReadRequest.cs @@ -0,0 +1,20 @@ +using DSC.TLink.ITv2.Enumerations; +using DSC.TLink.Serialization; + +namespace DSC.TLink.ITv2.Messages +{ + /// + /// Request for 0x0737 Access Code Attributes Read. + /// Sent as a direct ITv2 command (extends CommandMessageBase) while the panel + /// is in InstallersProgramming mode. The panel responds with 0x4737. + /// + [ITv2Command(ITv2Command.Configuration_Read_Access_Code_Attribute)] + public record AccessCodeAttributeReadRequest : CommandMessageBase + { + [CompactInteger] + public int UserNumberStart { get; init; } + + [CompactInteger] + public int NumberOfUsers { get; init; } + } +} diff --git a/NeoHub/TLink/ITv2/Messages/AccessCodeAttributeReadResponse.cs b/NeoHub/TLink/ITv2/Messages/AccessCodeAttributeReadResponse.cs new file mode 100644 index 0000000..8551ee7 --- /dev/null +++ b/NeoHub/TLink/ITv2/Messages/AccessCodeAttributeReadResponse.cs @@ -0,0 +1,13 @@ +using DSC.TLink.ITv2.Enumerations; + +namespace DSC.TLink.ITv2.Messages +{ + /// + /// Raw payload wrapper for 0x4737 Access Code Attributes Read Response. + /// + [ITv2Command(ITv2Command.Response_Access_Code_Attribute)] + public record AccessCodeAttributeReadResponse : IMessageData + { + public byte[] Data { get; init; } = Array.Empty(); + } +} diff --git a/NeoHub/TLink/ITv2/Messages/AccessCodePartitionAssignmentReadRequest.cs b/NeoHub/TLink/ITv2/Messages/AccessCodePartitionAssignmentReadRequest.cs new file mode 100644 index 0000000..c2a2164 --- /dev/null +++ b/NeoHub/TLink/ITv2/Messages/AccessCodePartitionAssignmentReadRequest.cs @@ -0,0 +1,20 @@ +using DSC.TLink.ITv2.Enumerations; +using DSC.TLink.Serialization; + +namespace DSC.TLink.ITv2.Messages +{ + /// + /// Request for 0x0738 Access Code Partition Assignments Read. + /// Sent as a direct ITv2 command (extends CommandMessageBase) while the panel + /// is in InstallersProgramming mode. The panel responds with 0x4738. + /// + [ITv2Command(ITv2Command.Configuration_Read_Access_Code_Partition_Assignment)] + public record AccessCodePartitionAssignmentReadRequest : CommandMessageBase + { + [CompactInteger] + public int UserNumberStart { get; init; } + + [CompactInteger] + public int NumberOfUsers { get; init; } + } +} diff --git a/NeoHub/TLink/ITv2/Messages/AccessCodePartitionAssignmentReadResponse.cs b/NeoHub/TLink/ITv2/Messages/AccessCodePartitionAssignmentReadResponse.cs new file mode 100644 index 0000000..0180928 --- /dev/null +++ b/NeoHub/TLink/ITv2/Messages/AccessCodePartitionAssignmentReadResponse.cs @@ -0,0 +1,13 @@ +using DSC.TLink.ITv2.Enumerations; + +namespace DSC.TLink.ITv2.Messages +{ + /// + /// Raw payload wrapper for 0x4738 Access Code Partition Assignments Read Response. + /// + [ITv2Command(ITv2Command.Response_Access_Code_Partition_Assignment)] + public record AccessCodePartitionAssignmentReadResponse : IMessageData + { + public byte[] Data { get; init; } = Array.Empty(); + } +} diff --git a/NeoHub/TLink/ITv2/Messages/AccessCodeReadRequest.cs b/NeoHub/TLink/ITv2/Messages/AccessCodeReadRequest.cs new file mode 100644 index 0000000..bbc54b7 --- /dev/null +++ b/NeoHub/TLink/ITv2/Messages/AccessCodeReadRequest.cs @@ -0,0 +1,20 @@ +using DSC.TLink.ITv2.Enumerations; +using DSC.TLink.Serialization; + +namespace DSC.TLink.ITv2.Messages +{ + /// + /// Request for 0x0736 Access Codes Read. + /// Sent as a direct ITv2 command (extends CommandMessageBase) while the panel + /// is in InstallersProgramming mode. The panel responds with 0x4736. + /// + [ITv2Command(ITv2Command.Configuration_Read_Access_Code)] + public record AccessCodeReadRequest : CommandMessageBase + { + [CompactInteger] + public int UserNumberStart { get; init; } + + [CompactInteger] + public int NumberOfUsers { get; init; } + } +} diff --git a/NeoHub/TLink/ITv2/Messages/AccessCodeReadResponse.cs b/NeoHub/TLink/ITv2/Messages/AccessCodeReadResponse.cs new file mode 100644 index 0000000..bbcc206 --- /dev/null +++ b/NeoHub/TLink/ITv2/Messages/AccessCodeReadResponse.cs @@ -0,0 +1,14 @@ +using DSC.TLink.ITv2.Enumerations; + +namespace DSC.TLink.ITv2.Messages +{ + /// + /// Raw payload wrapper for 0x4736 Access Codes Read Response. + /// The payload is left untyped on purpose for initial interoperability testing. + /// + [ITv2Command(ITv2Command.Response_Access_Code)] + public record AccessCodeReadResponse : IMessageData + { + public byte[] Data { get; init; } = Array.Empty(); + } +} diff --git a/NeoHub/TLink/ITv2/Messages/UserCodeConfigurationReadRequest.cs b/NeoHub/TLink/ITv2/Messages/UserCodeConfigurationReadRequest.cs new file mode 100644 index 0000000..1e6b4b5 --- /dev/null +++ b/NeoHub/TLink/ITv2/Messages/UserCodeConfigurationReadRequest.cs @@ -0,0 +1,20 @@ +using DSC.TLink.ITv2.Enumerations; +using DSC.TLink.Serialization; + +namespace DSC.TLink.ITv2.Messages +{ + /// + /// Request for 0x073C User Code Configuration Read. + /// Sent as a direct ITv2 command (extends CommandMessageBase) while the panel + /// is in InstallersProgramming mode. The panel responds with 0x473C. + /// + [ITv2Command(ITv2Command.Configuration_Read_User_Code_Configuration)] + public record UserCodeConfigurationReadRequest : CommandMessageBase + { + [CompactInteger] + public int UserNumberStart { get; init; } + + [CompactInteger] + public int NumberOfUsers { get; init; } + } +} diff --git a/NeoHub/TLink/ITv2/Messages/UserCodeConfigurationReadResponse.cs b/NeoHub/TLink/ITv2/Messages/UserCodeConfigurationReadResponse.cs new file mode 100644 index 0000000..03670c3 --- /dev/null +++ b/NeoHub/TLink/ITv2/Messages/UserCodeConfigurationReadResponse.cs @@ -0,0 +1,13 @@ +using DSC.TLink.ITv2.Enumerations; + +namespace DSC.TLink.ITv2.Messages +{ + /// + /// Raw payload wrapper for 0x473C User Code Configuration Read Response. + /// + [ITv2Command(ITv2Command.Response_User_Code_Configuration)] + public record UserCodeConfigurationReadResponse : IMessageData + { + public byte[] Data { get; init; } = Array.Empty(); + } +} diff --git a/NeoHub/TLink/Serialization/BinarySerializer.cs b/NeoHub/TLink/Serialization/BinarySerializer.cs index d60ddfa..2bbc442 100644 --- a/NeoHub/TLink/Serialization/BinarySerializer.cs +++ b/NeoHub/TLink/Serialization/BinarySerializer.cs @@ -303,11 +303,23 @@ private static TypePlan GetOrCreatePlan(Type type) private static TypePlan BuildPlan(Type type) { + int GetInheritanceDepth(Type? t) + { + int depth = 0; + while (t != null) + { + depth++; + t = t.BaseType; + } + return depth; + } + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) .Where(p => p.CanRead && p.CanWrite && !p.IsDefined(typeof(IgnorePropertyAttribute), false) && (p.GetMethod!.IsPublic || p.GetMethod.IsAssembly)) - .OrderBy(p => p.MetadataToken) + .OrderBy(p => GetInheritanceDepth(p.DeclaringType)) + .ThenBy(p => p.MetadataToken) .ToArray(); // Identify bit field groups