diff --git a/.gitignore b/.gitignore index f5b4c14..e37d81f 100644 --- a/.gitignore +++ b/.gitignore @@ -350,3 +350,6 @@ MigrationBackup/ .ionide/ .idea/ + +# Selenium screenshots +screenshots/ diff --git a/README.md b/README.md index 054e66d..b1d0069 100644 --- a/README.md +++ b/README.md @@ -2,37 +2,108 @@ [![Build Status](https://dev.azure.com/jonathanhorvath/Aporta/_apis/build/status%2Fbytedreamer.Aporta?branchName=develop)](https://dev.azure.com/jonathanhorvath/Aporta/_build/latest?definitionId=2&branchName=develop) -Aporta is an open source physical access controller. The main purpose of the software is to secure doors from unauthorized access. This is accomplished by determining if a person presenting an issued credential is authorized to enter a door. The door is unlocked momentarily by the software if the person has been given access. +Aporta is an open source physical access controller. The main purpose of the software is to secure doors from unauthorized access. This is accomplished by determining if a presented credential is authorized to enter a door. The door is unlocked momentarily by the software if the credential has the appropriate access rights. Aporta doesn't intend to recreate what is already available by existing physical access controllers. The goal is to provide a level of flexibility that is not possible with existing closed physical access control systems. Below are the design considerations that are going to make Aporta truly versatile. - The software is written to be platform independent. This gives Aporta the ability to run on wide range of hardware. The same code runs on Windows, Mac, or Linux, even lower power devices such as Raspberry Pi. - Modularity is built into the architecture of the software. There will be the ability to add custom plugins for enhanced capabilities. -- Open standards are embraced and built into the software. +- Open standards are embraced and built into the software — most notably OSDP, and Z9/Open Community Profile. - Features are not to be restricted by licensing. All the software's capabilities are available without being overburden with expensive and confusing license terms. -## Release Plan ## +## Z9/Open Community Profile ## -The project is early in its development. After working on access control products for many years, my inclination is that there are a large number of features required for a viable access controller. The list below is an attempt to limit the feature targeted in the first release. +Aporta uses the Z9/Open Community Profile data model for data storage, as well as host communications. The protocol and library are here: https://github.com/z9security/z9open-community. This profile covers important core electronic door access control functionality: credentials, access levels, schedules, holidays, card formats, events, status, door modes, and device actions such as momentary unlock. -- Easy installation **(Done)** - - Windows installer **(Done)** - - Debian packages **(Done)** +## Z9/Flex Community Profile ## + +Aporta supports the Z9/Flex Community Profile as a JSON/REST API, intended for use by the built-in web user interface. This API is not generally used for data synchronization with other systems, as Z9/Open Community Profile is much better suited for high-performance data synchronization. The protocol and library are here: https://github.com/z9security/z9flex-community. Z9/Open Community Profile and Z9/Flex Community Profile are essentially the same data model with 2 different representations (Google Protobuf, and JSON/REST, respectively). + +## Completed ## + +### Aporta +- Easy installation + - Windows installer + - Debian packages - Self hosted web management - - SSL required by default **(Done)** - - A master password to gain access + - SSL required by default +- OSDP Driver + - Install new devices with security channel encryption + - Process standard Wiegand card reads + - Detect if device is online + - Control output +- Access Control + - Enroll new credential + - Read entire card data for card number + - Log access events + - Door access privilege management from Credentials page (assign/remove doors, set schedule restrictions) + - Door Access column on Credentials page showing assigned doors with schedule names + - Card format management (create, edit, delete binary card formats with parity, field, and static elements) + - Holiday management (create, edit, delete holidays with repeat and schedule preservation options) + +### Z9/Open Community Profile +- Connection & Protocol + - Outbound TCP connection from panel to host (panel-initiates model) + - Protobuf-based Z9/Open Community protocol with identification handshake + - Automatic configuration download on connect (DbChange messages) +- Device Management + - OSDP credential readers over TCP/IP, configured from Dev messages + - Door model with strike actuator, door contact sensor, and REX sensor — all wired automatically from the device hierarchy + - Credential reader online/offline status reporting + - Device status reporting to host via DevStatus messages +- Credential & Data Format Support + - Credential mapping from Cred objects (card number, facility code, enabled/disabled, effective/expiry dates) + - Credentials stored by card number; card reads decoded using DataFormat definitions to extract and match by card number + - Card format definitions (DataFormat, DataLayout, DataGroup) for decoding raw card data into meaningful fields + - Credential templates, data layouts, and data formats persisted to local SQLite database +- Access Control Decisions + - Privilege-based access control using DoorAccessPriv and CredPrivBinding + - Door-specific privilege checking (precision access and privilege group elements) + - Element-level schedule restrictions on DoorAccessPrivElements + - Schedule enforcement with SchedRestriction (time-of-day, day-of-week) + - Holiday-aware schedule evaluation + - Credential status checks: disabled, not yet effective, expired + - Access granted and denied events reported to host with appropriate EvtSubCode +- Door Control + - Door strike actuation on access grant with configurable strike time from DoorConfig + - Extended strike time support via credential DoorAccessModifiers (extDoorTime flag) + - Request to Exit (REX) with configurable activateStrikeOnRex + - Door momentary unlock via DevActionReq from host + - Door mode support: unlocked, locked, card-only, card+confirming PIN, unique PIN only, card-only or unique PIN + - PIN support: keypad entry buffering, confirming PIN validation, unique PIN lookup + - Default door mode applied from DoorConfig on door creation + - Runtime door mode change via DevActionReq (DoorModeChange) from host, including reset-to-default + - REX behavior respects current door mode +- Door Alarm Monitoring + - Door state reporting: unlocked, locked, opened, closed + - Door forced open detection with DoorForced/DoorNotForced events + - Door held open detection with DoorHeld/DoorNotHeld events + - Extended held time support via credential extDoorTime flag +- Events + - CONTROLLER_STARTUP on service start + - MOMENTARY_UNLOCK audit trail when host initiates a momentary unlock action + - TAMPER / TAMPER_NORMAL events with dev state tracking when OSDP reader reports tamper + - CRED_READER_POWER_CYCLE event when OSDP reader reports power cycle + - RAW_CRED_READ event in primary mode for credential enrollment + - SCHED_ACTIVE / SCHED_INACTIVE events with per-minute schedule evaluation + +## API Architecture ## + +The web UI communicates with the server through the Z9/Flex Community Profile API (e.g., `/cred/list`, `/door/list`). The Extensions and Driver Configuration pages still use the original REST API (`/api/Extensions`), which is maintained as-is for now. + +## TODO ## + +### Aporta +- Self hosted web management + - Configurable user accounts (currently uses default credentials) - OSDP Driver - - Install new devices with security channel encryption **(Done)** - - Process standard Wiegand card reads **(Done)** - - Detect if device is online **(Done)** - - Control output **(Done)** - Notify when input is tripped - - Access Control - - Enroll new cardholder **(Done)** - - Basic access level assignment - - Read entire card data for card number **(Done)** - - Card number is a non-reversible hash **(Done)** - - Log access events **(Done)** + +### Z9/Open Community Profile +- DbChange: Handle device deletions (devDelete, devDeleteAll) — currently silently ignored, causing stale devices to accumulate +- EvtControl: Implement event flow control (StartContinuous, ConsumeUpTo) for generic delivery of events persisted while offline +- Events: CONTROLLER_ONLINE / CONTROLLER_OFFLINE — for IO_CONTROLLER_SUB subcontrollers (requires subcontroller device model first) +- Events: Power/battery (POWER_PRIMARY/OFF_PRIMARY/NONE, BATTERY_OK/LOW/FAIL/CRITICAL) — needs a way of expressing that an input/sensor is used for this purpose ## Installation ## @@ -70,7 +141,7 @@ The log files will be placed in the standard Linux logging directory ### Quick Start -After installing Aporta, browse to the local web url ```https://localhost:8443``` to get started. A [Quick Start Guide](https://github.com/bytedreamer/Aporta/wiki/Quick-start-guide) can be found in the Wiki section. +After installing Aporta, browse to the local web url ```https://localhost:8443``` to get started. You will be presented with a login screen. The default credentials are username ```admin``` and password ```pass```. A [Quick Start Guide](https://github.com/bytedreamer/Aporta/wiki/Quick-start-guide) can be found in the Wiki section. ### Configuration ### @@ -106,3 +177,5 @@ General information about configuring and setting up the Aporta project for deve [A summary of all of the Core Abstractions and Concepts](https://github.com/bytedreamer/Aporta/wiki/Aporta-Core-Abstractions-and-Concepts) used in the Aporta code. +Note: The Wiki documents the pre-Z9/Open Community Profile architecture. + diff --git a/src/Aporta.Core/Aporta.Core.csproj b/src/Aporta.Core/Aporta.Core.csproj index 9ed755b..af986d0 100644 --- a/src/Aporta.Core/Aporta.Core.csproj +++ b/src/Aporta.Core/Aporta.Core.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Aporta.Core/DataAccess/Aporta.sqlite b/src/Aporta.Core/DataAccess/Aporta.sqlite deleted file mode 100644 index f76813d..0000000 Binary files a/src/Aporta.Core/DataAccess/Aporta.sqlite and /dev/null differ diff --git a/src/Aporta.Core/DataAccess/Migrations/_0000_InitialCreate.cs b/src/Aporta.Core/DataAccess/Migrations/_0000_InitialCreate.cs index 494fb18..01e3391 100644 --- a/src/Aporta.Core/DataAccess/Migrations/_0000_InitialCreate.cs +++ b/src/Aporta.Core/DataAccess/Migrations/_0000_InitialCreate.cs @@ -6,25 +6,113 @@ namespace Aporta.Core.DataAccess.Migrations { public class _0000_InitialCreate : IMigration { - public int Version => 0; - - public string Name => "Initial create"; + // Version 100 to distinguish from old column-based schema (versions 0-10) + public int Version => 100; + + public string Name => "Initial create with JSON document storage"; public async Task PerformUpdate(IDbConnection connection, IDbTransaction transaction) { await connection.ExecuteAsync( - @"create table schema_info - ( - id integer not null - constraint schema_info_pk - primary key, - name text not null, - timestamp datetime not null - ); - - create unique index schema_info_id_uindex - on schema_info (id);", + @" + -- Schema info table (unchanged) + CREATE TABLE schema_info ( + id INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + timestamp DATETIME NOT NULL + ); + CREATE UNIQUE INDEX schema_info_id_uindex ON schema_info (id); + + -- Extension table (GUID primary key) + CREATE TABLE extension ( + id TEXT NOT NULL PRIMARY KEY, + data TEXT NOT NULL + ); + CREATE UNIQUE INDEX extension_id_uindex ON extension (id); + + -- Global setting table (string primary key) + CREATE TABLE global_setting ( + name TEXT NOT NULL PRIMARY KEY, + data TEXT NOT NULL + ); + + -- Credential Template table + CREATE TABLE cred_template ( + id INTEGER NOT NULL PRIMARY KEY, + data TEXT NOT NULL + ); + + -- Data Layout table + CREATE TABLE data_layout ( + id INTEGER NOT NULL PRIMARY KEY, + data TEXT NOT NULL + ); + + -- Data Format table + CREATE TABLE data_format ( + id INTEGER NOT NULL PRIMARY KEY, + data TEXT NOT NULL + ); + + -- Privilege table + CREATE TABLE priv ( + id INTEGER NOT NULL PRIMARY KEY, + data TEXT NOT NULL + ); + + -- Schedule table + CREATE TABLE sched ( + id INTEGER NOT NULL PRIMARY KEY, + data TEXT NOT NULL + ); + + -- Holiday table + CREATE TABLE hol ( + id INTEGER NOT NULL PRIMARY KEY, + data TEXT NOT NULL + ); + + -- Holiday Calendar table + CREATE TABLE hol_cal ( + id INTEGER NOT NULL PRIMARY KEY, + data TEXT NOT NULL + ); + + -- Holiday Type table + CREATE TABLE hol_type ( + id INTEGER NOT NULL PRIMARY KEY, + data TEXT NOT NULL + ); + + -- Z9 Device table (stores full Dev proto messages) + CREATE TABLE z9_dev ( + id INTEGER NOT NULL PRIMARY KEY, + data TEXT NOT NULL, + external_dev_mod_id TEXT + ); + + -- Encryption Key table + CREATE TABLE encryption_key ( + id INTEGER NOT NULL PRIMARY KEY, + data TEXT NOT NULL + ); + + -- Z9 Credential table (stores full proto including privBindings) + CREATE TABLE z9_cred ( + id INTEGER NOT NULL PRIMARY KEY, + data TEXT NOT NULL, + cred_num TEXT + ); + CREATE UNIQUE INDEX z9_cred_cred_num_uindex ON z9_cred(cred_num) WHERE cred_num IS NOT NULL; + + -- Z9 Event table + CREATE TABLE z9_evt ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + data TEXT NOT NULL + ); + +", transaction: transaction); } } -} \ No newline at end of file +} diff --git a/src/Aporta.Core/DataAccess/Migrations/_0001_AddExtensionTable.cs b/src/Aporta.Core/DataAccess/Migrations/_0001_AddExtensionTable.cs deleted file mode 100644 index befb4a7..0000000 --- a/src/Aporta.Core/DataAccess/Migrations/_0001_AddExtensionTable.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Data; -using System.Threading.Tasks; -using Dapper; - -namespace Aporta.Core.DataAccess.Migrations -{ - public class _0001_AddExtensionTable : IMigration - { - public int Version => 1; - - public string Name => "Add extension table"; - - public async Task PerformUpdate(IDbConnection connection, IDbTransaction transaction) - { - await connection.ExecuteAsync( - @"create table extension - ( - id text not null - constraint extension_pk - primary key, - name text not null, - enabled integer default 0 not null, - configuration text not null - ); - - create unique index extension_id_uindex - on extension (id);", - transaction: transaction); - } - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Migrations/_0002_AddEndpointTable.cs b/src/Aporta.Core/DataAccess/Migrations/_0002_AddEndpointTable.cs deleted file mode 100644 index 65ebcde..0000000 --- a/src/Aporta.Core/DataAccess/Migrations/_0002_AddEndpointTable.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Data; -using System.Threading.Tasks; -using Dapper; - -namespace Aporta.Core.DataAccess.Migrations -{ - public class _0002_AddEndpointTable : IMigration - { - public int Version => 2; - - public string Name => "Add endpoint table"; - - public async Task PerformUpdate(IDbConnection connection, IDbTransaction transaction) - { - await connection.ExecuteAsync( - @"create table endpoint - ( - id integer not null - constraint endpoint_pk - primary key autoincrement, - name text not null, - driver_id text not null, - endpoint_type integer not null, - extension_id text not null - references extension - on update cascade on delete cascade - ); - - create unique index endpoint_id_uindex - on endpoint (id);", - transaction: transaction); - } - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Migrations/_0003_AddOutputTable.cs b/src/Aporta.Core/DataAccess/Migrations/_0003_AddOutputTable.cs deleted file mode 100644 index 3cfc7b9..0000000 --- a/src/Aporta.Core/DataAccess/Migrations/_0003_AddOutputTable.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Data; -using System.Threading.Tasks; -using Dapper; - -namespace Aporta.Core.DataAccess.Migrations -{ - public class _0003_AddOutputTable : IMigration - { - public int Version => 3; - - public string Name => "Add output table"; - - public async Task PerformUpdate(IDbConnection connection, IDbTransaction transaction) - { - await connection.ExecuteAsync( - @"create table output - ( - id integer not null - constraint output_pk - primary key autoincrement, - endpoint_id integer not null - constraint output_endpoint_id_fk - references endpoint - on update cascade on delete cascade, - name text not null - ); - - create unique index output_id_uindex - on output (id);", - transaction: transaction); - } - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Migrations/_0004_AddInputTable.cs b/src/Aporta.Core/DataAccess/Migrations/_0004_AddInputTable.cs deleted file mode 100644 index bf4b04a..0000000 --- a/src/Aporta.Core/DataAccess/Migrations/_0004_AddInputTable.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Data; -using System.Threading.Tasks; -using Dapper; - -namespace Aporta.Core.DataAccess.Migrations -{ - public class _0004_AddInputTable : IMigration - { - public int Version => 4; - - public string Name => "Add input table"; - - public async Task PerformUpdate(IDbConnection connection, IDbTransaction transaction) - { - await connection.ExecuteAsync( - @"create table input - ( - id integer not null - constraint input_pk - primary key autoincrement, - endpoint_id integer not null - constraint input_endpoint_id_fk - references endpoint - on update cascade on delete cascade, - name text not null - ); - - create unique index input_id_uindex - on input (id);", - transaction: transaction); - } - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Migrations/_0005_AddDoorTable.cs b/src/Aporta.Core/DataAccess/Migrations/_0005_AddDoorTable.cs deleted file mode 100644 index cc39e6e..0000000 --- a/src/Aporta.Core/DataAccess/Migrations/_0005_AddDoorTable.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Data; -using System.Threading.Tasks; -using Dapper; - -namespace Aporta.Core.DataAccess.Migrations -{ - public class _0005_AddDoorTable : IMigration - { - public int Version => 5; - - public string Name => "Add Door table"; - - public async Task PerformUpdate(IDbConnection connection, IDbTransaction transaction) - { - await connection.ExecuteAsync( - @"create table door - ( - id integer not null - constraint door_pk - primary key autoincrement, - in_access_endpoint_id integer - constraint in_access_endpoint_id_fk - references endpoint - on update cascade on delete cascade, - out_access_endpoint_id integer - constraint out_access_endpoint_id_fk - references endpoint - on update cascade on delete cascade, - door_contact_endpoint_id integer - constraint door_contact_endpoint_id_fk - references endpoint - on update cascade on delete cascade, - request_to_exit_endpoint_id integer - constraint request_to_exit_endpoint_id_fk - references endpoint - on update cascade on delete cascade, - door_strike_endpoint_id integer - constraint door_strike_endpoint_id_fk - references endpoint - on update cascade on delete cascade, - name text not null - ); - - create unique index door_id_uindex - on door (id);", - transaction: transaction); - } - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Migrations/_0006_AddGlobalSettingTable.cs b/src/Aporta.Core/DataAccess/Migrations/_0006_AddGlobalSettingTable.cs deleted file mode 100644 index e60a112..0000000 --- a/src/Aporta.Core/DataAccess/Migrations/_0006_AddGlobalSettingTable.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Data; -using System.Threading.Tasks; -using Dapper; - -namespace Aporta.Core.DataAccess.Migrations -{ - public class _0006_AddGlobalSettingTable : IMigration - { - public int Version => 6; - - public string Name => "Add Global Setting table"; - - public async Task PerformUpdate(IDbConnection connection, IDbTransaction transaction) - { - await connection.ExecuteAsync( - @"create table global_setting - ( - name text not null - constraint global_setting_pk - primary key, - value text not null - ); - - create unique index global_setting_name_uindex - on global_setting (name);", - transaction: transaction); - } - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Migrations/_0007_AddCredentialTable.cs b/src/Aporta.Core/DataAccess/Migrations/_0007_AddCredentialTable.cs deleted file mode 100644 index c7f8b19..0000000 --- a/src/Aporta.Core/DataAccess/Migrations/_0007_AddCredentialTable.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Data; -using System.Threading.Tasks; -using Dapper; - -namespace Aporta.Core.DataAccess.Migrations -{ - public class _0007_AddCredentialTable : IMigration - { - public int Version => 7; - - public string Name => "Add Credential table"; - - public async Task PerformUpdate(IDbConnection connection, IDbTransaction transaction) - { - await connection.ExecuteAsync( - @"create table credential - ( - id integer not null - constraint credential_pk - primary key autoincrement, - number text not null, - enroll_date datetime not null - ); - - create unique index credential_id_uindex - on credential (id);", - transaction: transaction); - } - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Migrations/_0008_AddPersonTable.cs b/src/Aporta.Core/DataAccess/Migrations/_0008_AddPersonTable.cs deleted file mode 100644 index ffd04a9..0000000 --- a/src/Aporta.Core/DataAccess/Migrations/_0008_AddPersonTable.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Data; -using System.Threading.Tasks; -using Dapper; - -namespace Aporta.Core.DataAccess.Migrations -{ - public class _0008_AddPersonTable : IMigration - { - public int Version => 8; - - public string Name => "Add Person table"; - - public async Task PerformUpdate(IDbConnection connection, IDbTransaction transaction) - { - await connection.ExecuteAsync( - @"create table person - ( - id integer not null - constraint person_pk - primary key autoincrement, - first_name text, - last_name text, - enabled integer default 0 not null - ); - - create unique index person_id_uindex - on person (id); - - create table credential_assignment - ( - person_id integer not null - references person, - credential_id integer not null - constraint credential_assignment_pk - primary key - references credential, - enabled integer default 0 not null - ); - - create unique index credential_assignment_credential_id_uindex - on credential_assignment (credential_id);", - transaction: transaction); - } - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Migrations/_0009_AddEventTable.cs b/src/Aporta.Core/DataAccess/Migrations/_0009_AddEventTable.cs deleted file mode 100644 index d4d4476..0000000 --- a/src/Aporta.Core/DataAccess/Migrations/_0009_AddEventTable.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Data; -using System.Threading.Tasks; -using Dapper; - -namespace Aporta.Core.DataAccess.Migrations -{ - public class _0009_AddEventTable : IMigration - { - public int Version => 9; - - public string Name => "Add Event table"; - - public async Task PerformUpdate(IDbConnection connection, IDbTransaction transaction) - { - await connection.ExecuteAsync( - @"create table event - ( - id integer not null - constraint event_pk - primary key autoincrement, - endpoint_id integer not null - constraint endpoint_id_fk - references endpoint, - timestamp datetime not null, - event_type integer not null, - data text not null - ); - - create unique index event_id_uindex - on event (id); - - create unique index credential_number_uindex - on credential (number); - ", - transaction: transaction); - } - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Migrations/_0010_AddLastEventToCredentialTable.cs b/src/Aporta.Core/DataAccess/Migrations/_0010_AddLastEventToCredentialTable.cs deleted file mode 100644 index 2f66916..0000000 --- a/src/Aporta.Core/DataAccess/Migrations/_0010_AddLastEventToCredentialTable.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Data; -using System.Threading.Tasks; -using Dapper; - -namespace Aporta.Core.DataAccess.Migrations -{ - public class _0010_AddLastEventToCredentialTable : IMigration - { - public int Version => 10; - - public string Name => "All Last Event to Credenial table"; - - public async Task PerformUpdate(IDbConnection connection, IDbTransaction transaction) - { - await connection.ExecuteAsync( - @"create table credential_dg_tmp - ( - id integer not null - constraint credential_pk - primary key autoincrement, - number text not null, - last_event integer - ); - - insert into credential_dg_tmp(id, number) - select id, number - from credential; - - drop table credential; - - alter table credential_dg_tmp - rename to credential; - - create unique index credential_id_uindex - on credential (id); - - create unique index credential_number_uindex - on credential (number); - ", - transaction: transaction); - } - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Repositories/BaseRepository.cs b/src/Aporta.Core/DataAccess/Repositories/BaseRepository.cs deleted file mode 100644 index 297609a..0000000 --- a/src/Aporta.Core/DataAccess/Repositories/BaseRepository.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - -using Dapper; - -using Aporta.Shared.Models; - -namespace Aporta.Core.DataAccess.Repositories; - -public abstract class BaseRepository -{ - /// - /// Represents a base repository for accessing data from persistent storage. - /// - /// The type of the data entity. - protected abstract IDataAccess DataAccess { get; } - - /// - /// Represents the SQL SELECT query that is used to select data from the persistent storage. - /// - protected abstract string SqlSelect { get; } - - /// - /// Represents the SQL INSERT statement for inserting a record into the database. - /// - protected abstract string SqlInsert { get; } - - /// - /// Represents a SQL UPDATE statement for updating a record in a database table. - /// - protected abstract string SqlUpdate { get; } - - /// - /// Represents the SQL DELETE statement for a specific entity in the database. - /// - protected abstract string SqlDelete { get; } - - /// - /// Represents the SQL query for retrieving the total number of rows in the associated table. - /// - protected abstract string SqlRowCount { get; } - - /// - /// Retrieves an entity by its ID. - /// - /// The ID of the entity to retrieve. - /// The entity with the specified ID. - public async Task Get(int id) - { - using var connection = DataAccess.CreateDbConnection(); - connection.Open(); - - return await connection.QuerySingleOrDefaultAsync($@"{SqlSelect} where id = @id", new {id}); - } - - /// - /// Retrieves all entities of type T. - /// - /// A task that represents the asynchronous operation. The task result contains an enumerable collection of entities of type T. - public async Task> GetAll() - { - using var connection = DataAccess.CreateDbConnection(); - connection.Open(); - - return await connection.QueryAsync(SqlSelect); - } - - public async Task> GetAll(int pageNumber, int pageSize, string orderBy) - { - using var connection = DataAccess.CreateDbConnection(); - connection.Open(); - - int offset = (pageNumber - 1) * pageSize; - var results = await connection.QueryAsync($@"{SqlSelect} order by {orderBy} desc limit @offset, @pageSize", - new { offset, pageSize }); - - return new PaginatedItemsDto - { - Items = results, - PageNumber = pageNumber, - PageSize = pageSize, - TotalItems = await connection.ExecuteScalarAsync(SqlRowCount) - }; - } - - /// - /// Inserts a record into the database and returns the inserted record's ID. - /// - /// The record to insert. - /// The ID of the inserted record. - public async Task Insert(T record) - { - using var connection = DataAccess.CreateDbConnection(); - connection.Open(); - - int id = await connection.QueryFirstAsync($"{SqlInsert}; select last_insert_rowid()", - InsertParameters(record)); - - InsertId(record, id); - - return id; - } - - /// - /// Updates an entity in the database. - /// - /// The entity to be updated. - public async Task Update(T record) - { - using var connection = DataAccess.CreateDbConnection(); - connection.Open(); - - await connection.ExecuteAsync(SqlUpdate, UpdateParameters(record)); - } - - /// - /// Parameters for SQL INSERT command. - /// - /// The record to be inserted. - /// An anonymous type object with properties to be inserted. - protected abstract object InsertParameters(T record); - - /// - /// Parameters for SQL UPDATE command. - /// - /// The record to be updated. - /// An anonymous type object with properties to be updated. - protected abstract object UpdateParameters(T record); - - /// - /// Sets the new created ID value on an exsiting record. - /// - /// The record to insert into the database. - /// The new created ID of the inserted record. - protected abstract void InsertId(T record, int id); - - /// - /// Deletes a record with the specified ID. - /// - /// The ID of the record to delete. - /// A task representing the asynchronous delete operation. - public async Task Delete(int id) - { - using var connection = DataAccess.CreateDbConnection(); - connection.Open(); - - await connection.ExecuteAsync(SqlDelete, - new {id}); - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Repositories/CredTemplateRepository.cs b/src/Aporta.Core/DataAccess/Repositories/CredTemplateRepository.cs new file mode 100644 index 0000000..eb09aab --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/CredTemplateRepository.cs @@ -0,0 +1,17 @@ +using Z9.Spcore.Proto; + +namespace Aporta.Core.DataAccess.Repositories; + +public class CredTemplateRepository : ProtoJsonRepository +{ + private readonly IDataAccess _dataAccess; + + public CredTemplateRepository(IDataAccess dataAccess) + { + _dataAccess = dataAccess; + } + + protected override IDataAccess DataAccess => _dataAccess; + protected override string TableName => "cred_template"; + protected override int GetUnid(CredTemplate message) => message.Unid; +} diff --git a/src/Aporta.Core/DataAccess/Repositories/CredentialRepository.cs b/src/Aporta.Core/DataAccess/Repositories/CredentialRepository.cs index beec3c6..d90c4a7 100644 --- a/src/Aporta.Core/DataAccess/Repositories/CredentialRepository.cs +++ b/src/Aporta.Core/DataAccess/Repositories/CredentialRepository.cs @@ -1,165 +1,184 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Aporta.Shared.Models; using Dapper; namespace Aporta.Core.DataAccess.Repositories; -public class CredentialRepository : BaseRepository +/// +/// Read-only query helper that projects z9_cred rows into Credential/Person DTOs. +/// No longer owns a table — all data lives in z9_cred. +/// +public class CredentialRepository { + private readonly IDataAccess _dataAccess; + public CredentialRepository(IDataAccess dataAccess) { - DataAccess = dataAccess; + _dataAccess = dataAccess; } - - protected override IDataAccess DataAccess { get; } - - protected override string SqlSelect => @"select credential.id, - credential.number, - credential.last_event lastEvent, - credential_assignment.person_id assignedPersonId, - credential_assignment.enabled - from credential - left join credential_assignment on credential.id = credential_assignment.credential_id"; - - protected override string SqlInsert => @"insert into credential - (number, last_event) values - (@number, @lastEvent)"; - - protected override string SqlUpdate => throw new NotImplementedException(); - - private string SqlAssignmentInsert => @"insert into credential_assignment - (person_id, credential_id, enabled) values - (@personId, @credentialId, @enabled)"; - - private string SqlAssignmentDelete => @"delete from credential_assignment - where person_id = @personId and credential_id = @credentialId"; - - private string SqlUpdateLastEvent => @"update credential - set last_event = @lastEventId - where id = @credentialId"; - - private string SqlAssignedCredentials => SqlSelect + @" where credential.id IN (SELECT credential_id FROM credential_assignment)"; - private string SqlUnassignedCredentials => SqlSelect + @" where credential.id NOT IN (SELECT credential_id FROM credential_assignment)"; - - protected override string SqlDelete => @"delete from credential where id = @id"; - - protected override string SqlRowCount => @"select count(*) from credential"; - - protected override object InsertParameters(Credential credential) + + public async Task Get(int id) { - return new + using var connection = _dataAccess.CreateDbConnection(); + connection.Open(); + + var result = await connection.QuerySingleOrDefaultAsync<(int Id, string CredNum, string CredEnabled)>( + @"SELECT id, cred_num, + json_extract(data, '$.enabled') as CredEnabled + FROM z9_cred WHERE id = @id", + new { id }); + + if (result.Id == 0 && result.CredNum == null && result.CredEnabled == null) return null; + + return new Credential { - number = credential.Number, - lastEvent = credential.LastEvent + Id = result.Id, + Number = result.CredNum, + Enabled = ParseBool(result.CredEnabled) }; } - protected override object UpdateParameters(Credential record) + public async Task> GetAll() { - throw new NotImplementedException(); - } + using var connection = _dataAccess.CreateDbConnection(); + connection.Open(); - protected override void InsertId(Credential credential, int id) - { - credential.Id = id; + var results = await connection.QueryAsync<(int Id, string CredNum, string CredEnabled)>( + @"SELECT id, cred_num, + json_extract(data, '$.enabled') as CredEnabled + FROM z9_cred"); + + return results.Select(r => new Credential + { + Id = r.Id, + Number = r.CredNum, + Enabled = ParseBool(r.CredEnabled) + }); } public async Task AssignedCredential(string cardNumber) { - using var connection = DataAccess.CreateDbConnection(); + using var connection = _dataAccess.CreateDbConnection(); connection.Open(); - var credential = await connection.QuerySingleOrDefaultAsync( - $@"{SqlSelect} where number = @number", - new {number = cardNumber}); + var result = await connection.QuerySingleOrDefaultAsync<(int Id, string CredNum, string CredName, string CredEnabled)>( + @"SELECT id, cred_num, + json_extract(data, '$.name') as CredName, + json_extract(data, '$.enabled') as CredEnabled + FROM z9_cred + WHERE cred_num = @number", + new { number = cardNumber }); - if (credential == null) + if (result.Id == 0 && result.CredNum == null) { return null; } - var personAssignment = await connection.QuerySingleOrDefaultAsync( - "select person_id as personId, enabled from credential_assignment where credential_id = @credentialId", - new {credentialId = credential.Id}); + var credential = new AssignedCredential + { + Id = result.Id, + Number = result.CredNum + }; - if (personAssignment != null) + if (!string.IsNullOrWhiteSpace(result.CredName)) { - var personRepository = new PersonRepository(DataAccess); - credential.Person = await personRepository.Get((int)personAssignment.personId); - credential.Enabled = personAssignment.enabled > 0 && credential.Person.Enabled; + var enabled = ParseBool(result.CredEnabled) ?? true; + credential.Person = ParsePersonFromName(result.CredName, credential.Id, enabled); + credential.Enabled = enabled; } else { credential.Enabled = false; } - - return credential; - } - public async Task> CredentialsAssignedToPerson(int personId) - { - using var connection = DataAccess.CreateDbConnection(); - connection.Open(); - - return await connection.QueryAsync( - $@"{SqlSelect} where credential_assignment.person_id = @personId", - new { personId }); + return credential; } public async Task> Assigned() { - using var connection = DataAccess.CreateDbConnection(); + using var connection = _dataAccess.CreateDbConnection(); connection.Open(); - return await connection.QueryAsync(SqlAssignedCredentials); + var results = await connection.QueryAsync<(int Id, string CredNum, string CredEnabled)>( + @"SELECT id, cred_num, + json_extract(data, '$.enabled') as CredEnabled + FROM z9_cred + WHERE json_extract(data, '$.name') IS NOT NULL + AND cred_num IS NOT NULL + AND cred_num != ''"); + + return results.Select(r => new Credential + { + Id = r.Id, + Number = r.CredNum, + Enabled = ParseBool(r.CredEnabled) + }); } - + public async Task> Unassigned() { - using var connection = DataAccess.CreateDbConnection(); + using var connection = _dataAccess.CreateDbConnection(); connection.Open(); - return await connection.QueryAsync(SqlUnassignedCredentials); + var results = await connection.QueryAsync<(int Id, string CredNum)>( + @"SELECT id, cred_num FROM z9_cred + WHERE json_extract(data, '$.name') IS NULL + AND cred_num IS NOT NULL"); + + return results.Select(r => new Credential + { + Id = r.Id, + Number = r.CredNum + }); } - - public async Task AssignPerson(int credentialId, int personId, bool enabled = true) + + public async Task> Named() { - using var connection = DataAccess.CreateDbConnection(); + using var connection = _dataAccess.CreateDbConnection(); connection.Open(); - - await connection.ExecuteAsync(SqlAssignmentInsert, - new - { - credentialId, - personId, - enabled - }); + + var results = await connection.QueryAsync<(int Id, string CredName, string CredEnabled)>( + @"SELECT id, + json_extract(data, '$.name') as CredName, + json_extract(data, '$.enabled') as CredEnabled + FROM z9_cred + WHERE json_extract(data, '$.name') IS NOT NULL"); + + return results.Select(r => + { + var enabled = ParseBool(r.CredEnabled) ?? true; + return ParsePersonFromName(r.CredName, r.Id, enabled); + }); } - - public async Task RevokePerson(int credentialId, int personId) + + public static Person ParsePersonFromName(string credName, int id, bool enabled) { - using var connection = DataAccess.CreateDbConnection(); - connection.Open(); - - await connection.ExecuteAsync(SqlAssignmentDelete, - new - { - credentialId, - personId, - }); + var person = new Person { Id = id, Enabled = enabled }; + if (credName == null) + return person; + + var commaIndex = credName.IndexOf(", ", StringComparison.Ordinal); + if (commaIndex >= 0) + { + person.LastName = credName.Substring(0, commaIndex); + person.FirstName = credName.Substring(commaIndex + 2); + } + else + { + person.FirstName = credName; + } + return person; } - public async Task UpdateLastEvent(int credentialId, int lastEventId) + private static bool? ParseBool(string value) { - using var connection = DataAccess.CreateDbConnection(); - connection.Open(); - - await connection.ExecuteAsync(SqlUpdateLastEvent, - new - { - credentialId, lastEventId - }); + if (value == null) return null; + // SQLite json_extract returns "true"/"false" or "1"/"0" + if (bool.TryParse(value, out var b)) return b; + if (int.TryParse(value, out var i)) return i > 0; + return null; } -} \ No newline at end of file +} diff --git a/src/Aporta.Core/DataAccess/Repositories/DataFormatRepository.cs b/src/Aporta.Core/DataAccess/Repositories/DataFormatRepository.cs new file mode 100644 index 0000000..7ca9a65 --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/DataFormatRepository.cs @@ -0,0 +1,17 @@ +using Z9.Spcore.Proto; + +namespace Aporta.Core.DataAccess.Repositories; + +public class DataFormatRepository : ProtoJsonRepository +{ + private readonly IDataAccess _dataAccess; + + public DataFormatRepository(IDataAccess dataAccess) + { + _dataAccess = dataAccess; + } + + protected override IDataAccess DataAccess => _dataAccess; + protected override string TableName => "data_format"; + protected override int GetUnid(DataFormat message) => message.Unid; +} diff --git a/src/Aporta.Core/DataAccess/Repositories/DataLayoutRepository.cs b/src/Aporta.Core/DataAccess/Repositories/DataLayoutRepository.cs new file mode 100644 index 0000000..ab886ae --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/DataLayoutRepository.cs @@ -0,0 +1,17 @@ +using Z9.Spcore.Proto; + +namespace Aporta.Core.DataAccess.Repositories; + +public class DataLayoutRepository : ProtoJsonRepository +{ + private readonly IDataAccess _dataAccess; + + public DataLayoutRepository(IDataAccess dataAccess) + { + _dataAccess = dataAccess; + } + + protected override IDataAccess DataAccess => _dataAccess; + protected override string TableName => "data_layout"; + protected override int GetUnid(DataLayout message) => message.Unid; +} diff --git a/src/Aporta.Core/DataAccess/Repositories/DoorRepository.cs b/src/Aporta.Core/DataAccess/Repositories/DoorRepository.cs deleted file mode 100644 index 3e606c4..0000000 --- a/src/Aporta.Core/DataAccess/Repositories/DoorRepository.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Aporta.Shared.Models; - -namespace Aporta.Core.DataAccess.Repositories; - -public class DoorRepository : BaseRepository -{ - public DoorRepository(IDataAccess dataAccess) - { - DataAccess = dataAccess; - } - - protected override IDataAccess DataAccess { get; } - - protected override string SqlSelect => @"select door.id, - door.in_access_endpoint_id as inAccessEndpointId, - door.out_access_endpoint_id as outAccessEndpointId, - door.door_contact_endpoint_id as doorContactEndpointId, - door.request_to_exit_endpoint_id as requestToExitEndpointId, - door.door_strike_endpoint_id as doorStrikeEndpointId, - door.name - from door"; - - protected override string SqlInsert => @"insert into door - (in_access_endpoint_id, out_access_endpoint_id, - door_contact_endpoint_id, request_to_exit_endpoint_id, - door_strike_endpoint_id, name) values - (@inAccessEndpointId, @outAccessEndpointId, @doorContactEndpointId, - @requestToExitEndpointId, @doorStrikeEndpointId, @name)"; - - protected override string SqlUpdate =>throw new System.NotImplementedException(); - - protected override string SqlDelete => @"delete from door where id = @id"; - - protected override string SqlRowCount => @"select count(*) from door"; - - protected override object InsertParameters(Door door) - { - return new - { - inAccessEndpointId = door.InAccessEndpointId, - outAccessEndpointId = door.OutAccessEndpointId, - doorContactEndpointId = door.DoorContactEndpointId, - requestToExitEndpointId = door.RequestToExitEndpointId, - doorStrikeEndpointId = door.DoorStrikeEndpointId, - name = door.Name - }; - } - - protected override object UpdateParameters(Door record) - { - throw new System.NotImplementedException(); - } - - protected override void InsertId(Door door, int id) - { - door.Id = id; - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Repositories/EncryptionKeyRepository.cs b/src/Aporta.Core/DataAccess/Repositories/EncryptionKeyRepository.cs new file mode 100644 index 0000000..c90022f --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/EncryptionKeyRepository.cs @@ -0,0 +1,17 @@ +using Z9.Spcore.Proto; + +namespace Aporta.Core.DataAccess.Repositories; + +public class EncryptionKeyRepository : ProtoJsonRepository +{ + private readonly IDataAccess _dataAccess; + + public EncryptionKeyRepository(IDataAccess dataAccess) + { + _dataAccess = dataAccess; + } + + protected override IDataAccess DataAccess => _dataAccess; + protected override string TableName => "encryption_key"; + protected override int GetUnid(EncryptionKey message) => message.Unid; +} diff --git a/src/Aporta.Core/DataAccess/Repositories/EndpointRepository.cs b/src/Aporta.Core/DataAccess/Repositories/EndpointRepository.cs deleted file mode 100644 index ccece7d..0000000 --- a/src/Aporta.Core/DataAccess/Repositories/EndpointRepository.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Aporta.Shared.Models; -using Dapper; - -namespace Aporta.Core.DataAccess.Repositories; - -public class EndpointRepository : BaseRepository -{ - public EndpointRepository(IDataAccess dataAccess) - { - DataAccess = dataAccess; - - SqlMapper.AddTypeHandler(new GuidHandler()); - } - - protected override IDataAccess DataAccess { get; } - - protected override string SqlSelect => @"select id, name, endpoint_type as type, driver_id as driverEndpointId, extension_id as extensionId - from endpoint"; - - protected override string SqlInsert => @"insert into endpoint - (name, endpoint_type, driver_id, extension_id) values - (@name, @endpointType, @driverEndpointId, @extensionId)"; - - protected override string SqlUpdate => @"update endpoint - set name = @name - where driver_id = @driverEndpointId and extension_id = @extensionId"; - - - protected override string SqlDelete => @"delete from endpoint - where id = @id"; - - protected override string SqlRowCount => @"select count(*) from endpoint"; - - public async Task> GetForExtension(Guid extensionId) - { - using var connection = DataAccess.CreateDbConnection(); - connection.Open(); - - return await connection.QueryAsync(SqlSelect + @" where extension_id = @extensionId", - new {extensionId}); - } - - /// - protected override object InsertParameters(Endpoint endpoint) - { - return new - { - name = endpoint.Name, - endpointType = endpoint.Type, - driverEndpointId = endpoint.DriverEndpointId, - extensionId = endpoint.ExtensionId - }; - } - - /// - protected override object UpdateParameters(Endpoint endpoint) - { - return new - { - name = endpoint.Name, - driverEndpointId = endpoint.DriverEndpointId, - extensionId = endpoint.ExtensionId - }; - } - - /// - protected override void InsertId(Endpoint endpoint, int id) - { - endpoint.Id = id; - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Repositories/EventRepository.cs b/src/Aporta.Core/DataAccess/Repositories/EventRepository.cs deleted file mode 100644 index 2d7b87a..0000000 --- a/src/Aporta.Core/DataAccess/Repositories/EventRepository.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using Aporta.Shared.Models; - -namespace Aporta.Core.DataAccess.Repositories; - -public class EventRepository : BaseRepository -{ - public EventRepository(IDataAccess dataAccess) - { - DataAccess = dataAccess; - } - - protected override IDataAccess DataAccess { get; } - - protected override string SqlSelect => @"select event.id, - event.endpoint_id as endpointId, - event.timestamp, - event.event_type as type, - event.data - from event"; - - protected override string SqlInsert => @"insert into event - (endpoint_id, timestamp, event_type, data) values - (@endpointId, @timestamp, @type, @data)"; - - protected override string SqlUpdate => throw new NotImplementedException(); - - protected override string SqlDelete => @"delete from event where id = @id"; - - protected override string SqlRowCount => @"select count(*) from event"; - - protected override object InsertParameters(Event @event) - { - return new - { - endpointId = @event.EndpointId, - timestamp = @event.Timestamp, - type = @event.Type, - data = @event.Data - }; - } - - protected override object UpdateParameters(Event record) - { - throw new NotImplementedException(); - } - - protected override void InsertId(Event @event, int id) - { - @event.Id = id; - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Repositories/ExtensionRepository.cs b/src/Aporta.Core/DataAccess/Repositories/ExtensionRepository.cs index 5ab907d..4bc8953 100644 --- a/src/Aporta.Core/DataAccess/Repositories/ExtensionRepository.cs +++ b/src/Aporta.Core/DataAccess/Repositories/ExtensionRepository.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; using Aporta.Core.Models; using Dapper; @@ -8,34 +11,33 @@ namespace Aporta.Core.DataAccess.Repositories; public class ExtensionRepository { - private const string SqlSelect = @"select id, name, enabled, configuration - from extension"; - - private const string SqlInsert = @"insert into extension - (id, name, enabled, configuration) values - (@id, @name, @enabled, @configuration);"; - - private const string SqlUpdate = @"update extension - set enabled = @enabled, - configuration = @configuration - where id = @id;"; - + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + private readonly IDataAccess _dataAccess; public ExtensionRepository(IDataAccess dataAccess) { _dataAccess = dataAccess; - - SqlMapper.AddTypeHandler(new GuidHandler()); } - + public async Task Get(Guid id) { using var connection = _dataAccess.CreateDbConnection(); connection.Open(); - return await connection.QueryFirstOrDefaultAsync(SqlSelect + - @" where id = @id", new {id}); + var data = await connection.QueryFirstOrDefaultAsync( + "SELECT data FROM extension WHERE id = @id", + new { id = id.ToString() }); + + if (data == null) return null; + + var extension = JsonSerializer.Deserialize(data, JsonOptions); + extension.Id = id; + return extension; } public async Task> GetAll() @@ -43,7 +45,15 @@ public async Task> GetAll() using var connection = _dataAccess.CreateDbConnection(); connection.Open(); - return await connection.QueryAsync(SqlSelect); + var results = await connection.QueryAsync<(string Id, string Data)>( + "SELECT id, data FROM extension"); + + return results.Select(r => + { + var extension = JsonSerializer.Deserialize(r.Data, JsonOptions); + extension.Id = Guid.Parse(r.Id); + return extension; + }); } public async Task Insert(ExtensionHost extension) @@ -51,12 +61,16 @@ public async Task Insert(ExtensionHost extension) using var connection = _dataAccess.CreateDbConnection(); connection.Open(); - await connection.ExecuteAsync(SqlInsert, - new - { - id = extension.Id, name = extension.Name, enabled = extension.Enabled, - configuration = extension.Configuration - }); + var json = JsonSerializer.Serialize(new + { + name = extension.Name, + enabled = extension.Enabled, + configuration = extension.Configuration + }, JsonOptions); + + await connection.ExecuteAsync( + "INSERT INTO extension (id, data) VALUES (@id, @data)", + new { id = extension.Id.ToString(), data = json }); } public async Task Update(ExtensionHost extension) @@ -64,13 +78,15 @@ public async Task Update(ExtensionHost extension) using var connection = _dataAccess.CreateDbConnection(); connection.Open(); - await connection.ExecuteAsync(SqlUpdate, - new - { - id = extension.Id, - name = extension.Name, - enabled = extension.Enabled, - configuration = extension.Configuration - }); + var json = JsonSerializer.Serialize(new + { + name = extension.Name, + enabled = extension.Enabled, + configuration = extension.Configuration + }, JsonOptions); + + await connection.ExecuteAsync( + "UPDATE extension SET data = @data WHERE id = @id", + new { id = extension.Id.ToString(), data = json }); } -} \ No newline at end of file +} diff --git a/src/Aporta.Core/DataAccess/Repositories/GlobalSettingRepository.cs b/src/Aporta.Core/DataAccess/Repositories/GlobalSettingRepository.cs index 195f8af..e784974 100644 --- a/src/Aporta.Core/DataAccess/Repositories/GlobalSettingRepository.cs +++ b/src/Aporta.Core/DataAccess/Repositories/GlobalSettingRepository.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; using Aporta.Shared.Models; using Dapper; @@ -6,33 +8,32 @@ namespace Aporta.Core.DataAccess.Repositories; public class GlobalSettingRepository { - private const string SqlSelect = @"select name, value - from global_setting"; - - private const string SqlInsert = @"insert into global_setting - (name, value) values - (@name, @value);"; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; - private const string SqlUpdate = @"update global_setting - set value = @value - where name = @name;"; - private readonly IDataAccess _dataAccess; public GlobalSettingRepository(IDataAccess dataAccess) { _dataAccess = dataAccess; } - + public async Task Get(string name) { using var connection = _dataAccess.CreateDbConnection(); connection.Open(); - var globalSetting = await connection.QueryFirstOrDefaultAsync(SqlSelect + - @" where name = @name", new {name}); + var data = await connection.QueryFirstOrDefaultAsync( + "SELECT data FROM global_setting WHERE name = @name", + new { name }); + + if (data == null) return null; - return globalSetting?.Value; + var setting = JsonSerializer.Deserialize(data, JsonOptions); + return setting?.Value; } public async Task Insert(GlobalSetting globalSetting) @@ -40,22 +41,22 @@ public async Task Insert(GlobalSetting globalSetting) using var connection = _dataAccess.CreateDbConnection(); connection.Open(); - await connection.ExecuteAsync(SqlInsert, - new - { - name = globalSetting.Name, value = globalSetting.Value - }); + var json = JsonSerializer.Serialize(new { value = globalSetting.Value }, JsonOptions); + + await connection.ExecuteAsync( + "INSERT INTO global_setting (name, data) VALUES (@name, @data)", + new { name = globalSetting.Name, data = json }); } - + public async Task Update(GlobalSetting globalSetting) { using var connection = _dataAccess.CreateDbConnection(); connection.Open(); - await connection.ExecuteAsync(SqlUpdate, - new - { - name = globalSetting.Name, value = globalSetting.Value - }); + var json = JsonSerializer.Serialize(new { value = globalSetting.Value }, JsonOptions); + + await connection.ExecuteAsync( + "UPDATE global_setting SET data = @data WHERE name = @name", + new { name = globalSetting.Name, data = json }); } -} \ No newline at end of file +} diff --git a/src/Aporta.Core/DataAccess/Repositories/HolCalRepository.cs b/src/Aporta.Core/DataAccess/Repositories/HolCalRepository.cs new file mode 100644 index 0000000..9a85ea1 --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/HolCalRepository.cs @@ -0,0 +1,17 @@ +using Z9.Spcore.Proto; + +namespace Aporta.Core.DataAccess.Repositories; + +public class HolCalRepository : ProtoJsonRepository +{ + private readonly IDataAccess _dataAccess; + + public HolCalRepository(IDataAccess dataAccess) + { + _dataAccess = dataAccess; + } + + protected override IDataAccess DataAccess => _dataAccess; + protected override string TableName => "hol_cal"; + protected override int GetUnid(HolCal message) => message.Unid; +} diff --git a/src/Aporta.Core/DataAccess/Repositories/HolRepository.cs b/src/Aporta.Core/DataAccess/Repositories/HolRepository.cs new file mode 100644 index 0000000..6394487 --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/HolRepository.cs @@ -0,0 +1,17 @@ +using Z9.Spcore.Proto; + +namespace Aporta.Core.DataAccess.Repositories; + +public class HolRepository : ProtoJsonRepository +{ + private readonly IDataAccess _dataAccess; + + public HolRepository(IDataAccess dataAccess) + { + _dataAccess = dataAccess; + } + + protected override IDataAccess DataAccess => _dataAccess; + protected override string TableName => "hol"; + protected override int GetUnid(Hol message) => message.Unid; +} diff --git a/src/Aporta.Core/DataAccess/Repositories/HolTypeRepository.cs b/src/Aporta.Core/DataAccess/Repositories/HolTypeRepository.cs new file mode 100644 index 0000000..045d3a3 --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/HolTypeRepository.cs @@ -0,0 +1,17 @@ +using Z9.Spcore.Proto; + +namespace Aporta.Core.DataAccess.Repositories; + +public class HolTypeRepository : ProtoJsonRepository +{ + private readonly IDataAccess _dataAccess; + + public HolTypeRepository(IDataAccess dataAccess) + { + _dataAccess = dataAccess; + } + + protected override IDataAccess DataAccess => _dataAccess; + protected override string TableName => "hol_type"; + protected override int GetUnid(HolType message) => message.Unid; +} diff --git a/src/Aporta.Core/DataAccess/Repositories/InputRepository.cs b/src/Aporta.Core/DataAccess/Repositories/InputRepository.cs deleted file mode 100644 index d82df3a..0000000 --- a/src/Aporta.Core/DataAccess/Repositories/InputRepository.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Threading.Tasks; -using Aporta.Shared.Models; -using Dapper; - -namespace Aporta.Core.DataAccess.Repositories; - -public class InputRepository : BaseRepository -{ - public InputRepository(IDataAccess dataAccess) - { - DataAccess = dataAccess; - } - - protected override IDataAccess DataAccess { get; } - - protected override string SqlSelect => @"select input.id, input.endpoint_id as endpointId, input.name - from input"; - - protected override string SqlInsert => @"insert into input - (endpoint_id, name) values - (@endpointId, @name)"; - - protected override string SqlUpdate =>throw new System.NotImplementedException(); - - protected override string SqlDelete => @"delete from input where id = @id"; - - protected override string SqlRowCount => @"select count(*) from input"; - - public async Task GetForDriverId(string driverId) - { - using var connection = DataAccess.CreateDbConnection(); - connection.Open(); - - return await connection.QueryFirstAsync(SqlSelect + - @" inner join endpoint on input.endpoint_id = endpoint.id - where endpoint.driver_id = @driverId", - new {driverId}); - } - - protected override object InsertParameters(Input input) - { - return new - { - endpointId = input.EndpointId, - name = input.Name - }; - } - - protected override object UpdateParameters(Input record) - { - throw new System.NotImplementedException(); - } - - protected override void InsertId(Input input, int id) - { - input.Id = id; - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Repositories/JsonDocumentRepository.cs b/src/Aporta.Core/DataAccess/Repositories/JsonDocumentRepository.cs new file mode 100644 index 0000000..b396700 --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/JsonDocumentRepository.cs @@ -0,0 +1,159 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Aporta.Shared.Models; +using Dapper; + +namespace Aporta.Core.DataAccess.Repositories; + +public abstract class JsonDocumentRepository where T : class +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + protected abstract IDataAccess DataAccess { get; } + + protected abstract string TableName { get; } + + protected abstract string SqlRowCount { get; } + + public async Task Get(int id) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var row = await connection.QuerySingleOrDefaultAsync<(int Id, string Data)>( + $"SELECT id, data FROM {TableName} WHERE id = @id", new { id }); + + if (row.Data == null) return default; + + var entity = JsonSerializer.Deserialize(row.Data, JsonOptions); + SetId(entity, row.Id); + return entity; + } + + public async Task> GetAll() + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var results = await connection.QueryAsync<(int Id, string Data)>( + $"SELECT id, data FROM {TableName}"); + + return results.Select(r => + { + var entity = JsonSerializer.Deserialize(r.Data, JsonOptions); + SetId(entity, r.Id); + return entity; + }); + } + + public async Task> GetAll(int pageNumber, int pageSize, string orderBy) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + int offset = (pageNumber - 1) * pageSize; + var results = await connection.QueryAsync<(int Id, string Data)>( + $"SELECT id, data FROM {TableName} ORDER BY {orderBy} DESC LIMIT @offset, @pageSize", + new { offset, pageSize }); + + var items = results.Select(r => + { + var entity = JsonSerializer.Deserialize(r.Data, JsonOptions); + SetId(entity, r.Id); + return entity; + }); + + return new PaginatedItemsDto + { + Items = items, + PageNumber = pageNumber, + PageSize = pageSize, + TotalItems = await connection.ExecuteScalarAsync(SqlRowCount) + }; + } + + public async Task Insert(T record, int? explicitId = null) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var json = SerializeForInsert(record); + int id; + + if (explicitId.HasValue && explicitId.Value > 0) + { + // Use provided ID + id = explicitId.Value; + await connection.ExecuteAsync( + $"INSERT INTO {TableName} (id, data) VALUES (@id, @data)", + new { id, data = json }); + } + else + { + // Auto-assign: MAX(id) + 1, or 1 if table is empty + id = await connection.QueryFirstAsync( + $"INSERT INTO {TableName} (id, data) VALUES ((SELECT COALESCE(MAX(id), 0) + 1 FROM {TableName}), @data); SELECT last_insert_rowid()", + new { data = json }); + } + + SetId(record, id); + return id; + } + + public async Task Upsert(T record, int id) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var json = SerializeForInsert(record); + await connection.ExecuteAsync( + $"INSERT INTO {TableName} (id, data) VALUES (@id, @data) ON CONFLICT(id) DO UPDATE SET data = @data", + new { id, data = json }); + + SetId(record, id); + } + + public async Task Update(T record) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var json = SerializeForUpdate(record); + var id = GetId(record); + await connection.ExecuteAsync( + $"UPDATE {TableName} SET data = @data WHERE id = @id", + new { data = json, id }); + } + + public async Task Delete(int id) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + await connection.ExecuteAsync( + $"DELETE FROM {TableName} WHERE id = @id", new { id }); + } + + protected virtual string SerializeForInsert(T record) + { + return JsonSerializer.Serialize(record, JsonOptions); + } + + protected virtual string SerializeForUpdate(T record) + { + return JsonSerializer.Serialize(record, JsonOptions); + } + + protected abstract void SetId(T entity, int id); + + protected abstract int GetId(T entity); + + protected static JsonSerializerOptions GetJsonOptions() => JsonOptions; +} diff --git a/src/Aporta.Core/DataAccess/Repositories/OutputRepository.cs b/src/Aporta.Core/DataAccess/Repositories/OutputRepository.cs deleted file mode 100644 index 747411f..0000000 --- a/src/Aporta.Core/DataAccess/Repositories/OutputRepository.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.Threading.Tasks; -using Aporta.Shared.Models; -using Dapper; - -namespace Aporta.Core.DataAccess.Repositories; - -public class OutputRepository : BaseRepository -{ - public OutputRepository(IDataAccess dataAccess) - { - DataAccess = dataAccess; - } - - protected override IDataAccess DataAccess { get; } - - protected override string SqlSelect => @"select output.id, output.endpoint_id as endpointId, output.name - from output"; - - protected override string SqlInsert => @"insert into output - (endpoint_id, name) values - (@endpointId, @name)"; - - protected override string SqlUpdate => throw new NotImplementedException(); - - protected override string SqlDelete => @"delete from output where id = @id"; - - protected override string SqlRowCount => @"select count(*) from output"; - - public async Task GetForDriverId(string driverId) - { - using var connection = DataAccess.CreateDbConnection(); - connection.Open(); - - return await connection.QueryFirstAsync(SqlSelect + - @" inner join endpoint on output.endpoint_id = endpoint.id - where endpoint.driver_id = @driverId", - new {driverId}); - } - - protected override object InsertParameters(Output output) - { - return new - { - endpointId = output.EndpointId, - name = output.Name - }; - } - - protected override object UpdateParameters(Output record) - { - throw new NotImplementedException(); - } - - protected override void InsertId(Output output, int id) - { - output.Id = id; - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Repositories/PersonRepository.cs b/src/Aporta.Core/DataAccess/Repositories/PersonRepository.cs deleted file mode 100644 index 610bc38..0000000 --- a/src/Aporta.Core/DataAccess/Repositories/PersonRepository.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Aporta.Shared.Models; - -namespace Aporta.Core.DataAccess.Repositories; - -public class PersonRepository : BaseRepository -{ - public PersonRepository(IDataAccess dataAccess) - { - DataAccess = dataAccess; - } - - protected override IDataAccess DataAccess { get; } - - protected override string SqlSelect => @"select person.id, - person.first_name as firstName, - person.last_name as lastName, - person.enabled - from person"; - - protected override string SqlInsert => @"insert into person - (first_name, last_name, enabled) values - (@firstName, @lastName, @enabled)"; - - // ReSharper disable once UnassignedGetOnlyAutoProperty - protected override string SqlUpdate { get; } - - protected override string SqlDelete => @"delete from person where id = @id"; - - protected override string SqlRowCount => @"select count(*) from person"; - - protected override object InsertParameters(Person person) - { - return new - { - firstName = person.FirstName, - lastName = person.LastName, - enabled = person.Enabled - }; - } - - protected override object UpdateParameters(Person record) - { - throw new System.NotImplementedException(); - } - - protected override void InsertId(Person person, int id) - { - person.Id = id; - } -} \ No newline at end of file diff --git a/src/Aporta.Core/DataAccess/Repositories/PrivRepository.cs b/src/Aporta.Core/DataAccess/Repositories/PrivRepository.cs new file mode 100644 index 0000000..408330c --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/PrivRepository.cs @@ -0,0 +1,17 @@ +using Z9.Spcore.Proto; + +namespace Aporta.Core.DataAccess.Repositories; + +public class PrivRepository : ProtoJsonRepository +{ + private readonly IDataAccess _dataAccess; + + public PrivRepository(IDataAccess dataAccess) + { + _dataAccess = dataAccess; + } + + protected override IDataAccess DataAccess => _dataAccess; + protected override string TableName => "priv"; + protected override int GetUnid(Priv message) => message.Unid; +} diff --git a/src/Aporta.Core/DataAccess/Repositories/ProtoJsonRepository.cs b/src/Aporta.Core/DataAccess/Repositories/ProtoJsonRepository.cs new file mode 100644 index 0000000..2962ba5 --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/ProtoJsonRepository.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Google.Protobuf; + +namespace Aporta.Core.DataAccess.Repositories; + +/// +/// Base repository for storing Google.Protobuf IMessage types as JSON documents. +/// Uses Google.Protobuf.JsonFormatter/JsonParser for serialization. +/// +public abstract class ProtoJsonRepository where T : IMessage, new() +{ + protected static readonly JsonFormatter Formatter = new(JsonFormatter.Settings.Default); + protected static readonly JsonParser Parser = new(JsonParser.Settings.Default); + + protected abstract IDataAccess DataAccess { get; } + protected abstract string TableName { get; } + + /// + /// Gets the unid field value from the protobuf message (used as primary key). + /// + protected abstract int GetUnid(T message); + + public async Task Get(int id) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var json = await connection.QuerySingleOrDefaultAsync( + $"SELECT data FROM {TableName} WHERE id = @id", new { id }); + + return json == null ? default : Parser.Parse(json); + } + + public async Task> GetAll() + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var results = await connection.QueryAsync( + $"SELECT data FROM {TableName}"); + + return results.Select(json => Parser.Parse(json)); + } + + public async Task Upsert(T message) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var id = GetUnid(message); + var json = Formatter.Format(message); + + await connection.ExecuteAsync( + $"INSERT INTO {TableName} (id, data) VALUES (@id, @data) ON CONFLICT(id) DO UPDATE SET data = @data", + new { id, data = json }); + } + + public async Task Delete(int id) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + await connection.ExecuteAsync( + $"DELETE FROM {TableName} WHERE id = @id", new { id }); + } + + public async Task DeleteAll() + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + await connection.ExecuteAsync($"DELETE FROM {TableName}"); + } + + public async Task Count() + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + return await connection.ExecuteScalarAsync($"SELECT COUNT(*) FROM {TableName}"); + } + + /// + /// Returns the next available ID (MAX(id) + 1, or 1 if empty). + /// + public async Task NextId() + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + return await connection.ExecuteScalarAsync( + $"SELECT COALESCE(MAX(id), 0) + 1 FROM {TableName}"); + } +} diff --git a/src/Aporta.Core/DataAccess/Repositories/SchedRepository.cs b/src/Aporta.Core/DataAccess/Repositories/SchedRepository.cs new file mode 100644 index 0000000..ea367bc --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/SchedRepository.cs @@ -0,0 +1,17 @@ +using Z9.Spcore.Proto; + +namespace Aporta.Core.DataAccess.Repositories; + +public class SchedRepository : ProtoJsonRepository +{ + private readonly IDataAccess _dataAccess; + + public SchedRepository(IDataAccess dataAccess) + { + _dataAccess = dataAccess; + } + + protected override IDataAccess DataAccess => _dataAccess; + protected override string TableName => "sched"; + protected override int GetUnid(Sched message) => message.Unid; +} diff --git a/src/Aporta.Core/DataAccess/Repositories/Z9CredRepository.cs b/src/Aporta.Core/DataAccess/Repositories/Z9CredRepository.cs new file mode 100644 index 0000000..dc44deb --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/Z9CredRepository.cs @@ -0,0 +1,52 @@ +using System.Threading.Tasks; +using Dapper; +using Z9.Protobuf; +using Z9.Spcore.Proto; + +namespace Aporta.Core.DataAccess.Repositories; + +/// +/// Repository for storing the full Z9 Cred proto messages (including privBindings). +/// Overrides Upsert to maintain the denormalized cred_num column. +/// +public class Z9CredRepository : ProtoJsonRepository +{ + private readonly IDataAccess _dataAccess; + + public Z9CredRepository(IDataAccess dataAccess) + { + _dataAccess = dataAccess; + } + + protected override IDataAccess DataAccess => _dataAccess; + protected override string TableName => "z9_cred"; + protected override int GetUnid(Cred message) => message.Unid; + + public new async Task Upsert(Cred message) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var id = GetUnid(message); + var json = Formatter.Format(message); + var credNum = ExtractCredNum(message); + + await connection.ExecuteAsync( + "INSERT INTO z9_cred (id, data, cred_num) VALUES (@id, @data, @credNum) " + + "ON CONFLICT(id) DO UPDATE SET data = @data, cred_num = @credNum", + new { id, data = json, credNum }); + } + + /// + /// Extracts the credential number as a decimal string from a Cred's CardPin.CredNum. + /// Returns null if no CredNum is present. + /// + public static string ExtractCredNum(Cred cred) + { + if (cred.CardPin?.CredNum == null || cred.CardPin.CredNum.BytesCase == BigIntegerData.BytesOneofCase.None) + return null; + + return SpCoreProtoUtil.ToBigInteger(cred.CardPin.CredNum).ToString(); + } + +} diff --git a/src/Aporta.Core/DataAccess/Repositories/Z9DevRepository.cs b/src/Aporta.Core/DataAccess/Repositories/Z9DevRepository.cs new file mode 100644 index 0000000..aa14db4 --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/Z9DevRepository.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Z9.Spcore.Proto; + +namespace Aporta.Core.DataAccess.Repositories; + +public class Z9DevRepository : ProtoJsonRepository +{ + private readonly IDataAccess _dataAccess; + + public Z9DevRepository(IDataAccess dataAccess) + { + _dataAccess = dataAccess; + } + + protected override IDataAccess DataAccess => _dataAccess; + protected override string TableName => "z9_dev"; + protected override int GetUnid(Dev message) => message.Unid; + + public async Task> GetAllByDevType(DevType devType) + { + var all = await GetAll(); + return all.Where(d => d.DevType == devType); + } + + public async Task GetByExternalId(string externalId) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var json = await connection.QueryFirstOrDefaultAsync( + @"SELECT data FROM z9_dev + WHERE json_extract(data, '$.externalId') = @externalId", + new { externalId }); + + return json == null ? null : Parser.Parse(json); + } + + public async Task> GetChildren(int parentUnid) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var results = await connection.QueryAsync( + @"SELECT data FROM z9_dev + WHERE json_extract(data, '$.logicalParentUnid') = @parentUnid", + new { parentUnid }); + + return results.Select(json => Parser.Parse(json)); + } + + public async Task GetForDriverId(string driverId) + { + return await GetByExternalId(driverId); + } + + /// + /// Gets the IO_CONTROLLER_COMMUNITY z9_dev for the given extension (driver) GUID. + /// Uses the denormalized external_dev_mod_id column. + /// + public async Task GetController(Guid extensionId) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var json = await connection.QueryFirstOrDefaultAsync( + @"SELECT data FROM z9_dev + WHERE external_dev_mod_id = @extensionId", + new { extensionId = extensionId.ToString() }); + + return json == null ? null : Parser.Parse(json); + } + + /// + /// Gets all z9_devs whose physicalParentUnid matches the given parent. + /// + public async Task> GetPhysicalChildren(int parentUnid) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var results = await connection.QueryAsync( + @"SELECT data FROM z9_dev + WHERE json_extract(data, '$.physicalParentUnid') = @parentUnid", + new { parentUnid }); + + return results.Select(json => Parser.Parse(json)); + } + + /// + /// Gets available (pool) z9_devs matching the given devType. + /// Available = DevPlatform is Community and Enabled is false (auto-synced from driver, not yet assigned by user). + /// + public async Task> GetAvailableByDevType(DevType devType) + { + var all = await GetAll(); + return all.Where(d => + d.DevType == devType && + d.DevPlatformCase == Dev.DevPlatformOneofCase.DevPlatform && + d.DevPlatform == DevPlatform.Community && + !d.Enabled); + } + + /// + /// Gets the extension (driver) GUID for any z9_dev by walking physicalParentUnid + /// to its IO_CONTROLLER_COMMUNITY parent. + /// The controller stores the driver GUID in ExternalDevModId. + /// + public async Task GetExtensionId(Dev dev) + { + // If this dev IS the controller, return its ExternalDevModId directly + if (dev.DevMod == DevMod.IoControllerCommunity && + !string.IsNullOrEmpty(dev.ExternalDevModId)) + { + return Guid.TryParse(dev.ExternalDevModId, out var id) ? id : null; + } + + // Walk physicalParentUnid to find the controller + if (dev.PhysicalParentUnidCase != Dev.PhysicalParentUnidOneofCase.PhysicalParentUnid) + return null; + + var controller = await Get(dev.PhysicalParentUnid); + if (controller == null) + return null; + + return Guid.TryParse(controller.ExternalDevModId, out var controllerId) ? controllerId : null; + } + + public async Task Insert(Dev dev) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var nextId = await connection.ExecuteScalarAsync( + "SELECT COALESCE(MAX(id), 0) + 1 FROM z9_dev"); + + dev.Unid = nextId; + var json = Formatter.Format(dev); + var externalDevModId = GetExternalDevModId(dev); + + await connection.ExecuteAsync( + "INSERT INTO z9_dev (id, data, external_dev_mod_id) VALUES (@id, @data, @externalDevModId)", + new { id = nextId, data = json, externalDevModId }); + + return nextId; + } + + public new async Task Upsert(Dev message) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var id = GetUnid(message); + var json = Formatter.Format(message); + var externalDevModId = GetExternalDevModId(message); + + await connection.ExecuteAsync( + @"INSERT INTO z9_dev (id, data, external_dev_mod_id) VALUES (@id, @data, @externalDevModId) + ON CONFLICT(id) DO UPDATE SET data = @data, external_dev_mod_id = @externalDevModId", + new { id, data = json, externalDevModId }); + } + + /// + /// Extracts the external_dev_mod_id for the SQL column. + /// Only IO_CONTROLLER_COMMUNITY z9_devs have ExternalDevModId set. + /// + private static string GetExternalDevModId(Dev dev) + { + return string.IsNullOrEmpty(dev.ExternalDevModId) ? null : dev.ExternalDevModId; + } +} diff --git a/src/Aporta.Core/DataAccess/Repositories/Z9EvtRepository.cs b/src/Aporta.Core/DataAccess/Repositories/Z9EvtRepository.cs new file mode 100644 index 0000000..22b58d4 --- /dev/null +++ b/src/Aporta.Core/DataAccess/Repositories/Z9EvtRepository.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Aporta.Shared.Models; +using Dapper; +using Z9.Spcore.Proto; + +namespace Aporta.Core.DataAccess.Repositories; + +/// +/// Repository for storing Z9 Evt proto messages with auto-increment IDs. +/// Events are append-only, so this uses INSERT with AUTOINCREMENT rather than upsert-by-unid. +/// +public class Z9EvtRepository : ProtoJsonRepository +{ + private readonly IDataAccess _dataAccess; + + public Z9EvtRepository(IDataAccess dataAccess) + { + _dataAccess = dataAccess; + } + + protected override IDataAccess DataAccess => _dataAccess; + protected override string TableName => "z9_evt"; + protected override int GetUnid(Evt message) => (int)message.Unid; + + /// + /// Inserts an event with auto-increment ID. Sets message.Unid to the new ID. + /// + public async Task Insert(Evt message) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var json = Formatter.Format(message); + int id = await connection.QueryFirstAsync( + "INSERT INTO z9_evt (data) VALUES (@data); SELECT last_insert_rowid()", + new { data = json }); + + message.Unid = id; + return id; + } + + /// + /// Gets an event by its auto-increment ID, setting Unid from the row ID. + /// + public new async Task Get(int id) + { + var evt = await base.Get(id); + if (evt != null) + evt.Unid = id; + return evt; + } + + /// + /// Returns a paginated list of events ordered by id descending (most recent first). + /// Sets Unid from the row ID on each event. + /// + public async Task> GetAll(int pageNumber, int pageSize) + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + int offset = (pageNumber - 1) * pageSize; + var results = await connection.QueryAsync<(int id, string data)>( + "SELECT id, data FROM z9_evt ORDER BY id DESC LIMIT @offset, @pageSize", + new { offset, pageSize }); + + var items = results.Select(row => + { + var evt = Parser.Parse(row.data); + evt.Unid = row.id; + return evt; + }); + + return new PaginatedItemsDto + { + Items = items, + PageNumber = pageNumber, + PageSize = pageSize, + TotalItems = await connection.ExecuteScalarAsync("SELECT COUNT(*) FROM z9_evt") + }; + } + + /// + /// Returns unconsumed RAW_CRED_READ events, most recent first. + /// + public async Task> GetUnconsumedRawReads() + { + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var results = await connection.QueryAsync<(int id, string data)>( + @"SELECT id, data FROM z9_evt + WHERE json_extract(data, '$.evtCode') = @evtCode + AND (json_extract(data, '$.consumed') IS NULL + OR json_extract(data, '$.consumed') = false) + ORDER BY id DESC", + new { evtCode = "EvtCode_RAW_CRED_READ" }); + + return results.Select(row => + { + var evt = Parser.Parse(row.data); + evt.Unid = row.id; + return evt; + }); + } + + /// + /// Marks an event as consumed by updating the proto JSON. + /// + public async Task MarkConsumed(int id) + { + var evt = await Get(id); + if (evt == null) return; + + evt.Consumed = true; + + using var connection = DataAccess.CreateDbConnection(); + connection.Open(); + + var json = Formatter.Format(evt); + await connection.ExecuteAsync( + "UPDATE z9_evt SET data = @data WHERE id = @id", + new { id, data = json }); + } +} diff --git a/src/Aporta.Core/DataAccess/SqLiteDataAccess.cs b/src/Aporta.Core/DataAccess/SqLiteDataAccess.cs index 03923e8..e2db481 100644 --- a/src/Aporta.Core/DataAccess/SqLiteDataAccess.cs +++ b/src/Aporta.Core/DataAccess/SqLiteDataAccess.cs @@ -1,7 +1,8 @@ using System; using System.Data; using System.IO; -using System.Reflection; +using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Aporta.Core.DataAccess.Migrations; using Dapper; @@ -15,19 +16,9 @@ public class SqLiteDataAccess : IDataAccess private const string FileName = "Data/Aporta.sqlite"; private readonly bool _inMemory; - private readonly IMigration[] _migrations = + private readonly IMigration[] _migrations = { - new _0000_InitialCreate(), - new _0001_AddExtensionTable(), - new _0002_AddEndpointTable(), - new _0003_AddOutputTable(), - new _0004_AddInputTable(), - new _0005_AddDoorTable(), - new _0006_AddGlobalSettingTable(), - new _0007_AddCredentialTable(), - new _0008_AddPersonTable(), - new _0009_AddEventTable(), - new _0010_AddLastEventToCredentialTable() + new _0000_InitialCreate() }; /// @@ -92,7 +83,16 @@ public async Task UpdateSchema() } else { - currentVersion = await CurrentVersion(); + currentVersion = await CurrentVersion(); + } + + // Detect old column-based schema (versions 0-10) and require migration tool + if (currentVersion >= 0 && currentVersion <= 10) + { + throw new InvalidOperationException( + $"Database is using old schema (version {currentVersion}). " + + "Please run the Aporta.Migration tool to upgrade to JSON document storage before starting the application. " + + "Usage: dotnet Aporta.Migration.dll "); } using var connection = CreateDbConnection(); @@ -100,16 +100,16 @@ public async Task UpdateSchema() connection.Open(); using var transaction = connection.BeginTransaction(); - for (int migrationIndex = currentVersion + 1; migrationIndex < _migrations.Length; migrationIndex++) + foreach (var migration in _migrations.Where(m => m.Version > currentVersion).OrderBy(m => m.Version)) { - await _migrations[migrationIndex].PerformUpdate(connection, transaction); + await migration.PerformUpdate(connection, transaction); await connection.ExecuteAsync( @"insert into schema_info (id, name, timestamp) values (@id, @name, @timestamp)", new { - id = _migrations[migrationIndex].Version, name = _migrations[migrationIndex].Name, + id = migration.Version, name = migration.Name, timestamp = DateTime.UtcNow }, transaction); } diff --git a/src/Aporta.Core/Services/AccessService.cs b/src/Aporta.Core/Services/AccessService.cs index db1665e..98855c5 100644 --- a/src/Aporta.Core/Services/AccessService.cs +++ b/src/Aporta.Core/Services/AccessService.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Numerics; using System.Text.Json; using System.Threading.Tasks; using Aporta.Core.DataAccess; @@ -13,33 +14,107 @@ using Aporta.Shared.Models; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; +using Z9.Protobuf; +using Z9.Spcore.Proto; + +using Door = Aporta.Shared.Models.Door; namespace Aporta.Core.Services; +/// +/// Event args for access decision events, used to notify external systems. +/// +public class AccessDecisionEventArgs : EventArgs +{ + public string EndpointId { get; set; } + public bool IsGranted { get; set; } + public string CardNumber { get; set; } + public string PersonName { get; set; } + public EventReason Reason { get; set; } + public bool UseExtendedTime { get; set; } +} + public class AccessService { private readonly ExtensionService _extensionService; private readonly ILogger _logger; - private readonly DoorRepository _doorRepository; - private readonly EndpointRepository _endpointRepository; + private readonly Z9DevRepository _z9DevRepository; private readonly CredentialRepository _credentialRepository; - private readonly EventRepository _eventRepository; + private readonly Z9EvtRepository _z9EvtRepository; + private readonly Z9CredRepository _z9CredRepository; + private readonly CredTemplateRepository _credTemplateRepository; + private readonly PrivRepository _privRepository; + private readonly SchedRepository _schedRepository; + private readonly HolRepository _holRepository; private readonly ConcurrentDictionary _processAccessCredential = new(); private readonly IHubContext _hubContext; + private readonly DoorConfigurationService _doorConfigurationService; + private Func _getDoorUnidForEndpoint; + private Func _getStrikeTimesForEndpoint; + private Func _getDoorModeForEndpoint; + private Func _decodeCardData; + + /// + /// Event raised when an access decision is made (granted or denied). + /// External systems can subscribe to forward the decision. + /// + public event EventHandler AccessDecisionMade; /// /// Represents a service for accessing and managing system access. /// public AccessService(IDataAccess dataAccess, ExtensionService extensionService, - IHubContext hubContext, ILogger logger) + IHubContext hubContext, ILogger logger, + DoorConfigurationService doorConfigurationService) { - _doorRepository = new DoorRepository(dataAccess); _credentialRepository = new CredentialRepository(dataAccess); - _endpointRepository = new EndpointRepository(dataAccess); - _eventRepository = new EventRepository(dataAccess); + _z9DevRepository = new Z9DevRepository(dataAccess); + _z9EvtRepository = new Z9EvtRepository(dataAccess); + _z9CredRepository = new Z9CredRepository(dataAccess); + _credTemplateRepository = new CredTemplateRepository(dataAccess); + _privRepository = new PrivRepository(dataAccess); + _schedRepository = new SchedRepository(dataAccess); + _holRepository = new HolRepository(dataAccess); _extensionService = extensionService; _hubContext = hubContext; _logger = logger; + _doorConfigurationService = doorConfigurationService; + } + + /// + /// Sets a function to look up the Z9 Door unid for an endpoint. + /// Called by StartupWorker after Z9OpenCommunityProtocolService is available. + /// + public void SetDoorUnidLookup(Func getDoorUnidForEndpoint) + { + _getDoorUnidForEndpoint = getDoorUnidForEndpoint; + } + + /// + /// Sets a function to look up the strike times (strikeTimeMs, extendedStrikeTimeMs) for an endpoint. + /// + public void SetStrikeTimeLookup(Func getStrikeTimesForEndpoint) + { + _getStrikeTimesForEndpoint = getStrikeTimesForEndpoint; + } + + /// + /// Sets a function to look up the current door mode for an endpoint. + /// + public void SetDoorModeLookup(Func getDoorModeForEndpoint) + { + _getDoorModeForEndpoint = getDoorModeForEndpoint; + } + + /// + /// Sets a function to decode raw card bit data into a credential number string. + /// In Z9 mode, credentials are stored by decoded card number, so card reads + /// must be decoded before matching. In standalone mode, this is not set and + /// raw bit strings are matched directly. + /// + public void SetCardDataDecoder(Func decodeCardData) + { + _decodeCardData = decodeCardData; } public void Startup() @@ -50,7 +125,7 @@ public void Startup() public void Shutdown() { _processAccessCredential.Clear(); - + _extensionService.AccessCredentialReceived -= ExtensionServiceOnAccessCredentialReceived; } @@ -71,9 +146,7 @@ private async Task ProcessAccessRequest(AccessCredentialReceivedEventArgs eventA { try { - var endpoints = (await _endpointRepository.GetAll()).ToArray(); - - var matchingDoor = await MatchingDoor(eventArgs.Access.Id, endpoints); + var matchingDoor = await MatchingDoor(eventArgs.Access.Id); if (matchingDoor == null) { @@ -82,24 +155,75 @@ private async Task ProcessAccessRequest(AccessCredentialReceivedEventArgs eventA return; } - var accessPoint = endpoints.First(endpoint => endpoint.DriverEndpointId == eventArgs.Access.Id); + // Build an Endpoint-like reference from the access point's z9_dev + var accessPointDev = await _z9DevRepository.GetByExternalId(eventArgs.Access.Id); + var accessPointExtensionId = accessPointDev != null + ? await _z9DevRepository.GetExtensionId(accessPointDev) + : null; + var accessPoint = new Endpoint + { + Id = accessPointDev?.Unid ?? 0, + Name = eventArgs.Access.Name, + DriverEndpointId = eventArgs.Access.Id, + ExtensionId = accessPointExtensionId ?? Guid.Empty, + Type = EndpointType.Reader + }; - var matchingDoorStrike = MatchingDoorStrike(matchingDoor.DoorStrikeEndpointId, endpoints); + // Find the door strike z9_dev + Dev matchingStrikeDev = null; + if (matchingDoor.DoorStrikeEndpointId.HasValue) + { + matchingStrikeDev = await _z9DevRepository.Get(matchingDoor.DoorStrikeEndpointId.Value); + } - if (matchingDoorStrike == null) + if (!eventArgs.Handler.IsValid()) { - _logger.LogInformation("Door '{Name}' didn't have a strike assigned", matchingDoor.Name); return; } - if (!eventArgs.Handler.IsValid()) + // Check door mode before normal access control + var doorMode = _getDoorModeForEndpoint?.Invoke(accessPoint.DriverEndpointId); + if (doorMode == DoorModeType.StaticStateUnlocked) + { + _logger.LogInformation("Door '{Name}' is unlocked - granting access without credential check", matchingDoor.Name); + await eventArgs.Access.AccessGrantedNotification(); + AccessDecisionMade?.Invoke(this, new AccessDecisionEventArgs + { + EndpointId = accessPoint.DriverEndpointId, + IsGranted = true, + CardNumber = eventArgs.Handler.MatchingCardData, + Reason = EventReason.None + }); + return; + } + if (doorMode == DoorModeType.StaticStateLocked) { + _logger.LogInformation("Door '{Name}' is locked - denying access", matchingDoor.Name); + await eventArgs.Access.AccessDeniedNotification(); + AccessDecisionMade?.Invoke(this, new AccessDecisionEventArgs + { + EndpointId = accessPoint.DriverEndpointId, + IsGranted = false, + CardNumber = eventArgs.Handler.MatchingCardData, + Reason = EventReason.DoorLocked + }); return; } - if (await IsAccessGranted(eventArgs.Handler.MatchingCardData, matchingDoor, accessPoint)) + var (granted, useExtendedTime) = await IsAccessGranted(eventArgs.Handler.MatchingCardData, eventArgs.PinData, doorMode, matchingDoor, accessPoint); + if (granted) { - await OpenDoor(eventArgs.Access, matchingDoorStrike, 3); + // Only open door if strike is assigned - community controllers may not have one + if (matchingStrikeDev != null) + { + var strikeTimeMs = GetStrikeTimeMs(accessPoint.DriverEndpointId, useExtendedTime); + await OpenDoor(eventArgs.Access, matchingStrikeDev, strikeTimeMs); + } + else + { + _logger.LogInformation("Door '{Name}' access granted but no strike assigned to operate", matchingDoor.Name); + await eventArgs.Access.AccessGrantedNotification(); + } } else { @@ -112,129 +236,695 @@ private async Task ProcessAccessRequest(AccessCredentialReceivedEventArgs eventA } } - private async Task IsAccessGranted(string matchingCardData, Door matchingDoor, Endpoint accessPoint) + private async Task<(bool granted, bool useExtendedTime)> IsAccessGranted(string matchingCardData, string pinData, DoorModeType? doorMode, Door matchingDoor, Endpoint accessPoint) { - var assignedCredential = await _credentialRepository.AssignedCredential(matchingCardData); + // Guard: need at least a card or a PIN + if (matchingCardData == null && pinData == null) + return (false, false); + + AssignedCredential assignedCredential; + + if (matchingCardData == null && pinData != null) + { + // PIN-only path: look up credential by unique PIN + var allZ9Creds = await _z9CredRepository.GetAll(); + var matchingCred = allZ9Creds.FirstOrDefault(c => + c.CardPin?.PinUniqueCase == CardPin.PinUniqueOneofCase.PinUnique && + c.CardPin.PinUnique && + c.CardPin?.PinCase == CardPin.PinOneofCase.Pin && + c.CardPin.Pin == pinData); + + if (matchingCred == null) + { + _logger.LogInformation("Door '{Name}' denied access - unknown unique PIN", matchingDoor.Name); + var evtId = await InsertAccessEvt(EventType.AccessDenied, EventReason.UnknownUniquePin, + new EventData + { + Door = matchingDoor, + Endpoint = accessPoint, + EventReason = EventReason.UnknownUniquePin + }); + await _hubContext.Clients.All.SendAsync(Methods.NewEventReceived, evtId); + AccessDecisionMade?.Invoke(this, new AccessDecisionEventArgs + { + EndpointId = accessPoint.DriverEndpointId, + IsGranted = false, + Reason = EventReason.UnknownUniquePin + }); + return (false, false); + } + + var credNum = SpCoreProtoUtil.ToBigInteger(matchingCred.CardPin.CredNum).ToString(); + assignedCredential = await _credentialRepository.AssignedCredential(credNum); + } + else if (_decodeCardData != null) + { + // Z9 mode: decode raw bits to credential number, then look up by credNum + var decodedCredNum = _decodeCardData(matchingCardData); + assignedCredential = decodedCredNum != null + ? await _credentialRepository.AssignedCredential(decodedCredNum) + : null; + if (assignedCredential != null) + { + _logger.LogDebug("Matched credential by decoded credNum: {CredNum}", decodedCredNum); + } + } + else + { + // Standalone mode: match by raw bit string directly + assignedCredential = await _credentialRepository.AssignedCredential(matchingCardData); + } int eventId; if (assignedCredential?.Person == null) { _logger.LogInformation("Door '{Name}' badge requires enrollment", matchingDoor.Name); - eventId = await _eventRepository.Insert(new Event - { - EndpointId = accessPoint.Id, Type = EventType.AccessDenied, - Data = JsonSerializer.Serialize(new EventData + eventId = await InsertAccessEvt(EventType.AccessDenied, EventReason.CredentialNotEnrolled, + new EventData { Door = matchingDoor, Endpoint = accessPoint, EventReason = EventReason.CredentialNotEnrolled, CardNumber = matchingCardData - }) - }); + }); if (assignedCredential == null) { - await _credentialRepository.Insert(new Credential - { Number = matchingCardData, LastEvent = eventId }); - } - else - { - await _credentialRepository.UpdateLastEvent(assignedCredential.Id, eventId); + await InsertRawCredReadEvt(matchingCardData, accessPoint.DriverEndpointId); } - + await _hubContext.Clients.All.SendAsync(Methods.NewEventReceived, eventId); - return false; + // Notify external systems of access decision + AccessDecisionMade?.Invoke(this, new AccessDecisionEventArgs + { + EndpointId = accessPoint.DriverEndpointId, + IsGranted = false, + CardNumber = matchingCardData, + PersonName = null, + Reason = EventReason.CredentialNotEnrolled + }); + + return (false, false); } - if (!AccessGranted()) + // Check Z9 Cred status (enabled, effective date, credTemplate) + var z9Cred = await _z9CredRepository.Get(assignedCredential.Id); + if (z9Cred != null) { - _logger.LogInformation("Door '{Name}' denied access", matchingDoor.Name); - - eventId = await _eventRepository.Insert(new Event + // Check credential template requirement + if (z9Cred.CredTemplateUnidCase != Cred.CredTemplateUnidOneofCase.CredTemplateUnid + || await _credTemplateRepository.Get(z9Cred.CredTemplateUnid) == null) + { + _logger.LogInformation("Door '{Name}' denied access - no credential template", matchingDoor.Name); + + eventId = await InsertAccessEvt(EventType.AccessDenied, EventReason.NoCredentialTemplate, + new EventData + { + Door = matchingDoor, + Endpoint = accessPoint, + Person = assignedCredential.Person, + EventReason = EventReason.NoCredentialTemplate, + CardNumber = matchingCardData + }); + + await _hubContext.Clients.All.SendAsync(Methods.NewEventReceived, eventId); + + AccessDecisionMade?.Invoke(this, new AccessDecisionEventArgs + { + EndpointId = accessPoint.DriverEndpointId, + IsGranted = false, + CardNumber = matchingCardData, + PersonName = assignedCredential.Person.FirstName, + Reason = EventReason.NoCredentialTemplate + }); + + return (false, false); + } + + // Check if credential is disabled + if (z9Cred.EnabledCase == Cred.EnabledOneofCase.Enabled && !z9Cred.Enabled) + { + _logger.LogInformation("Door '{Name}' denied access - credential disabled", matchingDoor.Name); + + eventId = await InsertAccessEvt(EventType.AccessDenied, EventReason.CredentialDisabled, + new EventData + { + Door = matchingDoor, + Endpoint = accessPoint, + Person = assignedCredential.Person, + EventReason = EventReason.CredentialDisabled, + CardNumber = matchingCardData + }); + + await _hubContext.Clients.All.SendAsync(Methods.NewEventReceived, eventId); + + AccessDecisionMade?.Invoke(this, new AccessDecisionEventArgs + { + EndpointId = accessPoint.DriverEndpointId, + IsGranted = false, + CardNumber = matchingCardData, + PersonName = assignedCredential.Person.FirstName, + Reason = EventReason.CredentialDisabled + }); + + return (false, false); + } + + // Check if credential is not yet effective + if (z9Cred.Effective != null && z9Cred.Effective.MillisCase == DateTimeData.MillisOneofCase.Millis) { - EndpointId = accessPoint.Id, Type = EventType.AccessDenied, - Data = JsonSerializer.Serialize(new EventData + var effectiveDate = DateTimeOffset.FromUnixTimeMilliseconds(z9Cred.Effective.Millis).DateTime; + if (effectiveDate > DateTime.UtcNow) + { + _logger.LogInformation("Door '{Name}' denied access - credential not yet effective (effective {Date})", + matchingDoor.Name, effectiveDate.ToShortDateString()); + + eventId = await InsertAccessEvt(EventType.AccessDenied, EventReason.CredentialNotYetEffective, + new EventData + { + Door = matchingDoor, + Endpoint = accessPoint, + Person = assignedCredential.Person, + EventReason = EventReason.CredentialNotYetEffective, + CardNumber = matchingCardData + }); + + await _hubContext.Clients.All.SendAsync(Methods.NewEventReceived, eventId); + + AccessDecisionMade?.Invoke(this, new AccessDecisionEventArgs + { + EndpointId = accessPoint.DriverEndpointId, + IsGranted = false, + CardNumber = matchingCardData, + PersonName = assignedCredential.Person.FirstName, + Reason = EventReason.CredentialNotYetEffective + }); + + return (false, false); + } + } + + // Check if credential has expired + if (z9Cred.Expires != null && z9Cred.Expires.MillisCase == DateTimeData.MillisOneofCase.Millis) + { + var expiresDate = DateTimeOffset.FromUnixTimeMilliseconds(z9Cred.Expires.Millis).DateTime; + if (expiresDate < DateTime.UtcNow) + { + _logger.LogInformation("Door '{Name}' denied access - credential expired (expired {Date})", + matchingDoor.Name, expiresDate.ToShortDateString()); + + eventId = await InsertAccessEvt(EventType.AccessDenied, EventReason.CredentialExpired, + new EventData + { + Door = matchingDoor, + Endpoint = accessPoint, + Person = assignedCredential.Person, + EventReason = EventReason.CredentialExpired, + CardNumber = matchingCardData + }); + + await _hubContext.Clients.All.SendAsync(Methods.NewEventReceived, eventId); + + AccessDecisionMade?.Invoke(this, new AccessDecisionEventArgs + { + EndpointId = accessPoint.DriverEndpointId, + IsGranted = false, + CardNumber = matchingCardData, + PersonName = assignedCredential.Person.FirstName, + Reason = EventReason.CredentialExpired + }); + + return (false, false); + } + } + } + + var privilegeDenialReason = await CheckAccessPrivilege(assignedCredential.Id, matchingDoor, accessPoint.DriverEndpointId); + if (privilegeDenialReason != null) + { + var reason = privilegeDenialReason.Value; + var reasonText = reason == EventReason.OutsideSchedule ? "outside schedule" : "no privilege"; + _logger.LogInformation("Door '{Name}' denied access - {Reason}", matchingDoor.Name, reasonText); + + eventId = await InsertAccessEvt(EventType.AccessDenied, reason, + new EventData { Door = matchingDoor, Endpoint = accessPoint, Person = assignedCredential.Person, - EventReason = EventReason.AccessNotAssigned, + EventReason = reason, CardNumber = matchingCardData - }) - }); - await _credentialRepository.UpdateLastEvent(assignedCredential.Id, eventId); - + }); + await _hubContext.Clients.All.SendAsync(Methods.NewEventReceived, eventId); - - return false; + + // Notify external systems of access decision + AccessDecisionMade?.Invoke(this, new AccessDecisionEventArgs + { + EndpointId = accessPoint.DriverEndpointId, + IsGranted = false, + CardNumber = matchingCardData, + PersonName = assignedCredential.Person.FirstName, + Reason = reason + }); + + return (false, false); } - _logger.LogInformation("Door '{Name}' granted access", matchingDoor.Name); - - eventId = await _eventRepository.Insert(new Event + // PIN validation based on door mode + if (doorMode == DoorModeType.CardAndConfirmingPin && matchingCardData != null) { - EndpointId = accessPoint.Id, Type = EventType.AccessGranted, - Data = JsonSerializer.Serialize(new EventData + var z9CredForPin = await _z9CredRepository.Get(assignedCredential.Id); + if (pinData == null) + { + _logger.LogInformation("Door '{Name}' denied access - no confirming PIN entered", matchingDoor.Name); + eventId = await InsertAccessEvt(EventType.AccessDenied, EventReason.NoConfirmingPin, + new EventData + { + Door = matchingDoor, + Endpoint = accessPoint, + Person = assignedCredential.Person, + EventReason = EventReason.NoConfirmingPin, + CardNumber = matchingCardData + }); + + await _hubContext.Clients.All.SendAsync(Methods.NewEventReceived, eventId); + AccessDecisionMade?.Invoke(this, new AccessDecisionEventArgs + { + EndpointId = accessPoint.DriverEndpointId, + IsGranted = false, + CardNumber = matchingCardData, + PersonName = assignedCredential.Person.FirstName, + Reason = EventReason.NoConfirmingPin + }); + return (false, false); + } + if (z9CredForPin?.CardPin?.PinCase == CardPin.PinOneofCase.Pin && + z9CredForPin.CardPin.Pin != pinData) + { + _logger.LogInformation("Door '{Name}' denied access - incorrect confirming PIN", matchingDoor.Name); + eventId = await InsertAccessEvt(EventType.AccessDenied, EventReason.IncorrectConfirmingPin, + new EventData + { + Door = matchingDoor, + Endpoint = accessPoint, + Person = assignedCredential.Person, + EventReason = EventReason.IncorrectConfirmingPin, + CardNumber = matchingCardData + }); + + await _hubContext.Clients.All.SendAsync(Methods.NewEventReceived, eventId); + AccessDecisionMade?.Invoke(this, new AccessDecisionEventArgs + { + EndpointId = accessPoint.DriverEndpointId, + IsGranted = false, + CardNumber = matchingCardData, + PersonName = assignedCredential.Person.FirstName, + Reason = EventReason.IncorrectConfirmingPin + }); + return (false, false); + } + } + + // Check if credential has extended door time modifier + var useExtendedTime = z9Cred?.DoorAccessModifiers?.ExtDoorTimeCase == + DoorAccessModifiers.ExtDoorTimeOneofCase.ExtDoorTime && z9Cred.DoorAccessModifiers.ExtDoorTime; + + _logger.LogInformation("Door '{Name}' granted access{ExtTime}", matchingDoor.Name, + useExtendedTime ? " (extended time)" : ""); + + eventId = await InsertAccessEvt(EventType.AccessGranted, EventReason.None, + new EventData { Door = matchingDoor, Endpoint = accessPoint, Person = assignedCredential.Person, EventReason = EventReason.None, CardNumber = matchingCardData - }) - }); - await _credentialRepository.UpdateLastEvent(assignedCredential.Id, eventId); - + }); + await _hubContext.Clients.All.SendAsync(Methods.NewEventReceived, eventId); - - return true; + + // Notify external systems of access decision + AccessDecisionMade?.Invoke(this, new AccessDecisionEventArgs + { + EndpointId = accessPoint.DriverEndpointId, + IsGranted = true, + CardNumber = matchingCardData, + PersonName = assignedCredential.Person.FirstName, + Reason = EventReason.None, + UseExtendedTime = useExtendedTime + }); + + return (true, useExtendedTime); + } + + private async Task InsertAccessEvt(EventType eventType, EventReason reason, EventData eventData) + { + var (evtCode, evtSubCode) = EventReasonMapping.ToEvtCodes(eventType, reason); + var nowMillis = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var evt = new Evt + { + EvtCode = evtCode, + HwTime = new DateTimeData { Millis = nowMillis }, + DbTime = new DateTimeData { Millis = nowMillis }, + Consumed = false, + Priority = 0, + Data = JsonSerializer.Serialize(eventData) + }; + + if (evtSubCode.HasValue) + { + evt.EvtSubCode = evtSubCode.Value; + } + + if (eventData.Endpoint?.Id > 0) + { + evt.EvtDevRef = new EvtDevRef + { + Unid = eventData.Endpoint.Id, + Name = eventData.Endpoint.Name, + DevType = DevType.CredReader + }; + } + + if (eventData.Person != null) + { + evt.EvtCredRef = new EvtCredRef + { + Unid = eventData.Person.Id, + Name = $"{eventData.Person.LastName}, {eventData.Person.FirstName}" + }; + } + + return await _z9EvtRepository.Insert(evt); + } + + private async Task InsertRawCredReadEvt(string rawBits, string driverEndpointId) + { + var nowMillis = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var evt = new Evt + { + EvtCode = EvtCode.RawCredRead, + HwTime = new DateTimeData { Millis = nowMillis }, + DbTime = new DateTimeData { Millis = nowMillis }, + Consumed = false, + Priority = 0, + Data = rawBits, + }; + + // Set EvtDevRef to the CredReader dev if available + var credReaderDev = await _z9DevRepository.GetByExternalId(driverEndpointId); + if (credReaderDev != null) + { + evt.EvtDevRef = new EvtDevRef + { + Unid = credReaderDev.Unid, + Name = credReaderDev.Name, + DevType = DevType.CredReader + }; + } + + await _z9EvtRepository.Insert(evt); + } + + private int GetStrikeTimeMs(string endpointId, bool useExtendedTime) + { + var (strikeTimeMs, extendedStrikeTimeMs) = _getStrikeTimesForEndpoint?.Invoke(endpointId) ?? (null, null); + if (useExtendedTime && extendedStrikeTimeMs.HasValue) + return extendedStrikeTimeMs.Value; + return strikeTimeMs ?? 3000; } - private async Task OpenDoor(IAccess access, Endpoint matchingDoorStrike, int strikeTimer) + private async Task OpenDoor(IAccess access, Dev strikeDev, int strikeTimeMs) { + var extensionId = await _z9DevRepository.GetExtensionId(strikeDev); + if (!extensionId.HasValue) + { + _logger.LogWarning("No extension ID found for strike z9_dev {Unid}", strikeDev.Unid); + return; + } + var controlPoint = - _extensionService.GetControlPoint(matchingDoorStrike.ExtensionId, matchingDoorStrike.DriverEndpointId); - + _extensionService.GetControlPoint(extensionId.Value, strikeDev.ExternalId); + async Task ControlStrike() { await controlPoint.SetState(true); - await Task.Delay(TimeSpan.FromSeconds(strikeTimer)); + await Task.Delay(TimeSpan.FromMilliseconds(strikeTimeMs)); await controlPoint.SetState(false); - } + } - var openDoorTasks = new[] - { - Task.Run(ControlStrike), - Task.Run(access.AccessGrantedNotification) - }; + // Fire strike asynchronously - don't block access processing + // (blocking would keep the task in WaitingForActivation state, + // causing the deduplication check to drop subsequent card reads) + _ = Task.Run(ControlStrike); - await Task.WhenAll(openDoorTasks); + await access.AccessGrantedNotification(); } - private static bool AccessGranted() + /// + /// Checks if the credential has privilege to access the door. + /// Returns null if access is granted, or the EventReason for denial. + /// + private async Task CheckAccessPrivilege(int credentialUnid, Door door, string endpointId) { - return true; + // Look up the Z9 Door unid for this endpoint + int? z9DoorUnid = _getDoorUnidForEndpoint?.Invoke(endpointId); + + // Look up the full Z9 Cred proto to check privilege bindings + var z9Cred = await _z9CredRepository.Get(credentialUnid); + if (z9Cred == null) + { + _logger.LogDebug("Z9 Cred not found for unid {Unid}, denying access", credentialUnid); + return EventReason.NoPrivilege; + } + + if (z9Cred.PrivBindings == null || z9Cred.PrivBindings.Count == 0) + { + _logger.LogDebug("Credential {Unid} has no privilege bindings, denying access", credentialUnid); + return EventReason.NoPrivilege; + } + + // Track if we found any valid privilege (to distinguish NO_PRIV from OUTSIDE_SCHED) + bool foundValidPrivilege = false; + + // Check each privilege binding + foreach (var binding in z9Cred.PrivBindings) + { + // Check for precision access (direct door reference via devAsDoorAccessPrivUnid) + if (binding.DevAsDoorAccessPrivUnidCase == CredPrivBinding.DevAsDoorAccessPrivUnidOneofCase.DevAsDoorAccessPrivUnid) + { + // Check if this precision access applies to our door + if (z9DoorUnid.HasValue && binding.DevAsDoorAccessPrivUnid != z9DoorUnid.Value) + { + _logger.LogDebug("Credential {Unid} has precision access to door {PrivDoor} but not to {ActualDoor}", + credentialUnid, binding.DevAsDoorAccessPrivUnid, z9DoorUnid.Value); + continue; // This binding doesn't apply to our door + } + + foundValidPrivilege = true; + + // Check schedule restriction on the binding + if (!await IsInSchedule(binding.SchedRestriction)) + { + _logger.LogDebug("Credential {Unid} has precision access but outside schedule", + credentialUnid); + continue; // Try other bindings + } + + _logger.LogDebug("Credential {Unid} has precision access binding to door unid {DoorUnid}", + credentialUnid, binding.DevAsDoorAccessPrivUnid); + return null; // Access granted + } + + // Check for privilege reference (DoorAccessPriv) + if (binding.PrivUnidCase == CredPrivBinding.PrivUnidOneofCase.PrivUnid) + { + var priv = await _privRepository.Get(binding.PrivUnid); + if (priv == null) + { + _logger.LogDebug("Privilege {Unid} not found for credential {CredUnid}", + binding.PrivUnid, credentialUnid); + continue; + } + + // Check if this is a door access privilege + if (priv.PrivType == PrivType.Door && priv.Enabled) + { + // Check if any DoorAccessPrivElement matches our door and element schedule + var (doorMatches, outsideElementSchedule) = await CheckDoorAccessPrivElements(priv, z9DoorUnid); + if (!doorMatches) + { + _logger.LogDebug("Credential {Unid} has privilege {PrivName} but it doesn't include door {DoorUnid}", + credentialUnid, priv.Name, z9DoorUnid); + continue; // This privilege doesn't cover our door + } + + foundValidPrivilege = true; + + if (outsideElementSchedule) + { + _logger.LogDebug("Credential {Unid} has privilege {PrivName} but outside element schedule", + credentialUnid, priv.Name); + continue; // Try other bindings + } + + // Check schedule restriction on the binding + if (!await IsInSchedule(binding.SchedRestriction)) + { + _logger.LogDebug("Credential {Unid} has privilege {PrivName} but outside schedule", + credentialUnid, priv.Name); + continue; // Try other bindings + } + + _logger.LogDebug("Credential {Unid} has door access privilege {PrivUnid} ({PrivName})", + credentialUnid, priv.Unid, priv.Name); + return null; // Access granted + } + } + } + + // If we found valid privileges but all were outside schedule, return OutsideSchedule + if (foundValidPrivilege) + { + _logger.LogDebug("Credential {Unid} has valid privileges but all are outside schedule for door {DoorName}", + credentialUnid, door.Name); + return EventReason.OutsideSchedule; + } + + _logger.LogDebug("Credential {Unid} has no matching privilege for door {DoorName}", + credentialUnid, door.Name); + return EventReason.NoPrivilege; } - private async Task MatchingDoor(string accessPointId, Endpoint[] endpoints) + /// + /// Checks if a DoorAccessPriv includes the specified door and whether the element-level schedule allows access. + /// Returns (doorMatches: true if any element covers this door, outsideElementSchedule: true if door matched but all matching elements denied by schedule). + /// + private async Task<(bool doorMatches, bool outsideElementSchedule)> CheckDoorAccessPrivElements(Priv priv, int? z9DoorUnid) { - var doors = await _doorRepository.GetAll(); + var doorAccessPriv = priv.ExtDoorAccessPriv; + if (doorAccessPriv == null || doorAccessPriv.Elements == null || doorAccessPriv.Elements.Count == 0) + { + // No elements means no doors covered + _logger.LogDebug("DoorAccessPriv {PrivUnid} has no elements", priv.Unid); + return (false, false); + } + + // If we don't know the Z9 door unid, we can't do door-specific checking + // Fall back to allowing access if there are any elements + if (!z9DoorUnid.HasValue) + { + _logger.LogDebug("No Z9 door unid available, allowing any DoorAccessPriv element"); + return (true, false); + } + + bool anyDoorMatch = false; + + foreach (var element in doorAccessPriv.Elements) + { + bool matchesDoor = false; + + // Check if element specifies a door + if (element.DoorUnidCase == DoorAccessPrivElement.DoorUnidOneofCase.DoorUnid) + { + if (element.DoorUnid == z9DoorUnid.Value) + { + matchesDoor = true; + } + } + else + { + // Element has no specific door - means "all doors" + // (community profile doesn't support devGroup or controlledArea) + _logger.LogDebug("DoorAccessPrivElement has no specific door, treating as 'all doors'"); + matchesDoor = true; + } + + if (matchesDoor) + { + anyDoorMatch = true; - var matchingDoor = doors.FirstOrDefault(door => - MatchingEndpointId(endpoints, accessPointId, door.InAccessEndpointId) || - MatchingEndpointId(endpoints, accessPointId, door.OutAccessEndpointId)); - return matchingDoor; + // Check element-level schedule restriction + if (await IsInSchedule(element.SchedRestriction)) + { + _logger.LogDebug("DoorAccessPrivElement matches door {DoorUnid} and is in schedule", z9DoorUnid.Value); + return (true, false); + } + + _logger.LogDebug("DoorAccessPrivElement matches door {DoorUnid} but outside element schedule", z9DoorUnid.Value); + // Continue checking other elements — another may grant via a different schedule + } + } + + if (anyDoorMatch) + { + _logger.LogDebug("All matching DoorAccessPrivElements for door {DoorUnid} are outside schedule", z9DoorUnid.Value); + return (true, true); + } + + _logger.LogDebug("No DoorAccessPrivElement matches door {DoorUnid}", z9DoorUnid.Value); + return (false, false); } - - private static bool MatchingEndpointId(IEnumerable endpoints, string accessPointId, int? endpointId) + + /// + /// Checks if the current time is within the schedule restriction. + /// Returns true if in schedule or no schedule restriction (null = always). + /// + private async Task IsInSchedule(SchedRestriction schedRestriction) { - if (endpointId == null) return false; - return endpointId == endpoints.FirstOrDefault(endpoint => endpoint.DriverEndpointId == accessPointId)?.Id; + // No schedule restriction means "always" (24/7) + if (schedRestriction == null) + return true; + + // No schedule unid means "always" + if (schedRestriction.SchedUnidCase != SchedRestriction.SchedUnidOneofCase.SchedUnid) + return true; + + var sched = await _schedRepository.Get(schedRestriction.SchedUnid); + if (sched == null) + { + _logger.LogWarning("Schedule {Unid} not found, treating as always active", schedRestriction.SchedUnid); + return true; + } + + var holidays = await _holRepository.GetAll(); + bool inSched = SchedEvaluator.InSched(DateTime.Now, sched, holidays); + + // Apply invert flag + if (schedRestriction.Invert) + inSched = !inSched; + + return inSched; } - private static Endpoint MatchingDoorStrike(int? doorStrikeEndpointId, IEnumerable endpoints) + private async Task MatchingDoor(string accessPointId) { - return endpoints.FirstOrDefault(endpoint => doorStrikeEndpointId == endpoint.Id); + // Find CredReader Dev by ExternalId = accessPointId (DriverEndpointId) + var credReaderDev = await _z9DevRepository.GetByExternalId(accessPointId); + if (credReaderDev == null || credReaderDev.DevType != DevType.CredReader) + return null; + + if (credReaderDev.LogicalParentUnidCase != Dev.LogicalParentUnidOneofCase.LogicalParentUnid) + return null; + + var parentDev = await _z9DevRepository.Get(credReaderDev.LogicalParentUnid); + if (parentDev == null || parentDev.DevType != DevType.Door) + return null; + + // If the parent is an exit Door (has its own parent that's also a Door), go up one level + if (parentDev.LogicalParentUnidCase == Dev.LogicalParentUnidOneofCase.LogicalParentUnid) + { + var grandparentDev = await _z9DevRepository.Get(parentDev.LogicalParentUnid); + if (grandparentDev != null && grandparentDev.DevType == DevType.Door) + { + parentDev = grandparentDev; + } + } + + return await _doorConfigurationService.BuildDoorDto(parentDev); } -} \ No newline at end of file +} diff --git a/src/Aporta.Core/Services/CredentialService.cs b/src/Aporta.Core/Services/CredentialService.cs deleted file mode 100644 index 87cbb28..0000000 --- a/src/Aporta.Core/Services/CredentialService.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Aporta.Core.DataAccess; -using Aporta.Core.DataAccess.Repositories; -using Aporta.Core.Hubs; -using Aporta.Shared.Messaging; -using Aporta.Shared.Models; -using Microsoft.AspNetCore.SignalR; - -namespace Aporta.Core.Services; - -public class CredentialService -{ - private readonly CredentialRepository _credentialRepository; - private readonly IHubContext _hubContext; - - public CredentialService(IDataAccess dataAccess, IHubContext hubContext) - { - _hubContext = hubContext; - _credentialRepository = new CredentialRepository(dataAccess); - } - - public async Task> GetAll() - { - return await _credentialRepository.GetAll(); - } - - public async Task Get(int credentialId) - { - return await _credentialRepository.Get(credentialId); - } - - public async Task Insert(Credential credential) - { - await _credentialRepository.Insert(credential); - - await _hubContext.Clients.All.SendAsync(Methods.CredentialInserted, credential.Id); - } - - public async Task Delete(int id) - { - await _credentialRepository.Delete(id); - - await _hubContext.Clients.All.SendAsync(Methods.CredentialDeleted, id); - } - - public async Task> GetUnassigned() - { - return await _credentialRepository.Unassigned(); - } - - public async Task Enroll(int credentialId, int personId) - { - await _credentialRepository.AssignPerson(credentialId, personId); - - await _hubContext.Clients.All.SendAsync(Methods.PersonUpdated, personId); - } - - public async Task> GetAssigned() - { - return await _credentialRepository.Assigned(); - } -} \ No newline at end of file diff --git a/src/Aporta.Core/Services/DevStateService.cs b/src/Aporta.Core/Services/DevStateService.cs new file mode 100644 index 0000000..5c58dbf --- /dev/null +++ b/src/Aporta.Core/Services/DevStateService.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Z9.Spcore.Proto; + +namespace Aporta.Core.Services; + +public class DevAspectStateChangedEventArgs : EventArgs +{ + public int DevUnid { get; set; } + public DevAspect Aspect { get; set; } + public DevAspectState State { get; set; } +} + +public class DevStateService +{ + private readonly ConcurrentDictionary _stateRecords = new(); + + public event EventHandler DevAspectStateChanged; + + public void UpdateAspect(int devUnid, DevAspect aspect, Action mutator) + { + var record = _stateRecords.GetOrAdd(devUnid, _ => + { + var r = new DevStateRecord { DevUnid = devUnid, Unid = devUnid }; + r.DevState = new DevState(); + return r; + }); + + lock (record) + { + if (record.DevState == null) + record.DevState = new DevState(); + + var entry = record.DevState.DevAspectStates + .FirstOrDefault(e => e.KeyCase == DevState.Types.DevAspect_DevAspectState_Entry.KeyOneofCase.Key && e.Key == aspect); + + if (entry == null) + { + entry = new DevState.Types.DevAspect_DevAspectState_Entry { Key = aspect }; + entry.Value = new DevAspectState { DevAspect = aspect }; + record.DevState.DevAspectStates.Add(entry); + } + + if (entry.Value == null) + entry.Value = new DevAspectState { DevAspect = aspect }; + + entry.Value.DbTime = new DateTimeData { Millis = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; + + mutator(entry.Value); + + DevAspectStateChanged?.Invoke(this, new DevAspectStateChangedEventArgs + { + DevUnid = devUnid, + Aspect = aspect, + State = entry.Value, + }); + } + } + + public DevStateRecord GetDevStateRecord(int devUnid) + { + if (!_stateRecords.TryGetValue(devUnid, out var record)) + return null; + lock (record) + { + return record.Clone(); + } + } + + public List GetAllDevStateRecords() + { + var result = new List(); + foreach (var record in _stateRecords.Values) + { + lock (record) + { + result.Add(record.Clone()); + } + } + return result; + } + + public void RemoveDevStateRecord(int devUnid) + { + _stateRecords.TryRemove(devUnid, out _); + } +} diff --git a/src/Aporta.Core/Services/DoorConfigurationService.cs b/src/Aporta.Core/Services/DoorConfigurationService.cs index ca3ea7c..817f226 100644 --- a/src/Aporta.Core/Services/DoorConfigurationService.cs +++ b/src/Aporta.Core/Services/DoorConfigurationService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -8,6 +9,10 @@ using Aporta.Shared.Models; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; +using Z9.Protobuf; +using Z9.Spcore.Proto; + +using Door = Aporta.Shared.Models.Door; namespace Aporta.Core.Services; @@ -16,70 +21,257 @@ public class DoorConfigurationService( IHubContext hubContext, ILogger logger) { - private readonly DoorRepository _doorRepository = new(dataAccess); - private readonly EndpointRepository _endpointRepository = new(dataAccess); - private readonly OutputRepository _outputRepository = new(dataAccess); - private readonly InputRepository _inputRepository = new(dataAccess); + private readonly Z9DevRepository _z9DevRepository = new(dataAccess); public async Task> AvailableAccessPoints() { - var endpoints = await _endpointRepository.GetAll(); - var doors = (await _doorRepository.GetAll()).ToArray(); - return endpoints.Where(endpoint => - endpoint.Type == EndpointType.Reader && - !doors.Select(door => door.InAccessEndpointId).Contains(endpoint.Id) && - !doors.Select(door => door.OutAccessEndpointId).Contains(endpoint.Id)); + var available = await _z9DevRepository.GetAvailableByDevType(DevType.CredReader); + var tasks = available.Select(async d => + { + var extensionId = await _z9DevRepository.GetExtensionId(d); + return new Endpoint + { + Id = d.Unid, + Name = d.Name, + DriverEndpointId = d.ExternalId, + ExtensionId = extensionId ?? Guid.Empty, + Type = EndpointType.Reader, + }; + }); + return await Task.WhenAll(tasks); } public async Task> AvailableEndPoints() { - var endpoints = await _endpointRepository.GetAll(); - var doors = (await _doorRepository.GetAll()).ToArray(); - var inputs = (await _inputRepository.GetAll()).ToArray(); - var outputs = (await _outputRepository.GetAll()).ToArray(); - return endpoints.Where(endpoint => - //Find readers not assigned to a door - (endpoint.Type == EndpointType.Reader && - !doors.Select(door => door.InAccessEndpointId).Contains(endpoint.Id) && - !doors.Select(door => door.OutAccessEndpointId).Contains(endpoint.Id)) - || - //Find input endpoints not assigned to a door or input - (endpoint.Type == EndpointType.Input - && !doors.Select(door => door.DoorContactEndpointId).Contains(endpoint.Id) - && !inputs.Select(input => input.EndpointId).Contains(endpoint.Id)) - || - //Find output endpoints not assigned to a door or output - (endpoint.Type == EndpointType.Output - && !doors.Select(door => door.DoorStrikeEndpointId).Contains(endpoint.Id) - && !outputs.Select(output => output.EndpointId).Contains(endpoint.Id)) - ); + var allAvailable = (await _z9DevRepository.GetAll()) + .Where(d => d.DevPlatformCase == Dev.DevPlatformOneofCase.DevPlatform && + d.DevPlatform == DevPlatform.Community && + d.DevType != DevType.IoController); + var tasks = allAvailable.Select(async d => + { + var extensionId = await _z9DevRepository.GetExtensionId(d); + var type = d.DevType switch + { + DevType.Actuator => EndpointType.Output, + DevType.Sensor => EndpointType.Input, + DevType.CredReader => EndpointType.Reader, + _ => EndpointType.Output, + }; + return new Endpoint + { + Id = d.Unid, + Name = d.Name, + DriverEndpointId = d.ExternalId, + ExtensionId = extensionId ?? Guid.Empty, + Type = type, + }; + }); + return await Task.WhenAll(tasks); } public async Task> GetAll() { - return await _doorRepository.GetAll(); + var allDevs = (await _z9DevRepository.GetAllByDevType(DevType.Door)).ToArray(); + // Top-level doors: those with no logical parent, or whose parent is not a Door + var topLevelDoors = new List(); + foreach (var dev in allDevs) + { + if (dev.LogicalParentUnidCase != Dev.LogicalParentUnidOneofCase.LogicalParentUnid) + { + topLevelDoors.Add(dev); + continue; + } + var parent = await _z9DevRepository.Get(dev.LogicalParentUnid); + if (parent == null || parent.DevType != DevType.Door) + { + topLevelDoors.Add(dev); + } + } + var doors = new List(); + foreach (var dev in topLevelDoors) + { + doors.Add(await BuildDoorDto(dev)); + } + return doors; } - + public async Task Get(int doorId) { - return await _doorRepository.Get(doorId); + var dev = await _z9DevRepository.Get(doorId); + if (dev == null || dev.DevType != DevType.Door) return null; + return await BuildDoorDto(dev); } public async Task Insert(Door door) { logger.LogDebug("Request to insert door {Name}", door.Name); - - await _doorRepository.Insert(door); - + + // Create the Door Dev + var doorDev = new Dev + { + Name = door.Name, + DevType = DevType.Door, + }; + SpCoreProtoUtil.InitRequired(doorDev); + var doorUnid = await _z9DevRepository.Insert(doorDev); + + // Assign CredReader child for in-access (from pool z9_dev) + if (door.InAccessEndpointId.HasValue) + { + var childUnid = await AssignPoolDevAsDoorChild( + door.InAccessEndpointId.Value, doorUnid, DevUse.ActuatorDoorStrike, setDevUse: false); + if (childUnid.HasValue) + doorDev.LogicalChildrenUnid.Add(childUnid.Value); + } + + // Create exit Door child + CredReader grandchild for out-access + if (door.OutAccessEndpointId.HasValue) + { + var poolDev = await _z9DevRepository.Get(door.OutAccessEndpointId.Value); + if (poolDev != null) + { + var exitDoorDev = new Dev + { + Name = $"{door.Name} - Exit", + DevType = DevType.Door, + LogicalParentUnid = doorUnid, + }; + SpCoreProtoUtil.InitRequired(exitDoorDev); + var exitDoorUnid = await _z9DevRepository.Insert(exitDoorDev); + doorDev.LogicalChildrenUnid.Add(exitDoorUnid); + + var grandchildUnid = await AssignPoolDevAsDoorChild( + door.OutAccessEndpointId.Value, exitDoorUnid, DevUse.ActuatorDoorStrike, setDevUse: false); + if (grandchildUnid.HasValue) + { + exitDoorDev.LogicalChildrenUnid.Add(grandchildUnid.Value); + await _z9DevRepository.Upsert(exitDoorDev); + } + } + } + + // Assign Sensor child for door contact (from pool z9_dev) + if (door.DoorContactEndpointId.HasValue) + { + var childUnid = await AssignPoolDevAsDoorChild( + door.DoorContactEndpointId.Value, doorUnid, DevUse.SensorDoorContact); + if (childUnid.HasValue) + doorDev.LogicalChildrenUnid.Add(childUnid.Value); + } + + // Assign Sensor child for REX (from pool z9_dev) + if (door.RequestToExitEndpointId.HasValue) + { + var childUnid = await AssignPoolDevAsDoorChild( + door.RequestToExitEndpointId.Value, doorUnid, DevUse.SensorRex); + if (childUnid.HasValue) + doorDev.LogicalChildrenUnid.Add(childUnid.Value); + } + + // Assign Actuator child for door strike (from pool z9_dev) + if (door.DoorStrikeEndpointId.HasValue) + { + var childUnid = await AssignPoolDevAsDoorChild( + door.DoorStrikeEndpointId.Value, doorUnid, DevUse.ActuatorDoorStrike); + if (childUnid.HasValue) + doorDev.LogicalChildrenUnid.Add(childUnid.Value); + } + + // Update Door Dev with children + await _z9DevRepository.Upsert(doorDev); + door.Id = doorUnid; + await hubContext.Clients.All.SendAsync(Methods.DoorInserted, door.Id); } + /// + /// Assigns a pool z9_dev as a door child by setting logicalParentUnid and clearing DevPlatform. + /// Returns the z9_dev unid, or null if the pool dev was not found. + /// + private async Task AssignPoolDevAsDoorChild(int poolDevUnid, int doorUnid, DevUse devUse, bool setDevUse = true) + { + var poolDev = await _z9DevRepository.Get(poolDevUnid); + if (poolDev == null) return null; + + poolDev.LogicalParentUnid = doorUnid; + poolDev.Enabled = true; + if (setDevUse) + { + poolDev.DevUse = devUse; + } + await _z9DevRepository.Upsert(poolDev); + return poolDev.Unid; + } + public async Task Delete(int id) { logger.LogDebug("Request to delete door with id of {Id}", id); - - await _doorRepository.Delete(id); - + + var doorDev = await _z9DevRepository.Get(id); + if (doorDev != null) + { + // Delete children recursively + var children = await _z9DevRepository.GetChildren(id); + foreach (var child in children) + { + // If child is an exit door, delete its grandchildren too + if (child.DevType == DevType.Door) + { + var grandchildren = await _z9DevRepository.GetChildren(child.Unid); + foreach (var grandchild in grandchildren) + { + await _z9DevRepository.Delete(grandchild.Unid); + } + } + await _z9DevRepository.Delete(child.Unid); + } + await _z9DevRepository.Delete(id); + } + await hubContext.Clients.All.SendAsync(Methods.DoorDeleted, id); } -} \ No newline at end of file + + internal async Task BuildDoorDto(Dev doorDev) + { + var door = new Door + { + Id = doorDev.Unid, + Name = doorDev.Name, + }; + + var children = (await _z9DevRepository.GetChildren(doorDev.Unid)).ToArray(); + + foreach (var child in children) + { + switch (child.DevType) + { + case DevType.CredReader: + // In-access reader — EndpointId is the z9_dev unid + door.InAccessEndpointId = child.Unid; + break; + + case DevType.Door: + // Exit door — get its CredReader grandchild for out-access + var grandchildren = (await _z9DevRepository.GetChildren(child.Unid)).ToArray(); + var outReader = grandchildren.FirstOrDefault(gc => gc.DevType == DevType.CredReader); + if (outReader != null) + door.OutAccessEndpointId = outReader.Unid; + break; + + case DevType.Sensor when child.DevUseCase == Dev.DevUseOneofCase.DevUse && child.DevUse == DevUse.SensorDoorContact: + door.DoorContactEndpointId = child.Unid; + break; + + case DevType.Sensor when child.DevUseCase == Dev.DevUseOneofCase.DevUse && child.DevUse == DevUse.SensorRex: + door.RequestToExitEndpointId = child.Unid; + break; + + case DevType.Actuator when child.DevUseCase == Dev.DevUseOneofCase.DevUse && child.DevUse == DevUse.ActuatorDoorStrike: + door.DoorStrikeEndpointId = child.Unid; + break; + } + } + + return door; + } +} diff --git a/src/Aporta.Core/Services/EventReasonMapping.cs b/src/Aporta.Core/Services/EventReasonMapping.cs new file mode 100644 index 0000000..b3b8247 --- /dev/null +++ b/src/Aporta.Core/Services/EventReasonMapping.cs @@ -0,0 +1,72 @@ +using System; +using Aporta.Shared.Models; +using Z9.Spcore.Proto; + +namespace Aporta.Core.Services; + +/// +/// Bidirectional mapping between classic Aporta EventType/EventReason +/// and Z9/Open EvtCode/EvtSubCode. +/// +public static class EventReasonMapping +{ + /// + /// Maps classic EventType + EventReason to Z9/Open EvtCode + EvtSubCode. + /// evtSubCode is null for granted events. + /// + public static (EvtCode evtCode, EvtSubCode? evtSubCode) ToEvtCodes( + EventType eventType, EventReason reason) + { + if (eventType == EventType.AccessGranted) + return (EvtCode.DoorAccessGranted, null); + + var subCode = reason switch + { + EventReason.CredentialNotEnrolled => EvtSubCode.AccessDeniedUnknownCredNum, + EventReason.NoCredentialTemplate => EvtSubCode.AccessDeniedUnknownCredNumFormat, + EventReason.CredentialDisabled => EvtSubCode.AccessDeniedInactive, + EventReason.CredentialNotYetEffective => EvtSubCode.AccessDeniedNotEffective, + EventReason.CredentialExpired => EvtSubCode.AccessDeniedExpired, + EventReason.NoPrivilege => EvtSubCode.AccessDeniedNoPriv, + EventReason.AccessNotAssigned => EvtSubCode.AccessDeniedNoPriv, + EventReason.OutsideSchedule => EvtSubCode.AccessDeniedOutsideSched, + EventReason.DoorLocked => EvtSubCode.AccessDeniedDoorModeStaticLocked, + EventReason.UnknownUniquePin => EvtSubCode.AccessDeniedUnknownCredUniquePin, + EventReason.NoConfirmingPin => EvtSubCode.AccessDeniedNoConfirmingPinForCred, + EventReason.IncorrectConfirmingPin => EvtSubCode.AccessDeniedIncorrectConfirmingPin, + _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, + $"No EvtSubCode mapping for EventReason.{reason}") + }; + + return (EvtCode.DoorAccessDenied, subCode); + } + + /// + /// Reverse mapping: Z9/Open EvtCode + EvtSubCode back to classic EventType + EventReason. + /// Used by EventService to reconstruct Event DTOs from Evt protos. + /// + public static (EventType eventType, EventReason reason) ToEventTypeAndReason( + EvtCode evtCode, EvtSubCode evtSubCode) + { + if (evtCode == EvtCode.DoorAccessGranted) + return (EventType.AccessGranted, EventReason.None); + + var reason = evtSubCode switch + { + EvtSubCode.AccessDeniedUnknownCredNum => EventReason.CredentialNotEnrolled, + EvtSubCode.AccessDeniedUnknownCredNumFormat => EventReason.NoCredentialTemplate, + EvtSubCode.AccessDeniedInactive => EventReason.CredentialDisabled, + EvtSubCode.AccessDeniedNotEffective => EventReason.CredentialNotYetEffective, + EvtSubCode.AccessDeniedExpired => EventReason.CredentialExpired, + EvtSubCode.AccessDeniedNoPriv => EventReason.NoPrivilege, + EvtSubCode.AccessDeniedOutsideSched => EventReason.OutsideSchedule, + EvtSubCode.AccessDeniedDoorModeStaticLocked => EventReason.DoorLocked, + EvtSubCode.AccessDeniedUnknownCredUniquePin => EventReason.UnknownUniquePin, + EvtSubCode.AccessDeniedNoConfirmingPinForCred => EventReason.NoConfirmingPin, + EvtSubCode.AccessDeniedIncorrectConfirmingPin => EventReason.IncorrectConfirmingPin, + _ => EventReason.None + }; + + return (EventType.AccessDenied, reason); + } +} diff --git a/src/Aporta.Core/Services/EventService.cs b/src/Aporta.Core/Services/EventService.cs deleted file mode 100644 index 9f23b35..0000000 --- a/src/Aporta.Core/Services/EventService.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Threading.Tasks; - -using Aporta.Core.DataAccess; -using Aporta.Core.DataAccess.Repositories; -using Aporta.Shared.Models; - -namespace Aporta.Core.Services; - -public class EventService -{ - private readonly EventRepository _eventRepository; - - public EventService(IDataAccess dataAccess) - { - _eventRepository = new EventRepository(dataAccess); - } - - public async Task Get(int eventId) - { - return await _eventRepository.Get(eventId); - } - - public async Task> GetAll(int pageNumber, int pageSize) - { - return await _eventRepository.GetAll(pageNumber, pageSize, "timestamp"); - } -} \ No newline at end of file diff --git a/src/Aporta.Core/Services/ExtensionService.cs b/src/Aporta.Core/Services/ExtensionService.cs index 09b9e27..746d4c9 100644 --- a/src/Aporta.Core/Services/ExtensionService.cs +++ b/src/Aporta.Core/Services/ExtensionService.cs @@ -20,11 +20,13 @@ using Aporta.Extensions.Hardware; using Aporta.Shared.Messaging; using Aporta.Shared.Models; +using Z9.Protobuf; +using Z9.Spcore.Proto; namespace Aporta.Core.Services; /// -/// +/// /// public class ExtensionService( IDataAccess dataAccess, @@ -36,8 +38,7 @@ public class ExtensionService( private static readonly SemaphoreSlim EndpointUpdateSemaphore = new(1, 1); private readonly ExtensionRepository _extensionRepository = new(dataAccess); - private readonly EndpointRepository _endpointRepository = new(dataAccess); - private readonly DoorRepository _doorRepository = new(dataAccess); + private readonly Z9DevRepository _z9DevRepository = new(dataAccess); private readonly List _extensions = new(); private readonly object _extensionLock = new (); @@ -110,7 +111,7 @@ public IOutput GetControlPoint(Guid extensionId, string endpointId) return _extensions.First(extension => extension.Id == extensionId).Driver.Endpoints .First(endpoint => endpoint.Id == endpointId) as IOutput; } - + public IInput GetMonitorPoint(Guid extensionId, string endpointId) { return _extensions.First(extension => extension.Id == extensionId).Driver.Endpoints @@ -123,6 +124,26 @@ public IAccess GetAccessPoint(Guid extensionId, string endpointId) .First(endpoint => endpoint.Id == endpointId) as IAccess; } + /// + /// Returns all endpoints from all loaded drivers, paired with their extension (driver) GUID. + /// + public IEnumerable<(IEndpoint Endpoint, Guid ExtensionId)> GetAllDriverEndpoints() + { + return _extensions + .Where(e => e.Loaded && e.Driver != null) + .SelectMany(e => e.Driver.Endpoints.Select(ep => (ep, e.Id))); + } + + /// + /// Checks if a driver endpoint exists in any loaded driver. + /// + public bool DriverEndpointExists(string driverEndpointId) + { + return _extensions + .Where(e => e.Loaded && e.Driver != null) + .Any(e => e.Driver.Endpoints.Any(ep => ep.Id == driverEndpointId)); + } + public IEnumerable GetExtensions() { return _extensions.Select(extension => @@ -132,23 +153,27 @@ public IAccess GetAccessPoint(Guid extensionId, string endpointId) return clone; }); } - + public Shared.Models.Extension GetExtension(Guid extensionId) { var extension = _extensions.FirstOrDefault(extension => extension.Id == extensionId); var clone = extension?.ShallowCopy(); - + if (clone == null) return null; - + clone.Configuration = extension.Driver?.ScrubSensitiveConfigurationData(clone.Configuration); return clone; } public event EventHandler AccessCredentialReceived; - + public event EventHandler StateChanged; + public event EventHandler OnlineStatusChanged; + + public event EventHandler LocalStatusChanged; + [MethodImpl(MethodImplOptions.NoInlining)] private async Task DiscoverExtensions() { @@ -228,12 +253,42 @@ private void LoadExtension(ExtensionHost extension) extension.Driver.UpdatedEndpoints += DriverOnUpdatedEndpoints; extension.Driver.AccessCredentialReceived += DriverOnAccessCredentialReceived; extension.Driver.StateChanged += DriverOnStateChanged; + extension.Driver.OnlineStatusChanged += DriverOnOnlineStatusChanged; + extension.Driver.LocalStatusChanged += DriverOnLocalStatusChanged; extension.Driver.Load(extension.Configuration, dataEncryption, loggerFactory); extension.Configuration = extension.Driver.CurrentConfiguration(); extension.Loaded = true; } + // Controller z9_dev is created lazily in DriverOnUpdatedEndpoints when the driver + // first reports its endpoints, avoiding a race with the concurrent endpoint sync. + } + + /// + /// Ensures an IO_CONTROLLER_EXTERNAL z9_dev exists for the given driver extension. + /// + private async Task EnsureControllerDev(ExtensionHost extension) + { + var existingController = await _z9DevRepository.GetController(extension.Id); + if (existingController != null) + return; + + var controllerDev = new Dev + { + Name = extension.Name, + DevType = DevType.IoController, + DevMod = DevMod.IoControllerCommunity, + DevPlatform = DevPlatform.Community, + ExternalDevModId = extension.Id.ToString(), + ExternalDevModText = extension.Name, + ExternalId = extension.Id.ToString(), + }; + SpCoreProtoUtil.InitRequired(controllerDev); + await _z9DevRepository.Insert(controllerDev); + + logger.LogInformation("Created IO_CONTROLLER_COMMUNITY z9_dev for driver {Name} (extensionId={ExtensionId})", + extension.Name, extension.Id); } private void DriverOnUpdatedEndpoints(object sender, EventArgs eventArgs) @@ -246,49 +301,77 @@ private void DriverOnUpdatedEndpoints(object sender, EventArgs eventArgs) try { - var existingEndpoints = (await _endpointRepository.GetForExtension(driver.Id)).ToArray(); - foreach (var endpoint in EndpointsToBeInserted(driver, existingEndpoints)) + // Ensure controller exists before syncing endpoints + var controllerDev = await _z9DevRepository.GetController(driver.Id); + if (controllerDev == null) { - var insertEndpoint = new Endpoint + // Controller not yet created; EnsureControllerDev will handle it + var extension = _extensions.FirstOrDefault(e => e.Id == driver.Id); + if (extension != null) { - DriverEndpointId = endpoint.Id, ExtensionId = driver.Id, Name = endpoint.Name, - Type = endpoint switch - { - IOutput _ => EndpointType.Output, - IInput _ => EndpointType.Input, - IAccess _ => EndpointType.Reader, - _ => throw new Exception("Invalid endpoint type") - } - }; + await EnsureControllerDev(extension); + controllerDev = await _z9DevRepository.GetController(driver.Id); + } + } - await _endpointRepository.Insert(insertEndpoint); + if (controllerDev == null) + { + logger.LogWarning("No controller z9_dev found for driver {DriverId}, skipping endpoint sync", driver.Id); + return; } - foreach (var endpoint in EndpointsToBeUpdated(driver, existingEndpoints)) + var allPhysicalChildren = (await _z9DevRepository.GetPhysicalChildren(controllerDev.Unid)).ToArray(); + var existingPoolDevs = allPhysicalChildren.Where(d => !d.Enabled).ToArray(); + + // Use ALL children's external IDs to avoid creating duplicates for assigned devs + var allExternalIds = allPhysicalChildren + .Select(d => d.ExternalId) + .ToHashSet(); + + // Insert new endpoints as pool z9_devs + foreach (var endpoint in driver.Endpoints.Where(ep => + ep.ExtensionId == driver.Id && !allExternalIds.Contains(ep.Id))) { - var updateEndpoint = new Endpoint + var devType = endpoint switch { - DriverEndpointId = endpoint.Id, ExtensionId = driver.Id, Name = endpoint.Name, - Type = endpoint switch - { - IOutput _ => EndpointType.Output, - IInput _ => EndpointType.Input, - IAccess _ => EndpointType.Reader, - _ => throw new Exception("Invalid endpoint type") - } + IAccess => DevType.CredReader, + IOutput => DevType.Actuator, + IInput => DevType.Sensor, + _ => DevType.IoController }; - await _endpointRepository.Update(updateEndpoint); + var poolDev = new Dev + { + Name = endpoint.Name, + DevType = devType, + DevPlatform = DevPlatform.Community, + ExternalId = endpoint.Id, + PhysicalParentUnid = controllerDev.Unid, + }; + SpCoreProtoUtil.InitRequired(poolDev); + await _z9DevRepository.Insert(poolDev); } - var allEndPoints = (await _endpointRepository.GetAll()).ToArray(); - var doors = (await _doorRepository.GetAll()).ToArray(); - foreach (var endpoint in EndpointsToBeDeleted(driver, existingEndpoints)) + + // Update existing pool z9_devs (name changes etc.) — only pool, not assigned + var driverEndpointIds = driver.Endpoints + .Where(ep => ep.ExtensionId == driver.Id) + .Select(ep => ep.Id) + .ToHashSet(); + + foreach (var poolDev in existingPoolDevs.Where(d => driverEndpointIds.Contains(d.ExternalId))) { - if (IsEndPointAvailableForDelete(endpoint, doors, allEndPoints)) + var driverEndpoint = driver.Endpoints.First(ep => ep.Id == poolDev.ExternalId); + if (poolDev.Name != driverEndpoint.Name) { - await _endpointRepository.Delete(endpoint.Id); + poolDev.Name = driverEndpoint.Name; + await _z9DevRepository.Upsert(poolDev); } - + } + + // Delete pool z9_devs for endpoints no longer reported by driver + foreach (var poolDev in existingPoolDevs.Where(d => !driverEndpointIds.Contains(d.ExternalId))) + { + await _z9DevRepository.Delete(poolDev.Unid); } await SaveCurrentConfiguration(MatchingExtensionHost(driver.Id)); @@ -300,58 +383,24 @@ private void DriverOnUpdatedEndpoints(object sender, EventArgs eventArgs) }); } - - private bool IsEndPointAvailableForDelete(Endpoint endpointToDelete, Door[] doors, IEnumerable endpoints) - { - var availableEndPoints = endpoints.Where(endpoint => - //Find readers not assigned to a door - (endpoint.Type == EndpointType.Reader && - !doors.Select(door => door.InAccessEndpointId).Contains(endpoint.Id) && - !doors.Select(door => door.OutAccessEndpointId).Contains(endpoint.Id)) - || - //Find inputs not assigned to a door - (endpoint.Type == EndpointType.Input && - !doors.Select(door => door.DoorContactEndpointId).Contains(endpoint.Id)) - || - //Find outputs not assigned to a door - (endpoint.Type == EndpointType.Output && - !doors.Select(door => door.DoorStrikeEndpointId).Contains(endpoint.Id)) - ); - - return availableEndPoints.Count(endpoint => endpoint.DriverEndpointId == endpointToDelete.DriverEndpointId) > 0; - } - private void DriverOnAccessCredentialReceived(object sender, AccessCredentialReceivedEventArgs eventArgs) { AccessCredentialReceived?.Invoke(this, eventArgs); } - + private void DriverOnStateChanged(object sender, StateChangedEventArgs eventArgs) { StateChanged?.Invoke(this, eventArgs); } - private static IEnumerable EndpointsToBeInserted(IHardwareDriver driver, - Endpoint[] existingEndpoints) - { - return driver.Endpoints.Where(endpoint => - endpoint.ExtensionId == driver.Id && !existingEndpoints - .Select(existingEndpoint => existingEndpoint.DriverEndpointId).Contains(endpoint.Id)); - } - - private static IEnumerable EndpointsToBeUpdated(IHardwareDriver driver, Endpoint[] existingEndpoints) + private void DriverOnOnlineStatusChanged(object sender, OnlineStatusChangedEventArgs eventArgs) { - return driver.Endpoints.Where(endpoint => - endpoint.ExtensionId == driver.Id && existingEndpoints - .Select(existingEndpoint => existingEndpoint.DriverEndpointId).Contains(endpoint.Id)); + OnlineStatusChanged?.Invoke(this, eventArgs); } - private static IEnumerable EndpointsToBeDeleted(IHardwareDriver driver, - IEnumerable existingEndpoints) + private void DriverOnLocalStatusChanged(object sender, LocalStatusChangedEventArgs eventArgs) { - return existingEndpoints.Where(existingEndpoint => - existingEndpoint.ExtensionId == driver.Id && !driver.Endpoints - .Select(endpoint => endpoint.Id).Contains(existingEndpoint.DriverEndpointId)); + LocalStatusChanged?.Invoke(this, eventArgs); } private async Task SaveCurrentConfiguration(ExtensionHost extension) @@ -392,14 +441,16 @@ private void UnloadExtension(ExtensionHost extension) { return; } - + extension.Driver.Unload(); extension.Driver.UpdatedEndpoints -= DriverOnUpdatedEndpoints; extension.Driver.AccessCredentialReceived -= DriverOnAccessCredentialReceived; extension.Driver.StateChanged -= DriverOnStateChanged; + extension.Driver.OnlineStatusChanged -= DriverOnOnlineStatusChanged; + extension.Driver.LocalStatusChanged -= DriverOnLocalStatusChanged; extension.Host.Unload(); extension.Loaded = false; } } -} \ No newline at end of file +} diff --git a/src/Aporta.Core/Services/FlexMapper.cs b/src/Aporta.Core/Services/FlexMapper.cs new file mode 100644 index 0000000..14578d4 --- /dev/null +++ b/src/Aporta.Core/Services/FlexMapper.cs @@ -0,0 +1,1132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Aporta.Shared.Models.Flex; +using Google.Protobuf; +using Z9.Protobuf; +using Z9.Spcore.Proto; + +namespace Aporta.Core.Services; + +public static class FlexMapper +{ + // --- ObjRef helpers --- + + public static FlexObjRef MakeObjRef(string type, int unid, string name = null, string uuid = null, string tag = null, string externalId = null) + { + return new FlexObjRef { Type = type, Unid = unid, Name = name, Uuid = uuid, Tag = tag, ExternalId = externalId }; + } + + // --- DateTime helpers --- + + public static string DateTimeDataToIso(DateTimeData dt) + { + if (dt == null) return null; + return DateTimeOffset.FromUnixTimeMilliseconds(dt.Millis).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + } + + public static DateTimeData IsoToDateTimeData(string iso) + { + if (string.IsNullOrEmpty(iso)) return null; + if (DateTimeOffset.TryParse(iso, out var dto)) + return new DateTimeData { Millis = dto.ToUnixTimeMilliseconds() }; + return null; + } + + public static string SqlDateToIso(SqlDateData d) + { + if (d == null) return null; + return $"{d.Year:D4}-{d.Month:D2}-{d.Day:D2}"; + } + + public static SqlDateData IsoToSqlDate(string iso) + { + if (string.IsNullOrEmpty(iso)) return null; + if (DateTime.TryParse(iso, out var dt)) + return new SqlDateData { Year = dt.Year, Month = dt.Month, Day = dt.Day }; + return null; + } + + public static string SqlTimeToString(SqlTimeData t) + { + if (t == null) return null; + return $"{t.Hour:D2}:{t.Minute:D2}:{t.Second:D2}"; + } + + public static SqlTimeData StringToSqlTime(string s) + { + if (string.IsNullOrEmpty(s)) return null; + if (TimeSpan.TryParse(s, out var ts)) + return new SqlTimeData { Hour = ts.Hours, Minute = ts.Minutes, Second = ts.Seconds }; + return null; + } + + // --- BigInteger helpers --- + + public static string BigIntegerDataToString(BigIntegerData data) + { + if (data == null || data.BytesCase == BigIntegerData.BytesOneofCase.None) return null; + return SpCoreProtoUtil.ToBigInteger(data).ToString(); + } + + public static BigIntegerData StringToBigIntegerData(string s) + { + if (string.IsNullOrEmpty(s)) return null; + if (BigInteger.TryParse(s, out var val)) + return SpCoreProtoUtil.ToBigIntegerData(val); + return null; + } + + // --- Cred --- + + public static FlexCred ToFlex(Cred proto, Func lookupRef = null) + { + var flex = new FlexCred + { + Version = proto.Version, + Tag = proto.Tag, + Uuid = proto.Uuid, + Unid = proto.Unid, + Name = proto.Name, + Enabled = proto.Enabled, + Effective = proto.Effective != null ? DateTimeDataToIso(proto.Effective) : null, + Expires = proto.Expires != null ? DateTimeDataToIso(proto.Expires) : null, + }; + + if (proto.CredTemplateUnid != 0) + flex.CredTemplate = lookupRef?.Invoke(proto.CredTemplateUnid, "credTemplate") ?? MakeObjRef("credTemplate", proto.CredTemplateUnid); + + if (proto.CardPin != null) + flex.CardPin = ToFlexCardPin(proto.CardPin); + + if (proto.DoorAccessModifiers != null) + flex.DoorAccessModifiers = new FlexDoorAccessModifiers + { + ExtDoorTime = proto.DoorAccessModifiers.ExtDoorTimeCase == DoorAccessModifiers.ExtDoorTimeOneofCase.ExtDoorTime + ? proto.DoorAccessModifiers.ExtDoorTime : null + }; + + if (proto.PrivBindings.Count > 0) + { + flex.PrivBindings = proto.PrivBindings.Select(pb => ToFlexCredPrivBinding(pb, lookupRef)).ToList(); + } + + return flex; + } + + public static Cred ToProto(FlexCred flex) + { + var proto = new Cred + { + Version = flex.Version ?? 0, + Tag = flex.Tag ?? "", + Uuid = flex.Uuid ?? "", + Unid = flex.Unid ?? 0, + Name = flex.Name ?? "", + Enabled = flex.Enabled ?? true, + }; + + if (flex.Effective != null) + proto.Effective = IsoToDateTimeData(flex.Effective); + if (flex.Expires != null) + proto.Expires = IsoToDateTimeData(flex.Expires); + if (flex.CredTemplate?.Unid != null) + proto.CredTemplateUnid = flex.CredTemplate.Unid.Value; + + if (flex.CardPin != null) + proto.CardPin = ToProtoCardPin(flex.CardPin); + + if (flex.DoorAccessModifiers != null) + { + proto.DoorAccessModifiers = new DoorAccessModifiers(); + if (flex.DoorAccessModifiers.ExtDoorTime.HasValue) + proto.DoorAccessModifiers.ExtDoorTime = flex.DoorAccessModifiers.ExtDoorTime.Value; + } + + if (flex.PrivBindings != null) + { + foreach (var pb in flex.PrivBindings) + proto.PrivBindings.Add(ToProtoCredPrivBinding(pb)); + } + + return proto; + } + + private static FlexCardPin ToFlexCardPin(CardPin cp) + { + return new FlexCardPin + { + CredNum = BigIntegerDataToString(cp.CredNum), + FacilityCode = cp.FacilityCodeCase == CardPin.FacilityCodeOneofCase.FacilityCode ? cp.FacilityCode : null, + Pin = cp.Pin, + PinUnique = cp.PinUniqueCase == CardPin.PinUniqueOneofCase.PinUnique ? cp.PinUnique : null, + }; + } + + private static CardPin ToProtoCardPin(FlexCardPin flex) + { + var cp = new CardPin + { + Pin = flex.Pin ?? "", + }; + if (flex.CredNum != null) + cp.CredNum = StringToBigIntegerData(flex.CredNum); + if (flex.FacilityCode.HasValue) + cp.FacilityCode = flex.FacilityCode.Value; + if (flex.PinUnique.HasValue) + cp.PinUnique = flex.PinUnique.Value; + return cp; + } + + private static FlexCredPrivBinding ToFlexCredPrivBinding(CredPrivBinding pb, Func lookupRef) + { + var flex = new FlexCredPrivBinding + { + Unid = pb.Unid, + }; + + if (pb.PrivUnid != 0) + flex.Priv = lookupRef?.Invoke(pb.PrivUnid, "priv") ?? MakeObjRef("priv", pb.PrivUnid); + + if (pb.DevAsDoorAccessPrivUnidCase == CredPrivBinding.DevAsDoorAccessPrivUnidOneofCase.DevAsDoorAccessPrivUnid) + flex.DevAsDoorAccessPriv = lookupRef?.Invoke(pb.DevAsDoorAccessPrivUnid, "dev") ?? MakeObjRef("dev", pb.DevAsDoorAccessPrivUnid); + + if (pb.SchedRestriction != null) + flex.SchedRestriction = ToFlexSchedRestriction(pb.SchedRestriction, lookupRef); + + return flex; + } + + private static CredPrivBinding ToProtoCredPrivBinding(FlexCredPrivBinding flex) + { + var pb = new CredPrivBinding { Unid = flex.Unid ?? 0 }; + if (flex.Priv?.Unid != null) pb.PrivUnid = flex.Priv.Unid.Value; + if (flex.DevAsDoorAccessPriv?.Unid != null) pb.DevAsDoorAccessPrivUnid = flex.DevAsDoorAccessPriv.Unid.Value; + if (flex.SchedRestriction != null) + pb.SchedRestriction = ToProtoSchedRestriction(flex.SchedRestriction); + return pb; + } + + // --- SchedRestriction --- + + private static FlexSchedRestriction ToFlexSchedRestriction(SchedRestriction sr, Func lookupRef) + { + var flex = new FlexSchedRestriction + { + Invert = sr.Invert, + }; + if (sr.SchedUnidCase == SchedRestriction.SchedUnidOneofCase.SchedUnid) + flex.Sched = lookupRef?.Invoke(sr.SchedUnid, "sched") ?? MakeObjRef("sched", sr.SchedUnid); + return flex; + } + + private static SchedRestriction ToProtoSchedRestriction(FlexSchedRestriction flex) + { + var sr = new SchedRestriction { Invert = flex.Invert ?? false }; + if (flex.Sched?.Unid != null) sr.SchedUnid = flex.Sched.Unid.Value; + return sr; + } + + // --- CredTemplate --- + + public static FlexCredTemplate ToFlex(CredTemplate proto, Func lookupRef = null) + { + var flex = new FlexCredTemplate + { + Version = proto.Version, + Tag = proto.Tag, + Uuid = proto.Uuid, + Name = proto.Name, + Unid = proto.Unid, + Priority = proto.Priority, + }; + + if (proto.CardPinTemplate != null) + flex.CardPinTemplate = ToFlexCardPinTemplate(proto.CardPinTemplate, lookupRef); + + return flex; + } + + public static CredTemplate ToProto(FlexCredTemplate flex) + { + var proto = new CredTemplate + { + Version = flex.Version ?? 0, + Tag = flex.Tag ?? "", + Uuid = flex.Uuid ?? "", + Name = flex.Name ?? "", + Unid = flex.Unid ?? 0, + Priority = flex.Priority ?? 0, + }; + + if (flex.CardPinTemplate != null) + proto.CardPinTemplate = ToProtoCardPinTemplate(flex.CardPinTemplate); + + return proto; + } + + private static FlexCardPinTemplate ToFlexCardPinTemplate(CardPinTemplate cpt, Func lookupRef) + { + var flex = new FlexCardPinTemplate + { + CredComponentPresence = (int)cpt.CredComponentPresence, + CredNumPresence = (int)cpt.CredNumPresence, + PinPresence = (int)cpt.PinPresence, + PinUnique = cpt.PinUniqueCase == CardPinTemplate.PinUniqueOneofCase.PinUnique ? cpt.PinUnique : null, + MinPinLength = cpt.MinPinLength, + MaxPinLength = cpt.MaxPinLength, + FacilityCode = cpt.FacilityCode, + AnyDataLayout = cpt.AnyDataLayout, + }; + + if (cpt.DataLayoutUnid != 0) + flex.DataLayout = lookupRef?.Invoke(cpt.DataLayoutUnid, "dataLayout") ?? MakeObjRef("dataLayout", cpt.DataLayoutUnid); + + return flex; + } + + private static CardPinTemplate ToProtoCardPinTemplate(FlexCardPinTemplate flex) + { + var cpt = new CardPinTemplate + { + CredComponentPresence = (CredComponentPresence)(flex.CredComponentPresence ?? 0), + CredNumPresence = (CredComponentPresence)(flex.CredNumPresence ?? 0), + PinPresence = (CredComponentPresence)(flex.PinPresence ?? 0), + MinPinLength = flex.MinPinLength ?? 0, + MaxPinLength = flex.MaxPinLength ?? 0, + FacilityCode = flex.FacilityCode ?? 0, + AnyDataLayout = flex.AnyDataLayout ?? false, + }; + if (flex.PinUnique.HasValue) cpt.PinUnique = flex.PinUnique.Value; + if (flex.DataLayout?.Unid != null) cpt.DataLayoutUnid = flex.DataLayout.Unid.Value; + return cpt; + } + + // --- Evt --- + + public static FlexEvt ToFlex(Evt proto) + { + var flex = new FlexEvt + { + Unid = proto.Unid, + HwTime = DateTimeDataToIso(proto.HwTime), + DbTime = DateTimeDataToIso(proto.DbTime), + HwTimeZone = proto.HwTimeZone, + EvtCode = (int)proto.EvtCode, + EvtCodeText = Messages.GetEvtCodeText(proto.EvtCode), + ExternalEvtCodeText = string.IsNullOrEmpty(proto.ExternalEvtCodeText) ? null : proto.ExternalEvtCodeText, + ExternalEvtCodeId = string.IsNullOrEmpty(proto.ExternalEvtCodeId) ? null : proto.ExternalEvtCodeId, + EvtSubCode = proto.EvtSubCodeCase == Evt.EvtSubCodeOneofCase.EvtSubCode ? (int)proto.EvtSubCode : null, + EvtSubCodeText = proto.EvtSubCodeCase == Evt.EvtSubCodeOneofCase.EvtSubCode ? Messages.GetEvtSubCodeText(proto.EvtSubCode) : null, + ExternalSubCodeText = string.IsNullOrEmpty(proto.ExternalSubCodeText) ? null : proto.ExternalSubCodeText, + ExternalSubCodeId = string.IsNullOrEmpty(proto.ExternalSubCodeId) ? null : proto.ExternalSubCodeId, + Priority = proto.Priority, + Data = string.IsNullOrEmpty(proto.Data) ? null : proto.Data, + Consumed = proto.Consumed, + Uuid = string.IsNullOrEmpty(proto.Uuid) ? null : proto.Uuid, + }; + + if (proto.EvtModifiers != null) + flex.EvtModifiers = new FlexEvtModifiers + { + UsedCard = proto.EvtModifiers.UsedCardCase == EvtModifiers.UsedCardOneofCase.UsedCard ? proto.EvtModifiers.UsedCard : null, + UsedPin = proto.EvtModifiers.UsedPinCase == EvtModifiers.UsedPinOneofCase.UsedPin ? proto.EvtModifiers.UsedPin : null, + }; + + if (proto.EvtDevRef != null) + flex.EvtDevRef = ToFlexEvtDevRef(proto.EvtDevRef); + + if (proto.EvtControllerRef != null) + flex.EvtControllerRef = ToFlexEvtDevRef(proto.EvtControllerRef); + + if (proto.EvtCredRef != null) + flex.EvtCredRef = ToFlexEvtCredRef(proto.EvtCredRef); + + if (proto.EvtSchedRef != null) + flex.EvtSchedRef = ToFlexEvtSchedRef(proto.EvtSchedRef); + + return flex; + } + + private static FlexEvtDevRef ToFlexEvtDevRef(EvtDevRef r) + { + return new FlexEvtDevRef + { + Unid = r.Unid, + LogicalAddress = r.LogicalAddress, + Name = r.Name, + Address = r.Address, + Tag = r.Tag, + Uuid = r.Uuid, + ExternalId = r.ExternalId, + DevPlatform = (int)r.DevPlatform, + DevType = (int)r.DevType, + DevSubType = (int)r.DevSubType, + DevMod = (int)r.DevMod, + DevUse = (int)r.DevUse, + ExternalDevTypeText = string.IsNullOrEmpty(r.ExternalDevTypeText) ? null : r.ExternalDevTypeText, + ExternalDevTypeId = string.IsNullOrEmpty(r.ExternalDevTypeId) ? null : r.ExternalDevTypeId, + ExternalDevModText = string.IsNullOrEmpty(r.ExternalDevModText) ? null : r.ExternalDevModText, + ExternalDevModId = string.IsNullOrEmpty(r.ExternalDevModId) ? null : r.ExternalDevModId, + }; + } + + private static FlexEvtCredRef ToFlexEvtCredRef(EvtCredRef r) + { + return new FlexEvtCredRef + { + Unid = r.Unid, + CredTemplateRef = r.CredTemplateRef != null ? new FlexEvtCredTemplateRef + { + Unid = r.CredTemplateRef.Unid, + Name = r.CredTemplateRef.Name, + Tag = r.CredTemplateRef.Tag, + Uuid = r.CredTemplateRef.Uuid, + } : null, + Name = r.Name, + CredNum = BigIntegerDataToString(r.CredNum), + FacilityCode = r.FacilityCode, + Tag = r.Tag, + Uuid = r.Uuid, + }; + } + + private static FlexEvtSchedRef ToFlexEvtSchedRef(EvtSchedRef r) + { + return new FlexEvtSchedRef + { + Unid = r.Unid, + Name = r.Name, + Tag = r.Tag, + Uuid = r.Uuid, + Invert = r.Invert, + }; + } + + // --- Dev --- + + public static FlexDev ToFlex(Dev proto, Func lookupRef = null) + { + var flex = new FlexDev + { + Version = proto.Version, + Tag = proto.Tag, + Uuid = proto.Uuid, + Name = proto.Name, + ExternalId = proto.ExternalId, + Unid = proto.Unid, + Address = proto.Address, + LogicalAddress = proto.LogicalAddressCase == Dev.LogicalAddressOneofCase.LogicalAddress ? proto.LogicalAddress : null, + MacAddress = string.IsNullOrEmpty(proto.MacAddress) ? null : proto.MacAddress, + Enabled = proto.Enabled, + Port = proto.Port, + Speed = proto.Speed, + DevType = (int)proto.DevType, + DevSubType = (int)proto.DevSubType, + DevMod = (int)proto.DevMod, + DevPlatform = (int)proto.DevPlatform, + DevUse = (int)proto.DevUse, + TimeZone = string.IsNullOrEmpty(proto.TimeZone) ? null : proto.TimeZone, + IgnoreDaylightSavings = proto.IgnoreDaylightSavings, + }; + + if (proto.PhysicalParentUnid != 0) + flex.PhysicalParent = lookupRef?.Invoke(proto.PhysicalParentUnid, "dev") ?? MakeObjRef("dev", proto.PhysicalParentUnid); + if (proto.LogicalParentUnid != 0) + flex.LogicalParent = lookupRef?.Invoke(proto.LogicalParentUnid, "dev") ?? MakeObjRef("dev", proto.LogicalParentUnid); + + if (proto.LogicalChildrenUnid.Count > 0) + flex.LogicalChildren = proto.LogicalChildrenUnid.Select(u => lookupRef?.Invoke(u, "dev") ?? MakeObjRef("dev", u)).ToList(); + if (proto.PhysicalChildrenUnid.Count > 0) + flex.PhysicalChildren = proto.PhysicalChildrenUnid.Select(u => lookupRef?.Invoke(u, "dev") ?? MakeObjRef("dev", u)).ToList(); + + if (proto.DevModConfig != null) + flex.DevModConfig = new FlexDevModConfig + { + Unid = proto.DevModConfig.Unid, + Version = proto.DevModConfig.Version, + Type = (int)proto.DevModConfig.Type, + }; + + // Type-specific extension + switch (proto.ExtensionCase) + { + case Dev.ExtensionOneofCase.ExtDoor: + flex.DoorConfig = ToFlexDoorConfig(proto.ExtDoor, lookupRef); + break; + case Dev.ExtensionOneofCase.ExtCredReader: + flex.CredReaderConfig = ToFlexCredReaderConfig(proto.ExtCredReader, lookupRef); + break; + case Dev.ExtensionOneofCase.ExtController: + flex.ControllerConfig = ToFlexControllerConfig(proto.ExtController, lookupRef); + break; + case Dev.ExtensionOneofCase.ExtActuator: + flex.ActuatorConfig = ToFlexActuatorConfig(proto.ExtActuator, lookupRef); + break; + case Dev.ExtensionOneofCase.ExtSensor: + flex.SensorConfig = ToFlexSensorConfig(proto.ExtSensor, lookupRef); + break; + } + + return flex; + } + + public static Dev ToProto(FlexDev flex) + { + var proto = new Dev + { + Version = flex.Version ?? 0, + Tag = flex.Tag ?? "", + Uuid = flex.Uuid ?? "", + Name = flex.Name ?? "", + ExternalId = flex.ExternalId ?? "", + Unid = flex.Unid ?? 0, + Address = flex.Address ?? "", + Enabled = flex.Enabled ?? true, + Port = flex.Port ?? 0, + Speed = flex.Speed ?? 0, + DevType = (DevType)(flex.DevType ?? 0), + DevSubType = (DevSubType)(flex.DevSubType ?? 0), + DevMod = (DevMod)(flex.DevMod ?? 0), + DevPlatform = (DevPlatform)(flex.DevPlatform ?? 0), + DevUse = (DevUse)(flex.DevUse ?? 0), + TimeZone = flex.TimeZone ?? "", + IgnoreDaylightSavings = flex.IgnoreDaylightSavings ?? false, + }; + + if (flex.LogicalAddress.HasValue) proto.LogicalAddress = flex.LogicalAddress.Value; + if (flex.MacAddress != null) proto.MacAddress = flex.MacAddress; + if (flex.PhysicalParent?.Unid != null) proto.PhysicalParentUnid = flex.PhysicalParent.Unid.Value; + if (flex.LogicalParent?.Unid != null) proto.LogicalParentUnid = flex.LogicalParent.Unid.Value; + + if (flex.LogicalChildren != null) + foreach (var c in flex.LogicalChildren.Where(c => c.Unid.HasValue)) + proto.LogicalChildrenUnid.Add(c.Unid.Value); + if (flex.PhysicalChildren != null) + foreach (var c in flex.PhysicalChildren.Where(c => c.Unid.HasValue)) + proto.PhysicalChildrenUnid.Add(c.Unid.Value); + + return proto; + } + + private static FlexDoorConfig ToFlexDoorConfig(Z9.Spcore.Proto.Door door, Func lookupRef) + { + var config = door.DoorConfig; + if (config == null) return null; + var flex = new FlexDoorConfig + { + Version = config.Version, + Username = config.Username, + Password = config.Password, + DevInitiatesConnection = config.DevInitiatesConnectionCase == DoorConfig.DevInitiatesConnectionOneofCase.DevInitiatesConnection ? config.DevInitiatesConnection : null, + DisableEncryption = config.DisableEncryptionCase == DoorConfig.DisableEncryptionOneofCase.DisableEncryption ? config.DisableEncryption : null, + ActivateStrikeOnRex = config.ActivateStrikeOnRexCase == DoorConfig.ActivateStrikeOnRexOneofCase.ActivateStrikeOnRex ? config.ActivateStrikeOnRex : null, + StrikeTime = config.StrikeTimeCase == DoorConfig.StrikeTimeOneofCase.StrikeTime ? config.StrikeTime : null, + ExtendedStrikeTime = config.ExtendedStrikeTimeCase == DoorConfig.ExtendedStrikeTimeOneofCase.ExtendedStrikeTime ? config.ExtendedStrikeTime : null, + HeldTime = config.HeldTimeCase == DoorConfig.HeldTimeOneofCase.HeldTime ? config.HeldTime : null, + ExtendedHeldTime = config.ExtendedHeldTimeCase == DoorConfig.ExtendedHeldTimeOneofCase.ExtendedHeldTime ? config.ExtendedHeldTime : null, + }; + + if (config.DefaultDoorMode != null) + flex.DefaultDoorMode = ToFlexDoorMode(config.DefaultDoorMode); + + if (config.EncryptionKeyRefUnid != 0) + flex.EncryptionKeyRef = lookupRef?.Invoke(config.EncryptionKeyRefUnid, "encryptionKey") ?? MakeObjRef("encryptionKey", config.EncryptionKeyRefUnid); + if (config.EncryptionKeyRefNextUnid != 0) + flex.EncryptionKeyRefNext = lookupRef?.Invoke(config.EncryptionKeyRefNextUnid, "encryptionKey") ?? MakeObjRef("encryptionKey", config.EncryptionKeyRefNextUnid); + + return flex; + } + + private static FlexCredReaderConfig ToFlexCredReaderConfig(CredReader cr, Func lookupRef) + { + var config = cr.CredReaderConfig; + if (config == null) return null; + return new FlexCredReaderConfig + { + Version = config.Version, + Username = config.Username, + Password = config.Password, + DevInitiatesConnection = config.DevInitiatesConnectionCase == CredReaderConfig.DevInitiatesConnectionOneofCase.DevInitiatesConnection ? config.DevInitiatesConnection : null, + DisableEncryption = config.DisableEncryptionCase == CredReaderConfig.DisableEncryptionOneofCase.DisableEncryption ? config.DisableEncryption : null, + CommType = (int)config.CommType, + TamperType = (int)config.TamperType, + LedType = (int)config.LedType, + SerialPortAddress = string.IsNullOrEmpty(config.SerialPortAddress) ? null : config.SerialPortAddress, + }; + } + + private static FlexControllerConfig ToFlexControllerConfig(Controller ctrl, Func lookupRef) + { + var config = ctrl.ControllerConfig; + if (config == null) return null; + return new FlexControllerConfig + { + Version = config.Version, + Username = config.Username, + Password = config.Password, + DevInitiatesConnection = config.DevInitiatesConnectionCase == ControllerConfig.DevInitiatesConnectionOneofCase.DevInitiatesConnection ? config.DevInitiatesConnection : null, + DisableEncryption = config.DisableEncryptionCase == ControllerConfig.DisableEncryptionOneofCase.DisableEncryption ? config.DisableEncryption : null, + }; + } + + private static FlexActuatorConfig ToFlexActuatorConfig(Actuator act, Func lookupRef) + { + var config = act.ActuatorConfig; + if (config == null) return null; + return new FlexActuatorConfig + { + Version = config.Version, + Username = config.Username, + Password = config.Password, + DevInitiatesConnection = config.DevInitiatesConnectionCase == ActuatorConfig.DevInitiatesConnectionOneofCase.DevInitiatesConnection ? config.DevInitiatesConnection : null, + DisableEncryption = config.DisableEncryptionCase == ActuatorConfig.DisableEncryptionOneofCase.DisableEncryption ? config.DisableEncryption : null, + Invert = config.InvertCase == ActuatorConfig.InvertOneofCase.Invert ? config.Invert : null, + }; + } + + private static FlexSensorConfig ToFlexSensorConfig(Sensor sensor, Func lookupRef) + { + var config = sensor.SensorConfig; + if (config == null) return null; + return new FlexSensorConfig + { + Version = config.Version, + Username = config.Username, + Password = config.Password, + DevInitiatesConnection = config.DevInitiatesConnectionCase == SensorConfig.DevInitiatesConnectionOneofCase.DevInitiatesConnection ? config.DevInitiatesConnection : null, + DisableEncryption = config.DisableEncryptionCase == SensorConfig.DisableEncryptionOneofCase.DisableEncryption ? config.DisableEncryption : null, + Invert = config.InvertCase == SensorConfig.InvertOneofCase.Invert ? config.Invert : null, + }; + } + + public static FlexDoorMode ToFlexDoorMode(DoorMode dm) + { + return new FlexDoorMode + { + StaticState = (int)dm.StaticState, + AllowUniquePin = dm.AllowUniquePinCase == DoorMode.AllowUniquePinOneofCase.AllowUniquePin ? dm.AllowUniquePin : null, + AllowCard = dm.AllowCardCase == DoorMode.AllowCardOneofCase.AllowCard ? dm.AllowCard : null, + RequireConfirmingPinWithCard = dm.RequireConfirmingPinWithCardCase == DoorMode.RequireConfirmingPinWithCardOneofCase.RequireConfirmingPinWithCard ? dm.RequireConfirmingPinWithCard : null, + }; + } + + // --- Priv (DoorAccessPriv) --- + + public static FlexPriv ToFlex(Priv proto, Func lookupRef = null) + { + var flex = new FlexPriv + { + Version = proto.Version, + Tag = proto.Tag, + Uuid = proto.Uuid, + Name = proto.Name, + ExternalId = proto.ExternalId, + Unid = proto.Unid, + Enabled = proto.Enabled, + PrivType = (int)proto.PrivType, + }; + + if (proto.ExtensionCase == Priv.ExtensionOneofCase.ExtDoorAccessPriv && proto.ExtDoorAccessPriv.Elements.Count > 0) + { + flex.Elements = proto.ExtDoorAccessPriv.Elements.Select(e => new FlexDoorAccessPrivElement + { + Unid = e.Unid, + Door = e.DoorUnidCase == DoorAccessPrivElement.DoorUnidOneofCase.DoorUnid + ? (lookupRef?.Invoke(e.DoorUnid, "door") ?? MakeObjRef("door", e.DoorUnid)) + : null, + SchedRestriction = e.SchedRestriction != null ? ToFlexSchedRestriction(e.SchedRestriction, lookupRef) : null, + }).ToList(); + } + + return flex; + } + + public static Priv ToProto(FlexPriv flex) + { + var proto = new Priv + { + Version = flex.Version ?? 0, + Tag = flex.Tag ?? "", + Uuid = flex.Uuid ?? "", + Name = flex.Name ?? "", + ExternalId = flex.ExternalId ?? "", + Unid = flex.Unid ?? 0, + Enabled = flex.Enabled ?? true, + PrivType = (PrivType)(flex.PrivType ?? 0), + }; + + if (flex.Elements != null) + { + var dap = new DoorAccessPriv(); + foreach (var e in flex.Elements) + { + var elem = new DoorAccessPrivElement { Unid = e.Unid ?? 0 }; + if (e.Door?.Unid != null) elem.DoorUnid = e.Door.Unid.Value; + if (e.SchedRestriction != null) + elem.SchedRestriction = ToProtoSchedRestriction(e.SchedRestriction); + dap.Elements.Add(elem); + } + proto.ExtDoorAccessPriv = dap; + } + + return proto; + } + + // --- Sched --- + + public static FlexSched ToFlex(Sched proto, Func lookupRef = null) + { + var flex = new FlexSched + { + Version = proto.Version, + Tag = proto.Tag, + Uuid = proto.Uuid, + Name = proto.Name, + Unid = proto.Unid, + ExternalId = proto.ExternalId, + }; + + if (proto.Elements.Count > 0) + { + flex.Elements = proto.Elements.Select(e => + { + var se = new FlexSchedElement + { + Unid = e.Unid, + SchedDays = e.SchedDays.Select(d => (int)d).ToList(), + Holidays = e.Holidays, + Start = SqlTimeToString(e.Start), + Stop = SqlTimeToString(e.Stop), + PlusDays = e.PlusDays, + }; + if (e.HolTypesUnid.Count > 0) + se.HolTypes = e.HolTypesUnid.Select(u => lookupRef?.Invoke(u, "holType") ?? MakeObjRef("holType", u)).ToList(); + return se; + }).ToList(); + } + + return flex; + } + + public static Sched ToProto(FlexSched flex) + { + var proto = new Sched + { + Version = flex.Version ?? 0, + Tag = flex.Tag ?? "", + Uuid = flex.Uuid ?? "", + Name = flex.Name ?? "", + Unid = flex.Unid ?? 0, + ExternalId = flex.ExternalId ?? "", + }; + + if (flex.Elements != null) + { + foreach (var e in flex.Elements) + { + var se = new SchedElement + { + Unid = e.Unid ?? 0, + Holidays = e.Holidays ?? false, + Start = StringToSqlTime(e.Start) ?? new SqlTimeData(), + Stop = StringToSqlTime(e.Stop) ?? new SqlTimeData(), + PlusDays = e.PlusDays ?? 0, + }; + if (e.SchedDays != null) + foreach (var d in e.SchedDays) + se.SchedDays.Add((SchedDay)d); + if (e.HolTypes != null) + foreach (var ht in e.HolTypes.Where(h => h.Unid.HasValue)) + se.HolTypesUnid.Add(ht.Unid.Value); + proto.Elements.Add(se); + } + } + + return proto; + } + + // --- Hol --- + + public static FlexHol ToFlex(Hol proto, Func lookupRef = null) + { + var flex = new FlexHol + { + Version = proto.Version, + Tag = proto.Tag, + Uuid = proto.Uuid, + Name = proto.Name, + Unid = proto.Unid, + AllHolTypes = proto.AllHolTypes, + Date = SqlDateToIso(proto.Date), + NumDays = proto.NumDays, + Repeat = proto.Repeat, + NumYearsRepeat = proto.NumYearsRepeat, + PreserveSchedDay = proto.PreserveSchedDay, + }; + + if (proto.HolCalUnid != 0) + flex.HolCal = lookupRef?.Invoke(proto.HolCalUnid, "holCal") ?? MakeObjRef("holCal", proto.HolCalUnid); + + if (proto.HolTypesUnid.Count > 0) + flex.HolTypes = proto.HolTypesUnid.Select(u => lookupRef?.Invoke(u, "holType") ?? MakeObjRef("holType", u)).ToList(); + + return flex; + } + + public static Hol ToProto(FlexHol flex) + { + var proto = new Hol + { + Version = flex.Version ?? 0, + Tag = flex.Tag ?? "", + Uuid = flex.Uuid ?? "", + Name = flex.Name ?? "", + Unid = flex.Unid ?? 0, + AllHolTypes = flex.AllHolTypes ?? false, + NumDays = flex.NumDays ?? 1, + Repeat = flex.Repeat ?? false, + NumYearsRepeat = flex.NumYearsRepeat ?? 0, + PreserveSchedDay = flex.PreserveSchedDay ?? false, + }; + + if (flex.Date != null) proto.Date = IsoToSqlDate(flex.Date) ?? new SqlDateData(); + if (flex.HolCal?.Unid != null) proto.HolCalUnid = flex.HolCal.Unid.Value; + if (flex.HolTypes != null) + foreach (var ht in flex.HolTypes.Where(h => h.Unid.HasValue)) + proto.HolTypesUnid.Add(ht.Unid.Value); + + return proto; + } + + // --- HolCal --- + + public static FlexHolCal ToFlex(HolCal proto) + { + return new FlexHolCal + { + Version = proto.Version, + Tag = proto.Tag, + Uuid = proto.Uuid, + Name = proto.Name, + Unid = proto.Unid, + }; + } + + public static HolCal ToProto(FlexHolCal flex) + { + return new HolCal + { + Version = flex.Version ?? 0, + Tag = flex.Tag ?? "", + Uuid = flex.Uuid ?? "", + Name = flex.Name ?? "", + Unid = flex.Unid ?? 0, + }; + } + + // --- HolType --- + + public static FlexHolType ToFlex(HolType proto) + { + return new FlexHolType + { + Version = proto.Version, + Tag = proto.Tag, + Uuid = proto.Uuid, + Name = proto.Name, + Unid = proto.Unid, + ExternalId = proto.ExternalId, + }; + } + + public static HolType ToProto(FlexHolType flex) + { + return new HolType + { + Version = flex.Version ?? 0, + Tag = flex.Tag ?? "", + Uuid = flex.Uuid ?? "", + Name = flex.Name ?? "", + Unid = flex.Unid ?? 0, + ExternalId = flex.ExternalId ?? "", + }; + } + + // --- DataFormat --- + + public static FlexDataFormat ToFlex(DataFormat proto) + { + var flex = new FlexDataFormat + { + Version = proto.Version, + Tag = proto.Tag, + Uuid = proto.Uuid, + Name = proto.Name, + Unid = proto.Unid, + DataFormatType = (int)proto.DataFormatType, + }; + + if (proto.ExtensionCase == DataFormat.ExtensionOneofCase.ExtBinaryFormat) + { + var bf = proto.ExtBinaryFormat; + flex.MinBits = bf.MinBits; + flex.MaxBits = bf.MaxBits; + flex.SupportReverseRead = bf.SupportReverseRead; + if (bf.Elements.Count > 0) + flex.Elements = bf.Elements.Select(ToFlexBinaryElement).ToList(); + } + + return flex; + } + + public static DataFormat ToProto(FlexDataFormat flex) + { + var proto = new DataFormat + { + Version = flex.Version ?? 0, + Tag = flex.Tag ?? "", + Uuid = flex.Uuid ?? "", + Name = flex.Name ?? "", + Unid = flex.Unid ?? 0, + DataFormatType = (DataFormatType)(flex.DataFormatType ?? 0), + }; + + if (flex.DataFormatType == 1) // BINARY + { + var bf = new BinaryFormat + { + MinBits = flex.MinBits ?? 0, + MaxBits = flex.MaxBits ?? 0, + SupportReverseRead = flex.SupportReverseRead ?? false, + }; + if (flex.Elements != null) + foreach (var e in flex.Elements) + bf.Elements.Add(ToProtoBinaryElement(e)); + proto.ExtBinaryFormat = bf; + } + + return proto; + } + + private static FlexBinaryElement ToFlexBinaryElement(BinaryElement e) + { + var flex = new FlexBinaryElement + { + Unid = e.Unid, + Num = e.Num, + Type = (int)e.Type, + Start = e.Start, + Len = e.Len, + }; + + switch (e.ExtensionCase) + { + case BinaryElement.ExtensionOneofCase.ExtFieldBinaryElement: + flex.Field = (int)e.ExtFieldBinaryElement.Field; + flex.StaticValue = BigIntegerDataToString(e.ExtFieldBinaryElement.StaticValue); + break; + case BinaryElement.ExtensionOneofCase.ExtParityBinaryElement: + flex.Odd = e.ExtParityBinaryElement.Odd; + flex.SrcStart = e.ExtParityBinaryElement.SrcStart; + flex.SrcLen = e.ExtParityBinaryElement.SrcLen; + flex.Mask = string.IsNullOrEmpty(e.ExtParityBinaryElement.Mask) ? null : e.ExtParityBinaryElement.Mask; + break; + case BinaryElement.ExtensionOneofCase.ExtStaticBinaryElement: + flex.Value = BigIntegerDataToString(e.ExtStaticBinaryElement.Value); + break; + } + + return flex; + } + + private static BinaryElement ToProtoBinaryElement(FlexBinaryElement flex) + { + var e = new BinaryElement + { + Unid = flex.Unid ?? 0, + Num = flex.Num ?? 0, + Type = (BinaryElementType)(flex.Type ?? 0), + Start = flex.Start ?? 0, + Len = flex.Len ?? 0, + }; + + switch (e.Type) + { + case BinaryElementType.Field: + e.ExtFieldBinaryElement = new FieldBinaryElement + { + Field = (DataFormatField)(flex.Field ?? 0), + }; + if (flex.StaticValue != null) + e.ExtFieldBinaryElement.StaticValue = StringToBigIntegerData(flex.StaticValue); + break; + case BinaryElementType.Parity: + e.ExtParityBinaryElement = new ParityBinaryElement + { + Odd = flex.Odd ?? false, + SrcStart = flex.SrcStart ?? 0, + SrcLen = flex.SrcLen ?? 0, + Mask = flex.Mask ?? "", + }; + break; + case BinaryElementType.Static: + e.ExtStaticBinaryElement = new StaticBinaryElement(); + if (flex.Value != null) + e.ExtStaticBinaryElement.Value = StringToBigIntegerData(flex.Value); + break; + } + + return e; + } + + // --- DataLayout --- + + public static FlexDataLayout ToFlex(DataLayout proto, Func lookupRef = null) + { + var flex = new FlexDataLayout + { + Version = proto.Version, + Tag = proto.Tag, + Uuid = proto.Uuid, + Name = proto.Name, + Unid = proto.Unid, + LayoutType = (int)proto.LayoutType, + Priority = proto.Priority, + Enabled = proto.EnabledCase == DataLayout.EnabledOneofCase.Enabled ? proto.Enabled : null, + }; + + if (proto.ExtensionCase == DataLayout.ExtensionOneofCase.ExtBasicDataLayout) + { + var bdl = proto.ExtBasicDataLayout; + if (bdl.DataFormatUnid != 0) + flex.DataFormat = lookupRef?.Invoke(bdl.DataFormatUnid, "dataFormat") ?? MakeObjRef("dataFormat", bdl.DataFormatUnid); + } + + return flex; + } + + public static DataLayout ToProto(FlexDataLayout flex) + { + var proto = new DataLayout + { + Version = flex.Version ?? 0, + Tag = flex.Tag ?? "", + Uuid = flex.Uuid ?? "", + Name = flex.Name ?? "", + Unid = flex.Unid ?? 0, + LayoutType = (DataLayoutType)(flex.LayoutType ?? 0), + Priority = flex.Priority ?? 0, + }; + if (flex.Enabled.HasValue) proto.Enabled = flex.Enabled.Value; + + if (flex.LayoutType == 0) // BASIC + { + var bdl = new BasicDataLayout(); + if (flex.DataFormat?.Unid != null) + bdl.DataFormatUnid = flex.DataFormat.Unid.Value; + proto.ExtBasicDataLayout = bdl; + } + + return proto; + } + + // --- DevStateRecord --- + + public static FlexDevStateRecord ToFlex(DevStateRecord proto) + { + var flex = new FlexDevStateRecord + { + Unid = proto.UnidCase == DevStateRecord.UnidOneofCase.Unid ? proto.Unid : null, + Dev = MakeObjRef("dev", proto.DevUnid), + }; + + if (proto.DevState != null) + { + flex.DevState = new FlexDevState(); + foreach (var entry in proto.DevState.DevAspectStates) + { + var flexEntry = new FlexDevAspectStateEntry + { + Key = entry.KeyCase == DevState.Types.DevAspect_DevAspectState_Entry.KeyOneofCase.Key ? (int)entry.Key : null, + }; + if (entry.ValueCase == DevState.Types.DevAspect_DevAspectState_Entry.ValueOneofCase.Value && entry.Value != null) + { + flexEntry.Value = ToFlexDevAspectState(entry.Value); + } + flex.DevState.DevAspectStates.Add(flexEntry); + } + } + + return flex; + } + + private static FlexDevAspectState ToFlexDevAspectState(DevAspectState s) + { + var flex = new FlexDevAspectState + { + Unid = s.UnidCase == DevAspectState.UnidOneofCase.Unid ? s.Unid : null, + DevAspect = (int)s.DevAspect, + HwTime = DateTimeDataToIso(s.HwTime), + DbTime = DateTimeDataToIso(s.DbTime), + }; + + if (s.CommStateCase == DevAspectState.CommStateOneofCase.CommState) + flex.CommState = (int)s.CommState; + if (s.CommStateStaleCase == DevAspectState.CommStateStaleOneofCase.CommStateStale) + flex.CommStateStale = s.CommStateStale; + if (s.ActivityStateCase == DevAspectState.ActivityStateOneofCase.ActivityState) + flex.ActivityState = (int)s.ActivityState; + if (s.ActivityStateStaleCase == DevAspectState.ActivityStateStaleOneofCase.ActivityStateStale) + flex.ActivityStateStale = s.ActivityStateStale; + if (s.DoorMode != null) + flex.DoorMode = ToFlexDoorMode(s.DoorMode); + if (s.DoorModeStaleCase == DevAspectState.DoorModeStaleOneofCase.DoorModeStale) + flex.DoorModeStale = s.DoorModeStale; + if (s.ExternalStateCase == DevAspectState.ExternalStateOneofCase.ExternalState) + flex.ExternalState = s.ExternalState; + if (s.ExternalStateStaleCase == DevAspectState.ExternalStateStaleOneofCase.ExternalStateStale) + flex.ExternalStateStale = s.ExternalStateStale; + + return flex; + } + + // --- EncryptionKey --- + + public static FlexEncryptionKey ToFlex(EncryptionKey proto) + { + return new FlexEncryptionKey + { + Unid = proto.Unid, + Tag = proto.Tag, + Uuid = proto.Uuid, + Version = proto.Version, + Algorithm = proto.Algorithm, + Size = proto.Size, + KeyIdentifier = proto.KeyIdentifier, + Bytes = proto.BytesCase == EncryptionKey.BytesOneofCase.Bytes && !proto.Bytes.IsEmpty + ? Convert.ToBase64String(proto.Bytes.ToByteArray()) + : null, + }; + } + + public static EncryptionKey ToProto(FlexEncryptionKey flex) + { + var proto = new EncryptionKey + { + Unid = flex.Unid ?? 0, + Tag = flex.Tag ?? "", + Uuid = flex.Uuid ?? "", + Version = flex.Version ?? 0, + Algorithm = flex.Algorithm ?? "", + Size = flex.Size ?? 0, + KeyIdentifier = flex.KeyIdentifier ?? "", + }; + + if (flex.Bytes != null) + proto.Bytes = ByteString.CopyFrom(Convert.FromBase64String(flex.Bytes)); + + return proto; + } +} diff --git a/src/Aporta.Core/Services/FlexSessionService.cs b/src/Aporta.Core/Services/FlexSessionService.cs new file mode 100644 index 0000000..a580221 --- /dev/null +++ b/src/Aporta.Core/Services/FlexSessionService.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Concurrent; +using Aporta.Shared.Models.Flex; + +namespace Aporta.Core.Services; + +public class FlexSessionService +{ + private readonly ConcurrentDictionary _sessions = new(); + private const int SessionTimeoutMinutes = 30; + + public FlexAuthenticateResult Authenticate(string username, string password) + { + // Default credentials — configurable via global settings in the future + if (username == "admin" && password == "pass") + { + var token = Guid.NewGuid().ToString("N"); + _sessions[token] = new FlexSession + { + Username = username, + CreatedAt = DateTime.UtcNow, + }; + + return new FlexAuthenticateResult + { + Authenticated = true, + SessionToken = token, + SoftwareVersion = "1.0.0", + TimeZone = TimeZoneInfo.Local.Id, + ApiVersion = "1.1", + }; + } + + return new FlexAuthenticateResult + { + Authenticated = false, + }; + } + + public bool ValidateSession(string token) + { + if (string.IsNullOrEmpty(token)) + return false; + + if (!_sessions.TryGetValue(token, out var session)) + return false; + + if (DateTime.UtcNow - session.CreatedAt > TimeSpan.FromMinutes(SessionTimeoutMinutes)) + { + _sessions.TryRemove(token, out _); + return false; + } + + // Refresh the session on each use + session.CreatedAt = DateTime.UtcNow; + return true; + } + + public void Terminate(string token) + { + if (!string.IsNullOrEmpty(token)) + _sessions.TryRemove(token, out _); + } + + private class FlexSession + { + public string Username { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/src/Aporta.Core/Services/InputService.cs b/src/Aporta.Core/Services/InputService.cs index 67b1f9f..1305b21 100644 --- a/src/Aporta.Core/Services/InputService.cs +++ b/src/Aporta.Core/Services/InputService.cs @@ -1,32 +1,27 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Aporta.Core.DataAccess; using Aporta.Core.DataAccess.Repositories; using Aporta.Core.Hubs; using Aporta.Extensions.Hardware; using Aporta.Shared.Messaging; -using Aporta.Shared.Models; using Microsoft.AspNetCore.SignalR; +using Z9.Spcore.Proto; namespace Aporta.Core.Services; public class InputService { - private readonly InputRepository _inputRepository; - private readonly DoorRepository _doorRepository; - private readonly EndpointRepository _endpointRepository; + private readonly Z9DevRepository _z9DevRepository; private readonly IHubContext _hubContext; private readonly ExtensionService _extensionService; + private readonly DevStateService _devStateService; public InputService(IDataAccess dataAccess, IHubContext hubContext, - ExtensionService extensionService) + ExtensionService extensionService, DevStateService devStateService) { _hubContext = hubContext; _extensionService = extensionService; - _doorRepository = new DoorRepository(dataAccess); - _inputRepository = new InputRepository(dataAccess); - _endpointRepository = new EndpointRepository(dataAccess); + _devStateService = devStateService; + _z9DevRepository = new Z9DevRepository(dataAccess); _extensionService.StateChanged += ExtensionServiceOnStateChanged; } @@ -35,57 +30,20 @@ private async void ExtensionServiceOnStateChanged(object sender, StateChangedEve { try { - var input = await _inputRepository.GetForDriverId(eventArgs.Endpoint.Id); + var dev = await _z9DevRepository.GetForDriverId(eventArgs.Endpoint.Id); - await _hubContext.Clients.All.SendAsync(Methods.InputStateChanged, input.Id, - eventArgs.State); + if (dev != null && dev.DevType == DevType.Sensor) + { + await _hubContext.Clients.All.SendAsync(Methods.InputStateChanged, dev.Unid, + eventArgs.State); + + _devStateService.UpdateAspect(dev.Unid, DevAspect.Primary, + s => s.ActivityState = eventArgs.State ? ActivityState.Active : ActivityState.Inactive); + } } catch { // ignored } } - - public async Task> GetAll() - { - return await _inputRepository.GetAll(); - } - - public async Task Get(int inputId) - { - return await _inputRepository.Get(inputId); - } - - public async Task Insert(Input input) - { - await _inputRepository.Insert(input); - - await _hubContext.Clients.All.SendAsync(Methods.InputInserted, input.Id); - } - - public async Task Delete(int id) - { - await _inputRepository.Delete(id); - - await _hubContext.Clients.All.SendAsync(Methods.InputDeleted, id); - } - - public async Task> AvailableMonitorPoints() - { - var endpoints = await _endpointRepository.GetAll(); - var inputs = await _inputRepository.GetAll(); - var doors = (await _doorRepository.GetAll()).ToArray(); - return endpoints.Where(endpoint => - endpoint.Type == EndpointType.Input && - !inputs.Select(input => input.EndpointId).Contains(endpoint.Id) && - !doors.Select(door => door.DoorContactEndpointId).Contains(endpoint.Id) && - !doors.Select(door => door.RequestToExitEndpointId).Contains(endpoint.Id)); - } - - public async Task GetState(int inputId) - { - var input = await _inputRepository.Get(inputId); - var endpoint = await _endpointRepository.Get(input.EndpointId); - return await _extensionService.GetMonitorPoint(endpoint.ExtensionId, endpoint.DriverEndpointId).GetState(); - } -} \ No newline at end of file +} diff --git a/src/Aporta.Core/Services/OutputService.cs b/src/Aporta.Core/Services/OutputService.cs index f30eeac..438ff18 100644 --- a/src/Aporta.Core/Services/OutputService.cs +++ b/src/Aporta.Core/Services/OutputService.cs @@ -1,32 +1,27 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Aporta.Core.DataAccess; using Aporta.Core.DataAccess.Repositories; using Aporta.Core.Hubs; using Aporta.Extensions.Hardware; using Aporta.Shared.Messaging; -using Aporta.Shared.Models; using Microsoft.AspNetCore.SignalR; +using Z9.Spcore.Proto; namespace Aporta.Core.Services; public class OutputService { - private readonly DoorRepository _doorRepository; - private readonly OutputRepository _outputRepository; - private readonly EndpointRepository _endpointRepository; + private readonly Z9DevRepository _z9DevRepository; private readonly IHubContext _hubContext; private readonly ExtensionService _extensionService; + private readonly DevStateService _devStateService; public OutputService(IDataAccess dataAccess, IHubContext hubContext, - ExtensionService extensionService) + ExtensionService extensionService, DevStateService devStateService) { _hubContext = hubContext; _extensionService = extensionService; - _doorRepository = new DoorRepository(dataAccess); - _outputRepository = new OutputRepository(dataAccess); - _endpointRepository = new EndpointRepository(dataAccess); + _devStateService = devStateService; + _z9DevRepository = new Z9DevRepository(dataAccess); _extensionService.StateChanged += ExtensionServiceOnOutputStateChanged; } @@ -35,64 +30,19 @@ private async void ExtensionServiceOnOutputStateChanged(object sender, StateChan { try { - var output = await _outputRepository.GetForDriverId(eventArgs.Endpoint.Id); + var dev = await _z9DevRepository.GetForDriverId(eventArgs.Endpoint.Id); - await _hubContext.Clients.All.SendAsync(Methods.OutputStateChanged, output.Id, eventArgs.State); + if (dev != null && dev.DevType == DevType.Actuator) + { + await _hubContext.Clients.All.SendAsync(Methods.OutputStateChanged, dev.Unid, eventArgs.State); + + _devStateService.UpdateAspect(dev.Unid, DevAspect.Primary, + s => s.ActivityState = eventArgs.State ? ActivityState.Active : ActivityState.Inactive); + } } catch { // ignored } } - - public async Task> GetAll() - { - return await _outputRepository.GetAll(); - } - - public async Task Get(int outputId) - { - return await _outputRepository.Get(outputId); - } - - public async Task Insert(Output output) - { - await _outputRepository.Insert(output); - - await _hubContext.Clients.All.SendAsync(Methods.OutputInserted, output.Id); - } - - public async Task Delete(int id) - { - await _outputRepository.Delete(id); - - await _hubContext.Clients.All.SendAsync(Methods.OutputDeleted, id); - } - - public async Task> AvailableControlPoints() - { - var endpoints = await _endpointRepository.GetAll(); - var doors = await _doorRepository.GetAll(); - var outputs = await _outputRepository.GetAll(); - return endpoints.Where(endpoint => - endpoint.Type == EndpointType.Output && - !outputs.Select(output => output.EndpointId).Contains(endpoint.Id) && - !doors.Select(door => door.DoorStrikeEndpointId).Contains(endpoint.Id)); - } - - public async Task SetState(int outputId, bool state) - { - var output = await _outputRepository.Get(outputId); - var endpoint = await _endpointRepository.Get(output.EndpointId); - await _extensionService.GetControlPoint(endpoint.ExtensionId, endpoint.DriverEndpointId).SetState(state); - - await _hubContext.Clients.All.SendAsync(Methods.OutputStateChanged, output.Id, state); - } - - public async Task GetState(int outputId) - { - var output = await _outputRepository.Get(outputId); - var endpoint = await _endpointRepository.Get(output.EndpointId); - return await _extensionService.GetControlPoint(endpoint.ExtensionId, endpoint.DriverEndpointId).GetState(); - } -} \ No newline at end of file +} diff --git a/src/Aporta.Core/Services/PeopleService.cs b/src/Aporta.Core/Services/PeopleService.cs deleted file mode 100644 index bbcbc36..0000000 --- a/src/Aporta.Core/Services/PeopleService.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Aporta.Core.DataAccess; -using Aporta.Core.DataAccess.Repositories; -using Aporta.Core.Hubs; -using Aporta.Shared.Messaging; -using Aporta.Shared.Models; -using Microsoft.AspNetCore.SignalR; - -namespace Aporta.Core.Services; - -public class PeopleService -{ - private readonly PersonRepository _personRepository; - private readonly CredentialRepository _credentialRepository; - private readonly IHubContext _hubContext; - - public PeopleService(IDataAccess dataAccess, IHubContext hubContext) - { - _hubContext = hubContext; - _personRepository = new PersonRepository(dataAccess); - _credentialRepository = new CredentialRepository(dataAccess); - } - - public async Task> GetAll() - { - return await _personRepository.GetAll(); - } - - public async Task Get(int personId) - { - return await _personRepository.Get(personId); - } - - public async Task Insert(Person person) - { - await _personRepository.Insert(person); - - await _hubContext.Clients.All.SendAsync(Methods.PersonInserted, person.Id); - } - - public async Task Delete(int personId) - { - var credentialsAssignedToPerson = await _credentialRepository.CredentialsAssignedToPerson(personId); - foreach (var credential in credentialsAssignedToPerson) - { - await _credentialRepository.RevokePerson(credential.Id, personId); - } - - await _personRepository.Delete(personId); - - await _hubContext.Clients.All.SendAsync(Methods.PersonDeleted, personId); - } -} \ No newline at end of file diff --git a/src/Aporta.Core/Services/Z9OpenCommunityProtocolService.cs b/src/Aporta.Core/Services/Z9OpenCommunityProtocolService.cs new file mode 100644 index 0000000..679a3e3 --- /dev/null +++ b/src/Aporta.Core/Services/Z9OpenCommunityProtocolService.cs @@ -0,0 +1,1719 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Numerics; +using System.Text; +using System.Threading; +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Microsoft.Extensions.Logging; +using Z9.Protobuf; +using Z9.Spcore.Proto; + +namespace Aporta.Core.Services; + +public class Z9OpenCommunityProtocolService : IDisposable +{ + private const int DefaultOsdpTcpPort = 9843; + + private readonly ILogger _logger; + + // Z9 Open protobuf repositories + private readonly DataFormatRepository _dataFormatRepository; + private readonly DataLayoutRepository _dataLayoutRepository; + private readonly CredTemplateRepository _credTemplateRepository; + private readonly PrivRepository _privRepository; + private readonly SchedRepository _schedRepository; + private readonly HolRepository _holRepository; + private readonly HolCalRepository _holCalRepository; + private readonly HolTypeRepository _holTypeRepository; + private readonly Z9CredRepository _z9CredRepository; + private readonly Z9EvtRepository _z9EvtRepository; + private readonly Z9DevRepository _z9DevRepository; + + // Received OSDP CredReader configurations from Z9 + private readonly List _osdpReaderConfigs = new(); + + // Mapping from OSDP endpoint ID prefix to Z9 device unid + // Key format: "{host}:{port}:{osdpAddress}" (e.g., "localhost:9843:0") + private readonly Dictionary _endpointToConfig = new(); + + // Pending door strike actuators (received before their door's reader) + // Key: logicalParentUnid (door unid), Value: output number + private readonly Dictionary _pendingStrikeActuators = new(); + + // Pending door contact sensors (received before their door's reader) + // Key: logicalParentUnid (door unid), Value: input number + private readonly Dictionary _pendingDoorContactSensors = new(); + + // Pending REX sensors (received before their door's reader) + // Key: logicalParentUnid (door unid), Value: input number + private readonly Dictionary _pendingRexSensors = new(); + + // Door info received from Z9 (unid -> name mapping) + private readonly Dictionary _doorInfo = new(); + + // Door config values (unid -> value) + private readonly Dictionary _doorActivateStrikeOnRex = new(); + private readonly Dictionary _doorStrikeTimeMs = new(); + private readonly Dictionary _doorExtendedStrikeTimeMs = new(); + private readonly Dictionary _doorHeldTimeMs = new(); + private readonly Dictionary _doorExtendedHeldTimeMs = new(); + private readonly Dictionary _doorDefaultDoorMode = new(); + + /// + /// OSDP reader configuration extracted from Z9 Dev messages. + /// + public class OsdpReaderConfig + { + public int Unid { get; set; } + public string Name { get; set; } + public string Host { get; set; } + public int TcpPort { get; set; } = DefaultOsdpTcpPort; + public int OsdpAddress { get; set; } + public int BaudRate { get; set; } = 9600; + public int? DoorUnid { get; set; } + public int? StrikeOutputNumber { get; set; } + public int? DoorContactInputNumber { get; set; } + public int? RexInputNumber { get; set; } + public bool ActivateStrikeOnRex { get; set; } + public int? StrikeTimeMs { get; set; } + public int? ExtendedStrikeTimeMs { get; set; } + public int? HeldTimeMs { get; set; } + public int? ExtendedHeldTimeMs { get; set; } + public DoorMode DefaultDoorMode { get; set; } + } + private readonly DevStateService _devStateService; + private volatile bool _continuousDevStateRecord; + + // Schedule evaluation state + private readonly Dictionary _schedActiveState = new(); // unid -> was active + private Timer _schedTimer; + + private Thread _thread; + private volatile bool _stopping; + private TcpClient _client; + private SpCoreMessageInputStream _mis; + private SpCoreMessageOutputStream _mos; + private readonly object _writeLock = new(); + private string _id = "aporta-panel"; + private Evt _controllerStartupEvt; + + public bool IsConnected { get; private set; } + public bool IsIdentified { get; private set; } + public List ReceivedDbChanges { get; } = new(); + public List ReceivedDevActions { get; } = new(); + public List OsdpReaderConfigs => _osdpReaderConfigs; + public Exception LastException { get; private set; } + + public Z9OpenCommunityProtocolService( + ILogger logger, + IDataAccess dataAccess, + DevStateService devStateService) + { + _logger = logger; + _devStateService = devStateService; + _dataFormatRepository = new DataFormatRepository(dataAccess); + _dataLayoutRepository = new DataLayoutRepository(dataAccess); + _credTemplateRepository = new CredTemplateRepository(dataAccess); + _privRepository = new PrivRepository(dataAccess); + _schedRepository = new SchedRepository(dataAccess); + _holRepository = new HolRepository(dataAccess); + _holCalRepository = new HolCalRepository(dataAccess); + _holTypeRepository = new HolTypeRepository(dataAccess); + _z9CredRepository = new Z9CredRepository(dataAccess); + _z9EvtRepository = new Z9EvtRepository(dataAccess); + _z9DevRepository = new Z9DevRepository(dataAccess); + + _devStateService.DevAspectStateChanged += OnDevAspectStateChanged; + } + + public void Start(string host, int port, string id = null) + { + _stopping = false; + if (!string.IsNullOrWhiteSpace(id)) + _id = id; + + PersistControllerStartupEvent(); + + _logger.LogInformation("Z9/Open Community protocol service connecting to {Host}:{Port} id={Id}", host, port, _id); + + _thread = new Thread(() => Run(host, port)) { IsBackground = true, Name = "Z9OpenCommunityProtocolService" }; + _thread.Start(); + + StartScheduleEvaluation(); + } + + public void Stop() + { + StopScheduleEvaluation(); + _stopping = true; + _client?.Close(); + _thread?.Join(TimeSpan.FromSeconds(5)); + + _logger.LogInformation("Z9/Open Community protocol service stopped"); + } + + public void Dispose() + { + StopScheduleEvaluation(); + Stop(); + } + + private void Run(string host, int port) + { + try + { + _client = new TcpClient(host, port); + IsConnected = true; + _logger.LogInformation("Connected to Z9/Open Community host at {Host}:{Port}", host, port); + + var stream = _client.GetStream(); + _mis = new SpCoreMessageInputStream(stream); + _mos = new SpCoreMessageOutputStream(stream); + + // Initiating side sends identification first + SendIdentification(); + + while (!_stopping) + { + SpCoreMessage message; + try + { + message = _mis.Read(); + } + catch (IOException) + { + if (_stopping) break; + throw; + } + + if (message != null) + { + HandleMessage(message); + } + } + } + catch (SocketException) when (_stopping) + { + // Expected when stopping + } + catch (ObjectDisposedException) when (_stopping) + { + // Expected when stopping + } + catch (EndOfStreamException) + { + _logger.LogInformation("Z9/Open Community host disconnected"); + } + catch (IOException ex) when (ex.InnerException is SocketException) + { + _logger.LogInformation("Z9/Open Community host disconnected"); + } + catch (Exception ex) + { + LastException = ex; + _logger.LogError(ex, "Z9/Open Community protocol service error"); + } + finally + { + IsConnected = false; + } + } + + private void HandleMessage(SpCoreMessage message) + { + _logger.LogInformation("Received message type {Type}", message.Type); + + switch (message.Type) + { + case SpCoreMessage.Types.Type.Ping: + break; + + case SpCoreMessage.Types.Type.Identification: + IsIdentified = true; + _logger.LogInformation("Z9/Open Community host identified"); + // Don't re-send - we already sent ours as the initiating side + SendAllDevStateRecords(alwaysRespond: false); + SendControllerStartupEvent(); + break; + + case SpCoreMessage.Types.Type.DbChange: + lock (ReceivedDbChanges) + { + ReceivedDbChanges.Add(message.DbChange); + } + HandleDbChange(message.DbChange); + SendDbChangeResp(message.DbChange.RequestId); + break; + + case SpCoreMessage.Types.Type.DevActionReq: + lock (ReceivedDevActions) + { + ReceivedDevActions.Add(message.DevActionReq); + } + _logger.LogInformation("Received DevActionReq (requestId={RequestId}, type={Type}, devUnid={DevUnid})", + message.DevActionReq.RequestId, message.DevActionReq.DevActionType, message.DevActionReq.DevUnid); + HandleDevActionReq(message.DevActionReq); + break; + + case SpCoreMessage.Types.Type.EvtControl: + break; + + case SpCoreMessage.Types.Type.DevStateRecordControl: + HandleDevStateRecordControl(message.DevStateRecordControl); + break; + + default: + _logger.LogInformation("Ignoring unhandled message type {Type}", message.Type); + break; + } + } + + private void HandleDbChange(DbChange dbChange) + { + _logger.LogDebug("Processing DbChange (requestId={RequestId})", dbChange.RequestId); + + // Process deletes first (order: most dependent -> least dependent) + // Cred deletes + if (dbChange.CredDeleteAllCase == DbChange.CredDeleteAllOneofCase.CredDeleteAll && dbChange.CredDeleteAll) + { + _logger.LogInformation("Deleting all credentials"); + // Note: We don't have a DeleteAll that also cleans up persons/assignments + // For now, just delete credentials + } + foreach (var credDelete in dbChange.CredDelete) + { + _logger.LogInformation("Deleting credential {Unid}", credDelete); + _z9CredRepository.Delete(credDelete).GetAwaiter().GetResult(); + } + + // Priv deletes + if (dbChange.PrivDeleteAllCase == DbChange.PrivDeleteAllOneofCase.PrivDeleteAll && dbChange.PrivDeleteAll) + { + _logger.LogInformation("Deleting all privileges"); + _privRepository.DeleteAll().GetAwaiter().GetResult(); + } + foreach (var privDelete in dbChange.PrivDelete) + { + _logger.LogInformation("Deleting privilege {Unid}", privDelete); + _privRepository.Delete(privDelete).GetAwaiter().GetResult(); + } + + // CredTemplate deletes + if (dbChange.CredTemplateDeleteAllCase == DbChange.CredTemplateDeleteAllOneofCase.CredTemplateDeleteAll && dbChange.CredTemplateDeleteAll) + { + _logger.LogInformation("Deleting all credential templates"); + _credTemplateRepository.DeleteAll().GetAwaiter().GetResult(); + } + foreach (var credTemplateDelete in dbChange.CredTemplateDelete) + { + _logger.LogInformation("Deleting credential template {Unid}", credTemplateDelete); + _credTemplateRepository.Delete(credTemplateDelete).GetAwaiter().GetResult(); + } + + // DataLayout deletes + if (dbChange.DataLayoutDeleteAllCase == DbChange.DataLayoutDeleteAllOneofCase.DataLayoutDeleteAll && dbChange.DataLayoutDeleteAll) + { + _logger.LogInformation("Deleting all data layouts"); + _dataLayoutRepository.DeleteAll().GetAwaiter().GetResult(); + } + foreach (var dataLayoutDelete in dbChange.DataLayoutDelete) + { + _logger.LogInformation("Deleting data layout {Unid}", dataLayoutDelete); + _dataLayoutRepository.Delete(dataLayoutDelete).GetAwaiter().GetResult(); + } + + // DataFormat deletes + if (dbChange.DataFormatDeleteAllCase == DbChange.DataFormatDeleteAllOneofCase.DataFormatDeleteAll && dbChange.DataFormatDeleteAll) + { + _logger.LogInformation("Deleting all data formats"); + _dataFormatRepository.DeleteAll().GetAwaiter().GetResult(); + } + foreach (var dataFormatDelete in dbChange.DataFormatDelete) + { + _logger.LogInformation("Deleting data format {Unid}", dataFormatDelete); + _dataFormatRepository.Delete(dataFormatDelete).GetAwaiter().GetResult(); + } + + // Sched deletes + if (dbChange.SchedDeleteAllCase == DbChange.SchedDeleteAllOneofCase.SchedDeleteAll && dbChange.SchedDeleteAll) + { + _logger.LogInformation("Deleting all schedules"); + _schedRepository.DeleteAll().GetAwaiter().GetResult(); + } + foreach (var schedDelete in dbChange.SchedDelete) + { + _logger.LogInformation("Deleting schedule {Unid}", schedDelete); + _schedRepository.Delete(schedDelete).GetAwaiter().GetResult(); + } + + // Hol deletes + if (dbChange.HolDeleteAllCase == DbChange.HolDeleteAllOneofCase.HolDeleteAll && dbChange.HolDeleteAll) + { + _logger.LogInformation("Deleting all holidays"); + _holRepository.DeleteAll().GetAwaiter().GetResult(); + } + foreach (var holDelete in dbChange.HolDelete) + { + _logger.LogInformation("Deleting holiday {Unid}", holDelete); + _holRepository.Delete(holDelete).GetAwaiter().GetResult(); + } + + // HolCal deletes + if (dbChange.HolCalDeleteAllCase == DbChange.HolCalDeleteAllOneofCase.HolCalDeleteAll && dbChange.HolCalDeleteAll) + { + _logger.LogInformation("Deleting all holiday calendars"); + _holCalRepository.DeleteAll().GetAwaiter().GetResult(); + } + foreach (var holCalDelete in dbChange.HolCalDelete) + { + _logger.LogInformation("Deleting holiday calendar {Unid}", holCalDelete); + _holCalRepository.Delete(holCalDelete).GetAwaiter().GetResult(); + } + + // HolType deletes + if (dbChange.HolTypeDeleteAllCase == DbChange.HolTypeDeleteAllOneofCase.HolTypeDeleteAll && dbChange.HolTypeDeleteAll) + { + _logger.LogInformation("Deleting all holiday types"); + _holTypeRepository.DeleteAll().GetAwaiter().GetResult(); + } + foreach (var holTypeDelete in dbChange.HolTypeDelete) + { + _logger.LogInformation("Deleting holiday type {Unid}", holTypeDelete); + _holTypeRepository.Delete(holTypeDelete).GetAwaiter().GetResult(); + } + + // Process inserts/updates (order: least dependent -> most dependent) + // HolType (no dependencies) + foreach (var holType in dbChange.HolType) + { + ProcessHolType(holType); + } + + // HolCal (no dependencies) + foreach (var holCal in dbChange.HolCal) + { + ProcessHolCal(holCal); + } + + // Hol (depends on HolCal) + foreach (var hol in dbChange.Hol) + { + ProcessHol(hol); + } + + // Sched (may reference HolCal) + foreach (var sched in dbChange.Sched) + { + ProcessSched(sched); + } + + // DataFormat (no dependencies) + foreach (var dataFormat in dbChange.DataFormat) + { + ProcessDataFormat(dataFormat); + } + + // DataLayout (depends on DataFormat) + foreach (var dataLayout in dbChange.DataLayout) + { + ProcessDataLayout(dataLayout); + } + + // CredTemplate (depends on DataLayout) + foreach (var credTemplate in dbChange.CredTemplate) + { + ProcessCredTemplate(credTemplate); + } + + // Priv (may reference Sched) + foreach (var priv in dbChange.Priv) + { + ProcessPriv(priv); + } + + // Cred (depends on CredTemplate, may reference Priv) + foreach (var cred in dbChange.Cred) + { + ProcessCredential(cred); + } + + // Dev (extract OSDP reader configurations - stored in memory for now) + foreach (var dev in dbChange.Dev) + { + ProcessDev(dev); + } + } + + private void ProcessDataFormat(DataFormat dataFormat) + { + SpCoreProtoUtil.InitRequired(dataFormat); + _logger.LogDebug("Storing DataFormat: unid={Unid}, name={Name}, type={Type}", + dataFormat.Unid, dataFormat.Name, dataFormat.DataFormatType); + _dataFormatRepository.Upsert(dataFormat).GetAwaiter().GetResult(); + } + + private void ProcessDataLayout(DataLayout dataLayout) + { + SpCoreProtoUtil.InitRequired(dataLayout); + _logger.LogDebug("Storing DataLayout: unid={Unid}, name={Name}, type={Type}", + dataLayout.Unid, dataLayout.Name, dataLayout.LayoutType); + _dataLayoutRepository.Upsert(dataLayout).GetAwaiter().GetResult(); + } + + private void ProcessCredTemplate(CredTemplate credTemplate) + { + SpCoreProtoUtil.InitRequired(credTemplate); + _logger.LogDebug("Storing CredTemplate: unid={Unid}, name={Name}", + credTemplate.Unid, credTemplate.Name); + _credTemplateRepository.Upsert(credTemplate).GetAwaiter().GetResult(); + } + + private void ProcessPriv(Priv priv) + { + SpCoreProtoUtil.InitRequired(priv); + _logger.LogDebug("Storing Priv: unid={Unid}, name={Name}, type={Type}", + priv.Unid, priv.Name, priv.PrivType); + _privRepository.Upsert(priv).GetAwaiter().GetResult(); + } + + private void ProcessSched(Sched sched) + { + SpCoreProtoUtil.InitRequired(sched); + _logger.LogDebug("Storing Sched: unid={Unid}, name={Name}", + sched.Unid, sched.Name); + _schedRepository.Upsert(sched).GetAwaiter().GetResult(); + } + + private void ProcessHol(Hol hol) + { + SpCoreProtoUtil.InitRequired(hol); + _logger.LogDebug("Storing Hol: unid={Unid}, name={Name}", + hol.Unid, hol.Name); + _holRepository.Upsert(hol).GetAwaiter().GetResult(); + } + + private void ProcessHolCal(HolCal holCal) + { + SpCoreProtoUtil.InitRequired(holCal); + _logger.LogDebug("Storing HolCal: unid={Unid}, name={Name}", + holCal.Unid, holCal.Name); + _holCalRepository.Upsert(holCal).GetAwaiter().GetResult(); + } + + private void ProcessHolType(HolType holType) + { + SpCoreProtoUtil.InitRequired(holType); + _logger.LogDebug("Storing HolType: unid={Unid}, name={Name}", + holType.Unid, holType.Name); + _holTypeRepository.Upsert(holType).GetAwaiter().GetResult(); + } + + private void ProcessDev(Dev dev) + { + SpCoreProtoUtil.InitRequired(dev); + + // Persist to z9_dev table for Z9/Flex REST API + _z9DevRepository.Upsert(dev).GetAwaiter().GetResult(); + + if (dev.DevType == DevType.Actuator) + { + ProcessActuatorDev(dev); + return; + } + + if (dev.DevType == DevType.Sensor) + { + ProcessSensorDev(dev); + return; + } + + if (dev.DevType == DevType.Door) + { + ProcessDoorDev(dev); + return; + } + + // Check if this is an OSDP credential reader + if (dev.DevType != DevType.CredReader) + return; + + var credReaderConfig = dev.ExtCredReader?.CredReaderConfig; + if (credReaderConfig == null) + return; + + // Check for OSDP comm type + if (credReaderConfig.CommType != CredReaderCommType.OsdpHalfDuplex) + return; + + var serialPortAddress = credReaderConfig.SerialPortAddress; + if (string.IsNullOrWhiteSpace(serialPortAddress)) + { + _logger.LogWarning("OSDP CredReader {Name} (unid={Unid}) has no serialPortAddress, skipping", + dev.Name, dev.Unid); + return; + } + + // Parse host and port from serialPortAddress (e.g., "localhost:9843" or just "localhost") + string host; + int tcpPort; + var colonIndex = serialPortAddress.LastIndexOf(':'); + if (colonIndex > 0 && int.TryParse(serialPortAddress.Substring(colonIndex + 1), out tcpPort)) + { + host = serialPortAddress.Substring(0, colonIndex); + } + else + { + host = serialPortAddress; + tcpPort = DefaultOsdpTcpPort; + } + + var osdpConfig = new OsdpReaderConfig + { + Unid = dev.Unid, + Name = dev.Name ?? $"OSDP Reader {dev.Unid}", + Host = host, + TcpPort = tcpPort, + OsdpAddress = dev.Port, // OSDP polling address (0-126) + BaudRate = dev.Speed > 0 ? dev.Speed : 9600, + DoorUnid = dev.LogicalParentUnidCase == Dev.LogicalParentUnidOneofCase.LogicalParentUnid + ? dev.LogicalParentUnid + : null + }; + + _logger.LogInformation( + "Received OSDP CredReader config: Name={Name}, Host={Host}:{TcpPort}, OsdpAddress={OsdpAddress}", + osdpConfig.Name, osdpConfig.Host, osdpConfig.TcpPort, osdpConfig.OsdpAddress); + + // Check for pending strike actuator for this door + if (osdpConfig.DoorUnid != null) + { + lock (_pendingStrikeActuators) + { + if (_pendingStrikeActuators.TryGetValue(osdpConfig.DoorUnid.Value, out var outputNumber)) + { + osdpConfig.StrikeOutputNumber = outputNumber; + _pendingStrikeActuators.Remove(osdpConfig.DoorUnid.Value); + _logger.LogInformation("Applied pending door strike actuator: output {OutputNumber} on reader {Name}", + outputNumber, osdpConfig.Name); + } + } + + // Check for pending door contact sensor for this door + lock (_pendingDoorContactSensors) + { + if (_pendingDoorContactSensors.TryGetValue(osdpConfig.DoorUnid.Value, out var inputNumber)) + { + osdpConfig.DoorContactInputNumber = inputNumber; + _pendingDoorContactSensors.Remove(osdpConfig.DoorUnid.Value); + _logger.LogInformation("Applied pending door contact sensor: input {InputNumber} on reader {Name}", + inputNumber, osdpConfig.Name); + } + } + + // Check for pending REX sensor for this door + lock (_pendingRexSensors) + { + if (_pendingRexSensors.TryGetValue(osdpConfig.DoorUnid.Value, out var rexInputNumber)) + { + osdpConfig.RexInputNumber = rexInputNumber; + _pendingRexSensors.Remove(osdpConfig.DoorUnid.Value); + _logger.LogInformation("Applied pending REX sensor: input {InputNumber} on reader {Name}", + rexInputNumber, osdpConfig.Name); + } + } + + // Check for door config values + lock (_doorActivateStrikeOnRex) + { + if (_doorActivateStrikeOnRex.TryGetValue(osdpConfig.DoorUnid.Value, out var activateStrike)) + { + osdpConfig.ActivateStrikeOnRex = activateStrike; + } + } + lock (_doorStrikeTimeMs) + { + if (_doorStrikeTimeMs.TryGetValue(osdpConfig.DoorUnid.Value, out var strikeTimeMs)) + { + osdpConfig.StrikeTimeMs = strikeTimeMs; + } + } + lock (_doorExtendedStrikeTimeMs) + { + if (_doorExtendedStrikeTimeMs.TryGetValue(osdpConfig.DoorUnid.Value, out var extStrikeTimeMs)) + { + osdpConfig.ExtendedStrikeTimeMs = extStrikeTimeMs; + } + } + lock (_doorHeldTimeMs) + { + if (_doorHeldTimeMs.TryGetValue(osdpConfig.DoorUnid.Value, out var heldTimeMs)) + { + osdpConfig.HeldTimeMs = heldTimeMs; + } + } + lock (_doorExtendedHeldTimeMs) + { + if (_doorExtendedHeldTimeMs.TryGetValue(osdpConfig.DoorUnid.Value, out var extHeldTimeMs)) + { + osdpConfig.ExtendedHeldTimeMs = extHeldTimeMs; + } + } + lock (_doorDefaultDoorMode) + { + if (_doorDefaultDoorMode.TryGetValue(osdpConfig.DoorUnid.Value, out var defaultDoorMode)) + { + osdpConfig.DefaultDoorMode = defaultDoorMode; + } + } + } + + lock (_osdpReaderConfigs) + { + // Remove existing config with same unid + _osdpReaderConfigs.RemoveAll(c => c.Unid == osdpConfig.Unid); + _osdpReaderConfigs.Add(osdpConfig); + } + + // Store mapping from endpoint ID prefix to config for event correlation + var endpointIdPrefix = $"{osdpConfig.Host}:{osdpConfig.TcpPort}:{osdpConfig.OsdpAddress}"; + lock (_endpointToConfig) + { + _endpointToConfig[endpointIdPrefix] = osdpConfig; + } + + // Notify that OSDP configuration is available + OsdpConfigurationReceived?.Invoke(this, osdpConfig); + } + + private void ProcessActuatorDev(Dev dev) + { + if (dev.DevUse != DevUse.ActuatorDoorStrike) + return; + + if (dev.LogicalParentUnidCase != Dev.LogicalParentUnidOneofCase.LogicalParentUnid) + { + _logger.LogWarning("Door strike actuator {Name} (unid={Unid}) has no logicalParentUnid (door), skipping", + dev.Name, dev.Unid); + return; + } + + var doorUnid = dev.LogicalParentUnid; + var outputNumber = 0; + if (!string.IsNullOrWhiteSpace(dev.Address)) + { + int.TryParse(dev.Address, out outputNumber); + } + + _logger.LogInformation("Received door strike actuator: output {OutputNumber}, door unid={DoorUnid}", + outputNumber, doorUnid); + + // Try to find matching reader config by door unid + OsdpReaderConfig matchingConfig = null; + lock (_osdpReaderConfigs) + { + matchingConfig = _osdpReaderConfigs.Find(c => c.DoorUnid == doorUnid); + } + + if (matchingConfig != null) + { + matchingConfig.StrikeOutputNumber = outputNumber; + _logger.LogInformation("Assigned door strike output {OutputNumber} to reader {Name} (door unid={DoorUnid})", + outputNumber, matchingConfig.Name, doorUnid); + } + else + { + // Reader for this door hasn't arrived yet - store for later + lock (_pendingStrikeActuators) + { + _pendingStrikeActuators[doorUnid] = outputNumber; + } + _logger.LogInformation("Reader for door unid={DoorUnid} not yet received, storing pending strike actuator", doorUnid); + } + } + + private void ProcessSensorDev(Dev dev) + { + if (dev.DevUse == DevUse.SensorDoorContact) + { + ProcessSensorWithPending(dev, "door contact", + (config, inputNum) => config.DoorContactInputNumber = inputNum, + _pendingDoorContactSensors); + } + else if (dev.DevUse == DevUse.SensorRex) + { + ProcessSensorWithPending(dev, "REX", + (config, inputNum) => config.RexInputNumber = inputNum, + _pendingRexSensors); + } + } + + private void ProcessSensorWithPending(Dev dev, string sensorLabel, + Action assignInputNumber, + Dictionary pendingDictionary) + { + if (dev.LogicalParentUnidCase != Dev.LogicalParentUnidOneofCase.LogicalParentUnid) + { + _logger.LogWarning("{SensorLabel} sensor {Name} (unid={Unid}) has no logicalParentUnid (door), skipping", + sensorLabel, dev.Name, dev.Unid); + return; + } + + var doorUnid = dev.LogicalParentUnid; + var inputNumber = 0; + if (!string.IsNullOrWhiteSpace(dev.Address)) + { + int.TryParse(dev.Address, out inputNumber); + } + + _logger.LogInformation("Received {SensorLabel} sensor: input {InputNumber}, door unid={DoorUnid}", + sensorLabel, inputNumber, doorUnid); + + OsdpReaderConfig matchingConfig = null; + lock (_osdpReaderConfigs) + { + matchingConfig = _osdpReaderConfigs.Find(c => c.DoorUnid == doorUnid); + } + + if (matchingConfig != null) + { + assignInputNumber(matchingConfig, inputNumber); + _logger.LogInformation("Assigned {SensorLabel} input {InputNumber} to reader {Name} (door unid={DoorUnid})", + sensorLabel, inputNumber, matchingConfig.Name, doorUnid); + } + else + { + lock (pendingDictionary) + { + pendingDictionary[doorUnid] = inputNumber; + } + _logger.LogInformation("Reader for door unid={DoorUnid} not yet received, storing pending {SensorLabel} sensor", + doorUnid, sensorLabel); + } + } + + private void ProcessDoorDev(Dev dev) + { + var doorName = dev.Name ?? $"Door {dev.Unid}"; + lock (_doorInfo) + { + _doorInfo[dev.Unid] = doorName; + } + + // Extract door config values + if (dev.ExtDoor?.DoorConfig != null) + { + var doorConfig = dev.ExtDoor.DoorConfig; + var activateStrikeOnRex = doorConfig.ActivateStrikeOnRex; + int? strikeTimeMs = doorConfig.StrikeTimeCase == DoorConfig.StrikeTimeOneofCase.StrikeTime + ? doorConfig.StrikeTime : null; + int? extendedStrikeTimeMs = doorConfig.ExtendedStrikeTimeCase == DoorConfig.ExtendedStrikeTimeOneofCase.ExtendedStrikeTime + ? doorConfig.ExtendedStrikeTime : null; + int? heldTimeMs = doorConfig.HeldTimeCase == DoorConfig.HeldTimeOneofCase.HeldTime + ? doorConfig.HeldTime : null; + int? extendedHeldTimeMs = doorConfig.ExtendedHeldTimeCase == DoorConfig.ExtendedHeldTimeOneofCase.ExtendedHeldTime + ? doorConfig.ExtendedHeldTime : null; + + lock (_doorActivateStrikeOnRex) + { + _doorActivateStrikeOnRex[dev.Unid] = activateStrikeOnRex; + } + lock (_doorStrikeTimeMs) + { + _doorStrikeTimeMs[dev.Unid] = strikeTimeMs; + } + lock (_doorExtendedStrikeTimeMs) + { + _doorExtendedStrikeTimeMs[dev.Unid] = extendedStrikeTimeMs; + } + lock (_doorHeldTimeMs) + { + _doorHeldTimeMs[dev.Unid] = heldTimeMs; + } + lock (_doorExtendedHeldTimeMs) + { + _doorExtendedHeldTimeMs[dev.Unid] = extendedHeldTimeMs; + } + if (doorConfig.DefaultDoorMode != null) + { + lock (_doorDefaultDoorMode) + { + _doorDefaultDoorMode[dev.Unid] = doorConfig.DefaultDoorMode; + } + } + + // Apply to existing reader config if already loaded + lock (_osdpReaderConfigs) + { + var config = _osdpReaderConfigs.Find(c => c.DoorUnid == dev.Unid); + if (config != null) + { + config.ActivateStrikeOnRex = activateStrikeOnRex; + config.StrikeTimeMs = strikeTimeMs; + config.ExtendedStrikeTimeMs = extendedStrikeTimeMs; + config.HeldTimeMs = heldTimeMs; + config.ExtendedHeldTimeMs = extendedHeldTimeMs; + if (doorConfig.DefaultDoorMode != null) + config.DefaultDoorMode = doorConfig.DefaultDoorMode; + } + } + + _logger.LogInformation("Received Door Dev: unid={Unid}, name={Name}, activateStrikeOnRex={ActivateStrikeOnRex}, strikeTimeMs={StrikeTimeMs}, extendedStrikeTimeMs={ExtendedStrikeTimeMs}, heldTimeMs={HeldTimeMs}, extendedHeldTimeMs={ExtendedHeldTimeMs}", + dev.Unid, doorName, activateStrikeOnRex, strikeTimeMs, extendedStrikeTimeMs, heldTimeMs, extendedHeldTimeMs); + } + else + { + _logger.LogInformation("Received Door Dev: unid={Unid}, name={Name}", dev.Unid, doorName); + } + } + + /// + /// Event raised when OSDP reader configuration is received from Z9. + /// + public event EventHandler OsdpConfigurationReceived; + + private void ProcessCredential(Cred cred) + { + SpCoreProtoUtil.InitRequired(cred); + + if (cred.CardPin?.CredNum == null || cred.CardPin.CredNum.BytesCase == BigIntegerData.BytesOneofCase.None) + { + _logger.LogWarning( + "Credential Unid={Unid} has no card number, deleting from Aporta if it exists", cred.Unid); + _z9CredRepository.Delete(cred.Unid).GetAwaiter().GetResult(); + return; + } + + var credNumString = Z9CredRepository.ExtractCredNum(cred); + + _logger.LogInformation("Processing credential Unid={Unid} Number={Number} Name={Name} PrivBindings={BindingCount}", + cred.Unid, credNumString, cred.Name, cred.PrivBindings.Count); + + // Store the full Z9 Cred proto (cred_num set by overridden Upsert) + _z9CredRepository.Upsert(cred).GetAwaiter().GetResult(); + } + + /// + /// Creates a raw N-bit DataFormat and BasicDataLayout if one doesn't already exist for that bit count. + /// Used in standalone mode to enable card data decoding without Z9 host configuration. + /// + /// The number of bits in the raw card data. + public void EnsureRawDataFormat(int bitCount) + { + // Check if a DataFormat with this unid already exists + var existing = _dataFormatRepository.Get(bitCount).GetAwaiter().GetResult(); + if (existing != null) + return; + + _logger.LogInformation("Creating raw {BitCount}-bit DataFormat and DataLayout", bitCount); + + var dataFormat = new DataFormat + { + Unid = bitCount, + Name = $"Raw {bitCount}-bit", + DataFormatType = DataFormatType.Binary, + ExtBinaryFormat = new BinaryFormat + { + MinBits = bitCount, + MaxBits = bitCount, + SupportReverseRead = false + } + }; + // Single FIELD element: entire bit range is the credential number + dataFormat.ExtBinaryFormat.Elements.Add(new BinaryElement + { + Num = 0, + Type = BinaryElementType.Field, + Start = 0, + Len = bitCount, + ExtFieldBinaryElement = new FieldBinaryElement + { + Field = DataFormatField.CredNum + } + }); + SpCoreProtoUtil.InitRequired(dataFormat); + _dataFormatRepository.Upsert(dataFormat).GetAwaiter().GetResult(); + + var dataLayout = new DataLayout + { + Unid = bitCount, + Name = $"Raw {bitCount}-bit", + LayoutType = DataLayoutType.Basic, + Enabled = true, + Priority = 0, + ExtBasicDataLayout = new BasicDataLayout + { + DataFormatUnid = bitCount + } + }; + SpCoreProtoUtil.InitRequired(dataLayout); + _dataLayoutRepository.Upsert(dataLayout).GetAwaiter().GetResult(); + } + + /// + /// Creates the default CredTemplate (unid=1) if not already present. + /// Used in standalone mode so credentials have a valid credential template. + /// + public void EnsureDefaultCredTemplate() + { + var existing = _credTemplateRepository.Get(1).GetAwaiter().GetResult(); + if (existing != null) + return; + + _logger.LogInformation("Creating default CredTemplate (unid=1)"); + + var credTemplate = new CredTemplate + { + Unid = 1, + Name = "Card (Auto)", + Priority = 0, + CardPinTemplate = new CardPinTemplate + { + CredComponentPresence = CredComponentPresence.Required, + CredNumPresence = CredComponentPresence.Required, + PinPresence = CredComponentPresence.Absent, + AnyDataLayout = true + } + }; + SpCoreProtoUtil.InitRequired(credTemplate); + _credTemplateRepository.Upsert(credTemplate).GetAwaiter().GetResult(); + } + + /// + /// Creates a Z9 Cred proto and upserts it to the z9_cred table. + /// Used in standalone mode during auto-enrollment to create the full Z9 data chain. + /// + /// The Aporta credential ID (used as the Cred unid). + /// The decoded credential number. + /// The Aporta door ID (stored as devAsDoorAccessPrivUnid for future use). + public void CreateStandaloneCred(int credentialId, BigInteger credNum, int doorId) + { + _logger.LogInformation("Creating standalone Z9 Cred: unid={CredentialId}, credNum={CredNum}, doorId={DoorId}", + credentialId, credNum, doorId); + + var cred = new Cred + { + Unid = credentialId, + Enabled = true, + CredTemplateUnid = 1, + CardPin = new CardPin + { + CredNum = SpCoreProtoUtil.ToBigIntegerData(credNum) + } + }; + cred.PrivBindings.Add(new CredPrivBinding + { + Unid = 1, + DevAsDoorAccessPrivUnid = doorId + }); + SpCoreProtoUtil.InitRequired(cred); + _z9CredRepository.Upsert(cred).GetAwaiter().GetResult(); + } + + /// + /// Decodes a raw bit string from a card read into a credential number by trying all known formats. + /// + /// The raw bit string from the card reader (e.g., "10110010000110000001110011") + /// Tuple of (credNum, facilityCode, formatName) if decoded, or null if no format matched. + public (BigInteger credNum, int? facilityCode, string formatName)? DecodeCardRead(string rawBits) + { + if (string.IsNullOrEmpty(rawBits)) + return null; + + BitBuffer bb; + try + { + bb = BitBuffer.FromBinaryString(rawBits); + } + catch (FormatException ex) + { + _logger.LogWarning(ex, "Invalid bit string: {RawBits}", rawBits); + return null; + } + + var dataFormats = _dataFormatRepository.GetAll().GetAwaiter().GetResult(); + _logger.LogDebug("DecodeCardRead: trying {Count} data formats for {BitCount}-bit card", + dataFormats.Count(), rawBits.Length); + foreach (var dataFormat in dataFormats) + { + if (dataFormat.DataFormatType != DataFormatType.Binary || dataFormat.ExtBinaryFormat == null) + continue; + + try + { + var formatter = new BinaryFormatter(dataFormat); + var decodedRead = formatter.Decode(bb); + + // Extract credential number + var credNumElements = decodedRead.GetElementsMatchingField(DataFormatField.CredNum); + if (credNumElements.Count == 0) + continue; + + var credNum = credNumElements[0].GetValue(); + + // Extract facility code if present + int? facilityCode = null; + var fcElements = decodedRead.GetElementsMatchingField(DataFormatField.FacilityCode); + if (fcElements.Count > 0) + { + facilityCode = (int)fcElements[0].GetValue(); + } + + _logger.LogDebug("Decoded card read using format {Name}: credNum={CredNum}, fc={FacilityCode}", + dataFormat.Name, credNum, facilityCode); + + return (credNum, facilityCode, dataFormat.Name); + } + catch (BinaryFormatterException) + { + // This format didn't match, try next + } + } + + _logger.LogDebug("No format matched for card read: {RawBits}", rawBits); + return null; + } + + private void SendIdentification() + { + var message = new SpCoreMessage + { + Type = SpCoreMessage.Types.Type.Identification, + Identification = new Identification + { + Id = _id, + SoftwareVersion = "1.0.0", + ProtocolVersion = "0.1", + MaxBodyLength = SpCoreMessageHeader.MAX_LENGTH, + SpCoreDevMod = DevMod.IoControllerCommunity, + ProtocolCapabilities = new ProtocolCapabilities + { + SupportsIdentificationPassword = true, + SupportsIdentificationPasswordUpstream = true + } + } + }; + + WriteMessage(message); + } + + private void SendDbChangeResp(long requestId) + { + var message = new SpCoreMessage + { + Type = SpCoreMessage.Types.Type.DbChangeResp, + DbChangeResp = new DbChangeResp + { + RequestId = requestId + } + }; + + WriteMessage(message); + } + + /// + /// Event raised when a DevActionReq is received from the host. + /// + public event EventHandler DevActionRequested; + + /// + /// Dispatches a DevActionReq to the same handler as protocol-originated requests. + /// Used by the Z9/Flex REST API to trigger door actions. + /// + public void DispatchDevAction(DevActionReq req) + { + DevActionRequested?.Invoke(this, req); + } + + private void HandleDevActionReq(DevActionReq req) + { + try + { + DevActionRequested?.Invoke(this, req); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling DevActionReq (requestId={RequestId})", req.RequestId); + SendDevActionResp(req.RequestId, ex.Message); + return; + } + SendDevActionResp(req.RequestId); + } + + private void SendDevActionResp(long requestId, string exception = null) + { + var resp = new DevActionResp + { + RequestId = requestId + }; + if (exception != null) + { + resp.Exception = exception; + } + + var message = new SpCoreMessage + { + Type = SpCoreMessage.Types.Type.DevActionResp, + DevActionResp = resp + }; + + WriteMessage(message); + } + + /// + /// Registers an OsdpReaderConfig directly (for standalone/primary mode where configs + /// come from a config file instead of Z9 Dev messages). + /// + public void RegisterOsdpReaderConfig(OsdpReaderConfig config) + { + lock (_osdpReaderConfigs) + { + _osdpReaderConfigs.RemoveAll(c => c.Unid == config.Unid); + _osdpReaderConfigs.Add(config); + } + + var endpointIdPrefix = $"{config.Host}:{config.TcpPort}:{config.OsdpAddress}"; + lock (_endpointToConfig) + { + _endpointToConfig[endpointIdPrefix] = config; + } + + _logger.LogInformation("Registered OSDP reader config: {Name} at {Host}:{TcpPort}:{OsdpAddress}", + config.Name, config.Host, config.TcpPort, config.OsdpAddress); + } + + /// + /// Gets the OsdpReaderConfig for an endpoint ID, if available. + /// + /// The OSDP endpoint ID (format: "host:port:address:R#") + /// The config if found, null otherwise. + public OsdpReaderConfig GetConfigForEndpoint(string endpointId) + { + if (string.IsNullOrEmpty(endpointId)) + return null; + + // Endpoint ID format: "{host}:{port}:{address}:R{readerNum}" + // We need to extract "{host}:{port}:{address}" to match our mapping + var parts = endpointId.Split(':'); + if (parts.Length < 3) + return null; + + // For "localhost:9843:0:R0" we want "localhost:9843:0" + var endpointIdPrefix = $"{parts[0]}:{parts[1]}:{parts[2]}"; + + lock (_endpointToConfig) + { + return _endpointToConfig.TryGetValue(endpointIdPrefix, out var config) ? config : null; + } + } + + /// + /// Persists a CONTROLLER_STARTUP event to the event DB on application startup, + /// before any connection is established. The event will be delivered to the host + /// when it connects and starts pulling events. + /// + private void PersistControllerStartupEvent() + { + var nowMillis = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var evt = new Evt + { + EvtCode = EvtCode.ControllerStartup, + HwTime = new DateTimeData { Millis = nowMillis }, + DbTime = new DateTimeData { Millis = nowMillis }, + Consumed = false, + Priority = 0, + EvtDevRef = new EvtDevRef + { + Name = _id, + DevType = DevType.IoController, + DevMod = DevMod.IoControllerCommunity + } + }; + + // Use top-level controller Dev from DB if available (provides Z9-assigned unid/name) + var controllers = _z9DevRepository.GetAllByDevType(DevType.IoController).GetAwaiter().GetResult(); + var controller = controllers.FirstOrDefault(c => c.DevMod == DevMod.IoControllerCommunity); + if (controller != null) + { + evt.EvtDevRef.Unid = controller.Unid; + evt.EvtDevRef.Name = controller.Name; + } + + _z9EvtRepository.Insert(evt).GetAwaiter().GetResult(); + _controllerStartupEvt = evt; + _logger.LogInformation("Persisted CONTROLLER_STARTUP event (unid={Unid})", evt.Unid); + } + + /// + /// Sends the CONTROLLER_STARTUP event over the wire when the host connection is established. + /// Called once per startup cycle from the Identification handler. + /// + private void SendControllerStartupEvent() + { + var evt = _controllerStartupEvt; + if (evt == null) return; + _controllerStartupEvt = null; + + var message = new SpCoreMessage { Type = SpCoreMessage.Types.Type.Evt }; + message.Evt.Add(evt); + _logger.LogInformation("Sending CONTROLLER_STARTUP event (unid={Unid})", evt.Unid); + WriteMessage(message); + } + + /// + /// Sends a CRED_READER_ONLINE or CRED_READER_OFFLINE event to Z9. + /// + /// The OSDP reader configuration. + /// True if reader came online, false if offline. + public void SendCredReaderOnlineEvent(OsdpReaderConfig config, bool isOnline) + { + if (config == null) + { + _logger.LogWarning("Cannot send cred reader event: config is null"); + return; + } + + var evtCode = isOnline ? EvtCode.CredReaderOnline : EvtCode.CredReaderOffline; + var nowMillis = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var evt = new Evt + { + EvtCode = evtCode, + HwTime = new DateTimeData { Millis = nowMillis }, + DbTime = new DateTimeData { Millis = nowMillis }, + Consumed = false, + Priority = 0, + EvtDevRef = new EvtDevRef + { + Unid = config.Unid, + Name = config.Name, + DevType = DevType.CredReader + } + }; + + _z9EvtRepository.Insert(evt).GetAwaiter().GetResult(); + + if (!IsConnected) + { + _logger.LogWarning("Cannot send cred reader event: not connected"); + return; + } + + var message = new SpCoreMessage + { + Type = SpCoreMessage.Types.Type.Evt + }; + message.Evt.Add(evt); + + _logger.LogInformation("Sending {EvtCode} event for reader {Name} (unid={Unid})", + evtCode, config.Name, config.Unid); + + WriteMessage(message); + } + + /// + /// Sends an access event (granted or denied) to Z9. + /// Access events are NOT persisted here — AccessService already persists them to z9_evt. + /// + /// The OSDP reader configuration (to determine the device). + /// True if access was granted, false if denied. + /// The decoded credential number (null if unable to decode). + /// The facility code (null if not available or not decoded). + /// The raw bit string from the card read (stored in EvtData if credNum is null). + /// Optional sub-code providing the reason for denial. + public void SendAccessEvent(OsdpReaderConfig config, bool isGranted, BigInteger? credNum, int? facilityCode, string rawBits, EvtSubCode subCode = EvtSubCode.AccessDeniedUnknownCredNum) + { + if (config == null) + { + _logger.LogWarning("Cannot send access event: config is null"); + return; + } + + if (!IsConnected) + { + _logger.LogWarning("Cannot send access event: not connected"); + return; + } + + var evtCode = isGranted ? EvtCode.DoorAccessGranted : EvtCode.DoorAccessDenied; + var nowMillis = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var evt = new Evt + { + EvtCode = evtCode, + HwTime = new DateTimeData { Millis = nowMillis }, + DbTime = new DateTimeData { Millis = nowMillis }, + Consumed = false, + Priority = 0, + EvtDevRef = new EvtDevRef + { + Unid = config.Unid, + Name = config.Name, + DevType = DevType.CredReader + } + }; + + // Only set EvtSubCode for denied events (access granted events don't have a sub-code) + if (!isGranted) + { + evt.EvtSubCode = subCode; + } + + // Add decoded credential number to EvtCredRef if available + if (credNum.HasValue) + { + evt.EvtCredRef = new EvtCredRef + { + CredNum = SpCoreProtoUtil.ToBigIntegerData(credNum.Value) + }; + if (facilityCode.HasValue) + { + evt.EvtCredRef.FacilityCode = facilityCode.Value; + } + } + else if (!string.IsNullOrEmpty(rawBits)) + { + // Unable to decode - store raw bits in Data field + evt.Data = rawBits; + } + + var message = new SpCoreMessage + { + Type = SpCoreMessage.Types.Type.Evt + }; + message.Evt.Add(evt); + + _logger.LogInformation("Sending {EvtCode} event for reader {Name} (unid={Unid}), credNum={CredNum}, fc={FacilityCode}, rawBits={RawBits}, subCode={SubCode}", + evtCode, config.Name, config.Unid, credNum?.ToString() ?? "(none)", facilityCode?.ToString() ?? "(none)", rawBits ?? "(none)", subCode); + + WriteMessage(message); + } + + /// + /// Sends a TAMPER or TAMPER_NORMAL event to Z9. + /// + public void SendTamperEvent(OsdpReaderConfig config, bool isTamper) + { + if (config == null) + { + _logger.LogWarning("Cannot send tamper event: config is null"); + return; + } + + var evtCode = isTamper ? EvtCode.Tamper : EvtCode.TamperNormal; + var nowMillis = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var evt = new Evt + { + EvtCode = evtCode, + HwTime = new DateTimeData { Millis = nowMillis }, + DbTime = new DateTimeData { Millis = nowMillis }, + Consumed = false, + Priority = 0, + EvtDevRef = new EvtDevRef + { + Unid = config.Unid, + Name = config.Name, + DevType = DevType.CredReader + } + }; + + _z9EvtRepository.Insert(evt).GetAwaiter().GetResult(); + + if (!IsConnected) + { + _logger.LogWarning("Cannot send tamper event: not connected"); + return; + } + + var message = new SpCoreMessage { Type = SpCoreMessage.Types.Type.Evt }; + message.Evt.Add(evt); + + _logger.LogInformation("Sending {EvtCode} event for reader {Name} (unid={Unid})", + evtCode, config.Name, config.Unid); + + WriteMessage(message); + } + + /// + /// Sends a CRED_READER_POWER_CYCLE event to Z9. + /// + public void SendCredReaderPowerCycleEvent(OsdpReaderConfig config) + { + if (config == null) + { + _logger.LogWarning("Cannot send power cycle event: config is null"); + return; + } + + var nowMillis = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var evt = new Evt + { + EvtCode = EvtCode.CredReaderPowerCycle, + HwTime = new DateTimeData { Millis = nowMillis }, + DbTime = new DateTimeData { Millis = nowMillis }, + Consumed = false, + Priority = 0, + EvtDevRef = new EvtDevRef + { + Unid = config.Unid, + Name = config.Name, + DevType = DevType.CredReader + } + }; + + _z9EvtRepository.Insert(evt).GetAwaiter().GetResult(); + + if (!IsConnected) + { + _logger.LogWarning("Cannot send power cycle event: not connected"); + return; + } + + var message = new SpCoreMessage { Type = SpCoreMessage.Types.Type.Evt }; + message.Evt.Add(evt); + + _logger.LogInformation("Sending CRED_READER_POWER_CYCLE event for reader {Name} (unid={Unid})", + config.Name, config.Unid); + + WriteMessage(message); + } + + /// + /// Sends a door state event (unlocked/locked/opened/closed) to Z9. + /// + /// The OSDP reader configuration (to look up door info). + /// The event code (DoorUnlocked, DoorLocked, DoorOpened, DoorClosed). + public void SendDoorStateEvent(OsdpReaderConfig config, EvtCode evtCode) + { + if (config == null) + { + _logger.LogWarning("Cannot send door state event: config is null"); + return; + } + + if (!config.DoorUnid.HasValue) + { + _logger.LogWarning("Cannot send door state event: reader {Name} has no DoorUnid", config.Name); + return; + } + + var doorUnid = config.DoorUnid.Value; + string doorName; + lock (_doorInfo) + { + if (!_doorInfo.TryGetValue(doorUnid, out doorName)) + { + doorName = $"Door {doorUnid}"; + } + } + + var nowMillis = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var evt = new Evt + { + EvtCode = evtCode, + HwTime = new DateTimeData { Millis = nowMillis }, + DbTime = new DateTimeData { Millis = nowMillis }, + Consumed = false, + Priority = 0, + EvtDevRef = new EvtDevRef + { + Unid = doorUnid, + Name = doorName, + DevType = DevType.Door + } + }; + + _z9EvtRepository.Insert(evt).GetAwaiter().GetResult(); + + if (!IsConnected) + { + _logger.LogWarning("Cannot send door state event: not connected"); + return; + } + + var message = new SpCoreMessage + { + Type = SpCoreMessage.Types.Type.Evt + }; + message.Evt.Add(evt); + + _logger.LogInformation("Sending {EvtCode} event for door {DoorName} (unid={DoorUnid})", + evtCode, doorName, doorUnid); + + WriteMessage(message); + } + + /// + /// Sends a SCHED_ACTIVE or SCHED_INACTIVE event. + /// + private void SendSchedEvent(Sched sched, bool isActive) + { + var evtCode = isActive ? EvtCode.SchedActive : EvtCode.SchedInactive; + var nowMillis = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var evt = new Evt + { + EvtCode = evtCode, + HwTime = new DateTimeData { Millis = nowMillis }, + DbTime = new DateTimeData { Millis = nowMillis }, + Consumed = false, + Priority = 0, + EvtSchedRef = new EvtSchedRef + { + Unid = sched.Unid, + Name = sched.Name ?? $"Schedule {sched.Unid}", + Invert = false + } + }; + + _z9EvtRepository.Insert(evt).GetAwaiter().GetResult(); + + if (!IsConnected) + { + _logger.LogDebug("Schedule event persisted but not sent (not connected): {EvtCode} for schedule {Name} (unid={Unid})", + evtCode, sched.Name, sched.Unid); + return; + } + + var message = new SpCoreMessage { Type = SpCoreMessage.Types.Type.Evt }; + message.Evt.Add(evt); + + _logger.LogInformation("Sending {EvtCode} event for schedule {Name} (unid={Unid})", + evtCode, sched.Name, sched.Unid); + + WriteMessage(message); + } + + /// + /// Starts the per-minute schedule evaluation timer, aligned to the top of the next minute. + /// + public void StartScheduleEvaluation() + { + if (_schedTimer != null) + return; + + var now = DateTime.Now; + var nextMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0).AddMinutes(1); + var delayMs = (long)(nextMinute - now).TotalMilliseconds; + if (delayMs < 0) + delayMs = 0; + + _logger.LogInformation("Starting schedule evaluation timer (first tick in {DelayMs}ms)", delayMs); + + _schedTimer = new Timer(_ => EvaluateSchedules(), null, delayMs, 60_000); + } + + /// + /// Stops the schedule evaluation timer. + /// + public void StopScheduleEvaluation() + { + var timer = _schedTimer; + if (timer != null) + { + _schedTimer = null; + timer.Dispose(); + _logger.LogInformation("Schedule evaluation timer stopped"); + } + } + + private void EvaluateSchedules() + { + try + { + var schedules = _schedRepository.GetAll().GetAwaiter().GetResult(); + var holidays = _holRepository.GetAll().GetAwaiter().GetResult(); + var now = DateTime.Now; + + foreach (var sched in schedules) + { + var isActive = SchedEvaluator.InSched(now, sched, holidays); + + if (_schedActiveState.TryGetValue(sched.Unid, out var wasActive)) + { + // State exists — only fire on transition + if (isActive != wasActive) + { + _schedActiveState[sched.Unid] = isActive; + SendSchedEvent(sched, isActive); + } + } + else + { + // First evaluation — store state, fire only if active + _schedActiveState[sched.Unid] = isActive; + if (isActive) + { + SendSchedEvent(sched, true); + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error evaluating schedules"); + } + } + + private void WriteMessage(SpCoreMessage message) + { + lock (_writeLock) + { + _mos?.Write(message); + } + } + + private void HandleDevStateRecordControl(DevStateRecordControl control) + { + if (control == null) return; + + var flowControl = control.DevStateRecordFlowControl; + _logger.LogInformation("DevStateRecordControl: {FlowControl}", flowControl); + + switch (flowControl) + { + case DevStateRecordFlowControl.SendOneBatch: + SendAllDevStateRecords(); + break; + case DevStateRecordFlowControl.StartContinuous: + _continuousDevStateRecord = true; + SendAllDevStateRecords(); + break; + case DevStateRecordFlowControl.StopContinuous: + _continuousDevStateRecord = false; + break; + } + } + + private void SendAllDevStateRecords(bool alwaysRespond = true) + { + var records = _devStateService.GetAllDevStateRecords(); + if (!alwaysRespond && records.Count == 0) return; + + var message = new SpCoreMessage { Type = SpCoreMessage.Types.Type.DevStateRecord }; + message.DevStateRecord.AddRange(records); + + _logger.LogInformation("Sending {Count} DevStateRecords", records.Count); + WriteMessage(message); + } + + private void OnDevAspectStateChanged(object sender, DevAspectStateChangedEventArgs e) + { + if (!_continuousDevStateRecord || !IsConnected) return; + + var record = _devStateService.GetDevStateRecord(e.DevUnid); + if (record == null) return; + + var message = new SpCoreMessage { Type = SpCoreMessage.Types.Type.DevStateRecord }; + message.DevStateRecord.Add(record); + WriteMessage(message); + } +} diff --git a/src/Aporta.Extensions/Hardware/AccessCredentialReceivedEventArgs.cs b/src/Aporta.Extensions/Hardware/AccessCredentialReceivedEventArgs.cs index 5bf6088..d451dad 100644 --- a/src/Aporta.Extensions/Hardware/AccessCredentialReceivedEventArgs.cs +++ b/src/Aporta.Extensions/Hardware/AccessCredentialReceivedEventArgs.cs @@ -17,6 +17,15 @@ public AccessCredentialReceivedEventArgs(IAccess access, ICredentialReceivedHand Handler = handler; } + /// + /// Represents the event arguments for when an access credential is received with PIN data. + /// + public AccessCredentialReceivedEventArgs(IAccess access, ICredentialReceivedHandler handler, string pinData) + : this(access, handler) + { + PinData = pinData; + } + /// /// The access hardware that handled the card read. /// @@ -26,6 +35,11 @@ public AccessCredentialReceivedEventArgs(IAccess access, ICredentialReceivedHand /// Implementation of an interface that determines if access should be granted. /// public ICredentialReceivedHandler Handler { get; } + + /// + /// PIN data entered via keypad, or null if no keypad entry. + /// + public string PinData { get; } } /// diff --git a/src/Aporta.Extensions/Hardware/IHardwareDriver.cs b/src/Aporta.Extensions/Hardware/IHardwareDriver.cs index 3279f81..aa37e83 100644 --- a/src/Aporta.Extensions/Hardware/IHardwareDriver.cs +++ b/src/Aporta.Extensions/Hardware/IHardwareDriver.cs @@ -56,4 +56,6 @@ public interface IHardwareDriver : IExtension public event EventHandler StateChanged; public event EventHandler OnlineStatusChanged; + + public event EventHandler LocalStatusChanged; } \ No newline at end of file diff --git a/src/Aporta.Extensions/Hardware/LocalStatusChangedEventArgs.cs b/src/Aporta.Extensions/Hardware/LocalStatusChangedEventArgs.cs new file mode 100644 index 0000000..52123fc --- /dev/null +++ b/src/Aporta.Extensions/Hardware/LocalStatusChangedEventArgs.cs @@ -0,0 +1,18 @@ +using System; +using Aporta.Extensions.Endpoint; + +namespace Aporta.Extensions.Hardware; + +public class LocalStatusChangedEventArgs : EventArgs +{ + public IEndpoint Endpoint { get; } + public bool? TamperState { get; } + public bool PowerCycleDetected { get; } + + public LocalStatusChangedEventArgs(IEndpoint endpoint, bool? tamperState, bool powerCycleDetected) + { + Endpoint = endpoint; + TamperState = tamperState; + PowerCycleDetected = powerCycleDetected; + } +} diff --git a/src/Aporta.Extensions/Hardware/OnlineStatusChangedEventArgs.cs b/src/Aporta.Extensions/Hardware/OnlineStatusChangedEventArgs.cs index f044726..e4b9fb1 100644 --- a/src/Aporta.Extensions/Hardware/OnlineStatusChangedEventArgs.cs +++ b/src/Aporta.Extensions/Hardware/OnlineStatusChangedEventArgs.cs @@ -3,10 +3,15 @@ namespace Aporta.Extensions.Hardware; -// ReSharper disable once ClassNeverInstantiated.Global public class OnlineStatusChangedEventArgs { public IEndpoint Endpoint { get; } - + public bool IsOnline { get; } + + public OnlineStatusChangedEventArgs(IEndpoint endpoint, bool isOnline) + { + Endpoint = endpoint; + IsOnline = isOnline; + } } \ No newline at end of file diff --git a/src/Aporta.Migration/Aporta.Migration.csproj b/src/Aporta.Migration/Aporta.Migration.csproj new file mode 100644 index 0000000..1e69ed3 --- /dev/null +++ b/src/Aporta.Migration/Aporta.Migration.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + diff --git a/src/Aporta.Migration/Program.cs b/src/Aporta.Migration/Program.cs new file mode 100644 index 0000000..fc3ee78 --- /dev/null +++ b/src/Aporta.Migration/Program.cs @@ -0,0 +1,639 @@ +using System.Data; +using System.Text.Json; +using Dapper; +using Google.Protobuf; +using Microsoft.Data.Sqlite; +using Z9.Spcore.Proto; + +namespace Aporta.Migration; + +class Program +{ + static async Task Main(string[] args) + { + if (args.Length == 0) + { + Console.WriteLine("Aporta Database Migration Tool"); + Console.WriteLine("Migrates from column-based schema to JSON document storage."); + Console.WriteLine(); + Console.WriteLine("Usage: Aporta.Migration [--dry-run]"); + Console.WriteLine(); + Console.WriteLine("Options:"); + Console.WriteLine(" --dry-run Show what would be done without making changes"); + return 1; + } + + var dbPath = args[0]; + var dryRun = args.Contains("--dry-run"); + + if (!File.Exists(dbPath)) + { + Console.WriteLine($"Error: Database file not found: {dbPath}"); + return 1; + } + + // Create backup + var backupPath = $"{dbPath}.backup-{DateTime.Now:yyyyMMdd-HHmmss}"; + if (!dryRun) + { + Console.WriteLine($"Creating backup: {backupPath}"); + File.Copy(dbPath, backupPath); + } + + try + { + var connectionString = $"Data Source={dbPath}"; + await using var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(); + + var migrator = new Migrator(connection, dryRun); + await migrator.MigrateAsync(); + + Console.WriteLine(); + Console.WriteLine(dryRun ? "Dry run complete. No changes made." : "Migration complete!"); + return 0; + } + catch (Exception ex) + { + Console.WriteLine($"Error during migration: {ex.Message}"); + Console.WriteLine(); + if (!dryRun) + { + Console.WriteLine($"Restoring from backup: {backupPath}"); + File.Copy(backupPath, dbPath, overwrite: true); + } + return 1; + } + } +} + +public class Migrator +{ + private const int OldSchemaVersion = 10; + + private readonly IDbConnection _connection; + private readonly bool _dryRun; + + public Migrator(IDbConnection connection, bool dryRun) + { + _connection = connection; + _dryRun = dryRun; + } + + public async Task MigrateAsync() + { + var currentVersion = await GetCurrentVersionAsync(); + Console.WriteLine($"Current schema version: {currentVersion}"); + + if (currentVersion == -1) + { + Console.WriteLine("Error: No schema_info table found. Is this an Aporta database?"); + throw new Exception("Invalid database"); + } + + // Check if already migrated to JSON schema + if (await IsJsonSchemaAsync()) + { + Console.WriteLine("Database is already using JSON schema. Nothing to do."); + return; + } + + // Step 1: Apply old migrations to get to version 10 + if (currentVersion < OldSchemaVersion) + { + Console.WriteLine($"Applying old migrations to reach version {OldSchemaVersion}..."); + await ApplyOldMigrationsAsync(currentVersion); + } + + // Step 2: Transform to JSON schema + Console.WriteLine("Transforming to JSON document storage..."); + await TransformToJsonSchemaAsync(); + } + + private async Task GetCurrentVersionAsync() + { + var tableExists = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_info'"); + + if (tableExists == 0) return -1; + + return await _connection.QueryFirstOrDefaultAsync( + "SELECT COALESCE(MAX(id), -1) FROM schema_info"); + } + + private async Task IsJsonSchemaAsync() + { + // Check if person table has 'data' column (JSON schema) vs 'first_name' column (old schema) + var columns = await _connection.QueryAsync( + "SELECT name FROM pragma_table_info('person')"); + return columns.Contains("data"); + } + + private async Task ApplyOldMigrationsAsync(int currentVersion) + { + var migrations = GetOldMigrations(); + + foreach (var (version, name, sql) in migrations.Where(m => m.Version > currentVersion)) + { + Console.WriteLine($" Applying migration {version}: {name}"); + if (!_dryRun) + { + using var transaction = _connection.BeginTransaction(); + await _connection.ExecuteAsync(sql, transaction: transaction); + await _connection.ExecuteAsync( + "INSERT INTO schema_info (id, name, timestamp) VALUES (@id, @name, @timestamp)", + new { id = version, name, timestamp = DateTime.UtcNow }, + transaction); + transaction.Commit(); + } + } + } + + private async Task TransformToJsonSchemaAsync() + { + using var transaction = _dryRun ? null : _connection.BeginTransaction(); + + // Transform each table + await TransformExtensionTableAsync(transaction); + await TransformEndpointTableAsync(transaction); + await TransformOutputTableAsync(transaction); + await TransformInputTableAsync(transaction); + await TransformDoorTableAsync(transaction); + await TransformGlobalSettingTableAsync(transaction); + await TransformPersonTableAsync(transaction); + await TransformCredentialTableAsync(transaction); + await TransformCredentialAssignmentTableAsync(transaction); + await TransformEventTableAsync(transaction); + + // Update schema_info + if (!_dryRun) + { + // Clear old schema records and insert new (version 100 = JSON schema) + await _connection.ExecuteAsync("DELETE FROM schema_info", transaction: transaction); + await _connection.ExecuteAsync( + "INSERT INTO schema_info (id, name, timestamp) VALUES (@id, @name, @timestamp)", + new { id = 100, name = "Initial create with JSON document storage", timestamp = DateTime.UtcNow }, + transaction: transaction); + } + + transaction?.Commit(); + } + + private async Task TransformExtensionTableAsync(IDbTransaction? transaction) + { + Console.WriteLine(" Transforming extension table..."); + if (_dryRun) return; + + // extension: id (text), name, enabled, configuration -> id (text), data (json) + var oldData = await _connection.QueryAsync<(string Id, string Name, int Enabled, string Configuration)>( + "SELECT id, name, enabled, configuration FROM extension", transaction: transaction); + + await _connection.ExecuteAsync( + @"CREATE TABLE extension_new (id TEXT NOT NULL PRIMARY KEY, data TEXT NOT NULL)", + transaction: transaction); + + foreach (var row in oldData) + { + var json = JsonSerializer.Serialize(new + { + name = row.Name, + enabled = row.Enabled != 0, + configuration = row.Configuration + }); + await _connection.ExecuteAsync( + "INSERT INTO extension_new (id, data) VALUES (@id, @data)", + new { id = row.Id, data = json }, + transaction: transaction); + } + + await _connection.ExecuteAsync("DROP TABLE extension", transaction: transaction); + await _connection.ExecuteAsync("ALTER TABLE extension_new RENAME TO extension", transaction: transaction); + } + + private async Task TransformEndpointTableAsync(IDbTransaction? transaction) + { + Console.WriteLine(" Transforming endpoint table..."); + if (_dryRun) return; + + var oldData = await _connection.QueryAsync<(int Id, string Name, string DriverId, int EndpointType, string ExtensionId)>( + "SELECT id, name, driver_id, endpoint_type, extension_id FROM endpoint", transaction: transaction); + + await _connection.ExecuteAsync( + @"CREATE TABLE endpoint_new (id INTEGER NOT NULL PRIMARY KEY, data TEXT NOT NULL)", + transaction: transaction); + + foreach (var row in oldData) + { + var json = JsonSerializer.Serialize(new + { + name = row.Name, + driverEndpointId = row.DriverId, + type = row.EndpointType, + extensionId = row.ExtensionId + }); + await _connection.ExecuteAsync( + "INSERT INTO endpoint_new (id, data) VALUES (@id, @data)", + new { id = row.Id, data = json }, + transaction: transaction); + } + + await _connection.ExecuteAsync("DROP TABLE endpoint", transaction: transaction); + await _connection.ExecuteAsync("ALTER TABLE endpoint_new RENAME TO endpoint", transaction: transaction); + } + + private async Task TransformOutputTableAsync(IDbTransaction? transaction) + { + Console.WriteLine(" Transforming output table..."); + if (_dryRun) return; + + var oldData = await _connection.QueryAsync<(int Id, int EndpointId, string Name)>( + "SELECT id, endpoint_id, name FROM output", transaction: transaction); + + await _connection.ExecuteAsync( + @"CREATE TABLE output_new (id INTEGER NOT NULL PRIMARY KEY, data TEXT NOT NULL)", + transaction: transaction); + + foreach (var row in oldData) + { + var json = JsonSerializer.Serialize(new + { + endpointId = row.EndpointId, + name = row.Name + }); + await _connection.ExecuteAsync( + "INSERT INTO output_new (id, data) VALUES (@id, @data)", + new { id = row.Id, data = json }, + transaction: transaction); + } + + await _connection.ExecuteAsync("DROP TABLE output", transaction: transaction); + await _connection.ExecuteAsync("ALTER TABLE output_new RENAME TO output", transaction: transaction); + } + + private async Task TransformInputTableAsync(IDbTransaction? transaction) + { + Console.WriteLine(" Transforming input table..."); + if (_dryRun) return; + + var oldData = await _connection.QueryAsync<(int Id, int EndpointId, string Name)>( + "SELECT id, endpoint_id, name FROM input", transaction: transaction); + + await _connection.ExecuteAsync( + @"CREATE TABLE input_new (id INTEGER NOT NULL PRIMARY KEY, data TEXT NOT NULL)", + transaction: transaction); + + foreach (var row in oldData) + { + var json = JsonSerializer.Serialize(new + { + endpointId = row.EndpointId, + name = row.Name + }); + await _connection.ExecuteAsync( + "INSERT INTO input_new (id, data) VALUES (@id, @data)", + new { id = row.Id, data = json }, + transaction: transaction); + } + + await _connection.ExecuteAsync("DROP TABLE input", transaction: transaction); + await _connection.ExecuteAsync("ALTER TABLE input_new RENAME TO input", transaction: transaction); + } + + private async Task TransformDoorTableAsync(IDbTransaction? transaction) + { + Console.WriteLine(" Transforming door table..."); + if (_dryRun) return; + + var oldData = await _connection.QueryAsync<(int Id, int? InAccessEndpointId, int? OutAccessEndpointId, + int? DoorContactEndpointId, int? RequestToExitEndpointId, int? DoorStrikeEndpointId, string Name)>( + @"SELECT id, in_access_endpoint_id, out_access_endpoint_id, door_contact_endpoint_id, + request_to_exit_endpoint_id, door_strike_endpoint_id, name FROM door", transaction: transaction); + + await _connection.ExecuteAsync( + @"CREATE TABLE door_new (id INTEGER NOT NULL PRIMARY KEY, data TEXT NOT NULL)", + transaction: transaction); + + foreach (var row in oldData) + { + var json = JsonSerializer.Serialize(new + { + name = row.Name, + inAccessEndpointId = row.InAccessEndpointId, + outAccessEndpointId = row.OutAccessEndpointId, + doorContactEndpointId = row.DoorContactEndpointId, + requestToExitEndpointId = row.RequestToExitEndpointId, + doorStrikeEndpointId = row.DoorStrikeEndpointId + }); + await _connection.ExecuteAsync( + "INSERT INTO door_new (id, data) VALUES (@id, @data)", + new { id = row.Id, data = json }, + transaction: transaction); + } + + await _connection.ExecuteAsync("DROP TABLE door", transaction: transaction); + await _connection.ExecuteAsync("ALTER TABLE door_new RENAME TO door", transaction: transaction); + } + + private async Task TransformGlobalSettingTableAsync(IDbTransaction? transaction) + { + Console.WriteLine(" Transforming global_setting table..."); + if (_dryRun) return; + + var oldData = await _connection.QueryAsync<(string Name, string Value)>( + "SELECT name, value FROM global_setting", transaction: transaction); + + await _connection.ExecuteAsync( + @"CREATE TABLE global_setting_new (name TEXT NOT NULL PRIMARY KEY, data TEXT NOT NULL)", + transaction: transaction); + + foreach (var row in oldData) + { + var json = JsonSerializer.Serialize(new { value = row.Value }); + await _connection.ExecuteAsync( + "INSERT INTO global_setting_new (name, data) VALUES (@name, @data)", + new { name = row.Name, data = json }, + transaction: transaction); + } + + await _connection.ExecuteAsync("DROP TABLE global_setting", transaction: transaction); + await _connection.ExecuteAsync("ALTER TABLE global_setting_new RENAME TO global_setting", transaction: transaction); + } + + private async Task TransformPersonTableAsync(IDbTransaction? transaction) + { + Console.WriteLine(" Transforming person table..."); + if (_dryRun) return; + + var oldData = await _connection.QueryAsync<(int Id, string? FirstName, string? LastName, int Enabled)>( + "SELECT id, first_name, last_name, enabled FROM person", transaction: transaction); + + await _connection.ExecuteAsync( + @"CREATE TABLE person_new (id INTEGER NOT NULL PRIMARY KEY, data TEXT NOT NULL)", + transaction: transaction); + + foreach (var row in oldData) + { + var json = JsonSerializer.Serialize(new + { + firstName = row.FirstName, + lastName = row.LastName, + enabled = row.Enabled != 0 + }); + await _connection.ExecuteAsync( + "INSERT INTO person_new (id, data) VALUES (@id, @data)", + new { id = row.Id, data = json }, + transaction: transaction); + } + + await _connection.ExecuteAsync("DROP TABLE person", transaction: transaction); + await _connection.ExecuteAsync("ALTER TABLE person_new RENAME TO person", transaction: transaction); + } + + private async Task TransformCredentialTableAsync(IDbTransaction? transaction) + { + Console.WriteLine(" Transforming credential table..."); + if (_dryRun) return; + + var oldData = await _connection.QueryAsync<(int Id, string Number, int? LastEvent)>( + "SELECT id, number, last_event FROM credential", transaction: transaction); + + await _connection.ExecuteAsync( + @"CREATE TABLE credential_new (id INTEGER NOT NULL PRIMARY KEY, data TEXT NOT NULL)", + transaction: transaction); + + foreach (var row in oldData) + { + var json = JsonSerializer.Serialize(new + { + number = row.Number, + lastEvent = row.LastEvent + }); + await _connection.ExecuteAsync( + "INSERT INTO credential_new (id, data) VALUES (@id, @data)", + new { id = row.Id, data = json }, + transaction: transaction); + } + + await _connection.ExecuteAsync("DROP TABLE credential", transaction: transaction); + await _connection.ExecuteAsync("ALTER TABLE credential_new RENAME TO credential", transaction: transaction); + await _connection.ExecuteAsync( + "CREATE UNIQUE INDEX credential_number_uindex ON credential (json_extract(data, '$.number'))", + transaction: transaction); + } + + private async Task TransformCredentialAssignmentTableAsync(IDbTransaction? transaction) + { + Console.WriteLine(" Transforming credential_assignment table..."); + if (_dryRun) return; + + var oldData = await _connection.QueryAsync<(int PersonId, int CredentialId, int Enabled)>( + "SELECT person_id, credential_id, enabled FROM credential_assignment", transaction: transaction); + + await _connection.ExecuteAsync( + @"CREATE TABLE credential_assignment_new (id INTEGER NOT NULL PRIMARY KEY, data TEXT NOT NULL)", + transaction: transaction); + + var id = 1; + foreach (var row in oldData) + { + var json = JsonSerializer.Serialize(new + { + credentialId = row.CredentialId, + personId = row.PersonId, + enabled = row.Enabled + }); + await _connection.ExecuteAsync( + "INSERT INTO credential_assignment_new (id, data) VALUES (@id, @data)", + new { id = id++, data = json }, + transaction: transaction); + } + + await _connection.ExecuteAsync("DROP TABLE credential_assignment", transaction: transaction); + await _connection.ExecuteAsync("ALTER TABLE credential_assignment_new RENAME TO credential_assignment", transaction: transaction); + await _connection.ExecuteAsync( + "CREATE INDEX idx_ca_credential ON credential_assignment (json_extract(data, '$.credentialId'))", + transaction: transaction); + await _connection.ExecuteAsync( + "CREATE INDEX idx_ca_person ON credential_assignment (json_extract(data, '$.personId'))", + transaction: transaction); + } + + private static readonly JsonFormatter ProtoFormatter = new(JsonFormatter.Settings.Default); + + private async Task TransformEventTableAsync(IDbTransaction? transaction) + { + Console.WriteLine(" Migrating event table to z9_evt..."); + if (_dryRun) return; + + var oldData = await _connection.QueryAsync<(int Id, int EndpointId, DateTime Timestamp, int EventType, string Data)>( + "SELECT id, endpoint_id, timestamp, event_type, data FROM event", transaction: transaction); + + await _connection.ExecuteAsync( + @"CREATE TABLE z9_evt (id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, data TEXT NOT NULL)", + transaction: transaction); + + foreach (var row in oldData) + { + var millis = new DateTimeOffset(row.Timestamp, TimeSpan.Zero).ToUnixTimeMilliseconds(); + var isGranted = row.EventType == 0; // EventType.AccessGranted = 0 + + var evt = new Evt + { + EvtCode = isGranted ? EvtCode.DoorAccessGranted : EvtCode.DoorAccessDenied, + HwTime = new DateTimeData { Millis = millis }, + DbTime = new DateTimeData { Millis = millis }, + Consumed = false, + Priority = 0, + Data = row.Data + }; + + // Parse EventData to get EventReason for denied events + if (!isGranted && !string.IsNullOrEmpty(row.Data)) + { + try + { + using var doc = JsonDocument.Parse(row.Data); + if (doc.RootElement.TryGetProperty("EventReason", out var reasonProp) || + doc.RootElement.TryGetProperty("eventReason", out reasonProp)) + { + var reasonInt = reasonProp.GetInt32(); + var subCode = reasonInt switch + { + 4 => EvtSubCode.AccessDeniedUnknownCredNum, // CredentialNotEnrolled + 11 => EvtSubCode.AccessDeniedUnknownCredNumFormat, // NoCredentialTemplate + 5 => EvtSubCode.AccessDeniedInactive, // CredentialDisabled + 6 => EvtSubCode.AccessDeniedNotEffective, // CredentialNotYetEffective + 7 => EvtSubCode.AccessDeniedExpired, // CredentialExpired + 8 => EvtSubCode.AccessDeniedNoPriv, // NoPrivilege + 3 => EvtSubCode.AccessDeniedNoPriv, // AccessNotAssigned + 9 => EvtSubCode.AccessDeniedOutsideSched, // OutsideSchedule + 10 => EvtSubCode.AccessDeniedDoorModeStaticLocked, // DoorLocked + _ => (EvtSubCode?)null + }; + if (subCode.HasValue) + evt.EvtSubCode = subCode.Value; + } + } + catch (JsonException) + { + // Old data may not be parseable — leave without sub-code + } + } + + var json = ProtoFormatter.Format(evt); + await _connection.ExecuteAsync( + "INSERT INTO z9_evt (id, data) VALUES (@id, @data)", + new { id = row.Id, data = json }, + transaction: transaction); + } + + await _connection.ExecuteAsync("DROP TABLE event", transaction: transaction); + } + + private static List<(int Version, string Name, string Sql)> GetOldMigrations() + { + return new List<(int, string, string)> + { + (1, "Add extension table", @" + CREATE TABLE extension ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + enabled INTEGER DEFAULT 0 NOT NULL, + configuration TEXT NOT NULL + ); + CREATE UNIQUE INDEX extension_id_uindex ON extension (id);"), + + (2, "Add endpoint table", @" + CREATE TABLE endpoint ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + driver_id TEXT NOT NULL, + endpoint_type INTEGER NOT NULL, + extension_id TEXT NOT NULL REFERENCES extension ON UPDATE CASCADE ON DELETE CASCADE + ); + CREATE UNIQUE INDEX endpoint_id_uindex ON endpoint (id);"), + + (3, "Add output table", @" + CREATE TABLE output ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + endpoint_id INTEGER NOT NULL REFERENCES endpoint ON UPDATE CASCADE ON DELETE CASCADE, + name TEXT NOT NULL + ); + CREATE UNIQUE INDEX output_id_uindex ON output (id);"), + + (4, "Add input table", @" + CREATE TABLE input ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + endpoint_id INTEGER NOT NULL REFERENCES endpoint ON UPDATE CASCADE ON DELETE CASCADE, + name TEXT NOT NULL + ); + CREATE UNIQUE INDEX input_id_uindex ON input (id);"), + + (5, "Add door table", @" + CREATE TABLE door ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + in_access_endpoint_id INTEGER REFERENCES endpoint ON UPDATE CASCADE ON DELETE CASCADE, + out_access_endpoint_id INTEGER REFERENCES endpoint ON UPDATE CASCADE ON DELETE CASCADE, + door_contact_endpoint_id INTEGER REFERENCES endpoint ON UPDATE CASCADE ON DELETE CASCADE, + request_to_exit_endpoint_id INTEGER REFERENCES endpoint ON UPDATE CASCADE ON DELETE CASCADE, + door_strike_endpoint_id INTEGER REFERENCES endpoint ON UPDATE CASCADE ON DELETE CASCADE, + name TEXT NOT NULL + ); + CREATE UNIQUE INDEX door_id_uindex ON door (id);"), + + (6, "Add global_setting table", @" + CREATE TABLE global_setting ( + name TEXT NOT NULL PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE UNIQUE INDEX global_setting_name_uindex ON global_setting (name);"), + + (7, "Add credential table", @" + CREATE TABLE credential ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + number TEXT NOT NULL, + enroll_date DATETIME NOT NULL + ); + CREATE UNIQUE INDEX credential_id_uindex ON credential (id);"), + + (8, "Add person table", @" + CREATE TABLE person ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + first_name TEXT, + last_name TEXT, + enabled INTEGER DEFAULT 0 NOT NULL + ); + CREATE UNIQUE INDEX person_id_uindex ON person (id); + + CREATE TABLE credential_assignment ( + person_id INTEGER NOT NULL REFERENCES person, + credential_id INTEGER NOT NULL PRIMARY KEY REFERENCES credential, + enabled INTEGER DEFAULT 0 NOT NULL + ); + CREATE UNIQUE INDEX credential_assignment_credential_id_uindex ON credential_assignment (credential_id);"), + + (9, "Add event table", @" + CREATE TABLE event ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + endpoint_id INTEGER NOT NULL REFERENCES endpoint, + timestamp DATETIME NOT NULL, + event_type INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE UNIQUE INDEX event_id_uindex ON event (id); + CREATE UNIQUE INDEX credential_number_uindex ON credential (number);"), + + (10, "Add last_event to credential table", @" + CREATE TABLE credential_dg_tmp ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + number TEXT NOT NULL, + last_event INTEGER + ); + INSERT INTO credential_dg_tmp(id, number) SELECT id, number FROM credential; + DROP TABLE credential; + ALTER TABLE credential_dg_tmp RENAME TO credential; + CREATE UNIQUE INDEX credential_id_uindex ON credential (id); + CREATE UNIQUE INDEX credential_number_uindex ON credential (number);") + }; + } +} diff --git a/src/Aporta.Shared/Calls/DoorCalls.cs b/src/Aporta.Shared/Calls/DoorCalls.cs deleted file mode 100644 index 81b2158..0000000 --- a/src/Aporta.Shared/Calls/DoorCalls.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Net.Http; -using System.Net.Http.Json; -using System.Threading.Tasks; -using Aporta.Shared.Models; - -namespace Aporta.Shared.Calls -{ - /// - public class DoorCalls(HttpClient httpClient) : IDoorCalls - { - - /// - public async Task GetAvailableEndpoints() - { - return await httpClient.GetFromJsonAsync($"{Paths.Doors}/endpointsavailable"); - } - - /// - public async Task GetAvailableAccessPoints() - { - return await httpClient.GetFromJsonAsync($"{Paths.Doors}/available"); - } - - } - - /// - /// Represents a class that handles Door API calls. - /// - public interface IDoorCalls - { - /// - /// Return all endpoints available to be assigned to a door - /// - public Task GetAvailableEndpoints(); - - /// - /// Return all access points available to be assigned to a door - /// - public Task GetAvailableAccessPoints(); - } - -} diff --git a/src/Aporta.Shared/Calls/FlexApiService.cs b/src/Aporta.Shared/Calls/FlexApiService.cs new file mode 100644 index 0000000..72af36d --- /dev/null +++ b/src/Aporta.Shared/Calls/FlexApiService.cs @@ -0,0 +1,309 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using Aporta.Shared.Models; +using Aporta.Shared.Models.Flex; + +namespace Aporta.Shared.Calls; + +public class FlexApiService +{ + private readonly HttpClient _httpClient; + private string _sessionToken; + private string _username; + private string _password; + + public FlexApiService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public bool IsAuthenticated => !string.IsNullOrEmpty(_sessionToken); + + public async Task LoginAsync(string username, string password) + { + var request = new FlexAuthenticateRequest { Username = username, Password = password }; + var response = await _httpClient.PostAsJsonAsync("authenticate", request); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(); + if (result?.Authenticated == true) + { + _sessionToken = result.SessionToken; + _username = username; + _password = password; + } + return result; + } + + public void Logout() + { + _sessionToken = null; + _username = null; + _password = null; + } + + public async Task> GetEvtListAsync(int offset, int max) + { + return await FlexGetAsync>($"evt/list?offset={offset}&max={max}"); + } + + public async Task> GetCredListAsync(int offset, int max) + { + return await FlexGetAsync>($"cred/list?offset={offset}&max={max}"); + } + + public async Task> GetSchedListAsync(int offset, int max) + { + return await FlexGetAsync>($"sched/list?offset={offset}&max={max}"); + } + + public async Task> SaveSchedAsync(FlexSched sched) + { + return await FlexPostAsync>("sched/save", sched); + } + + public async Task DeleteSchedAsync(int unid) + { + return await FlexPostAsync($"sched/delete/{unid}"); + } + + public async Task> GetBinaryFormatListAsync(int offset, int max) + { + return await FlexGetAsync>($"binaryFormat/list?offset={offset}&max={max}"); + } + + public async Task> SaveDataFormatAsync(FlexDataFormat dataFormat) + { + return await FlexPostAsync>("dataFormat/save", dataFormat); + } + + public async Task DeleteDataFormatAsync(int unid) + { + return await FlexPostAsync($"dataFormat/delete/{unid}"); + } + + public async Task> GetBasicDataLayoutListAsync(int offset, int max) + { + return await FlexGetAsync>($"basicDataLayout/list?offset={offset}&max={max}"); + } + + public async Task> SaveDataLayoutAsync(FlexDataLayout dataLayout) + { + return await FlexPostAsync>("dataLayout/save", dataLayout); + } + + public async Task DeleteDataLayoutAsync(int unid) + { + return await FlexPostAsync($"dataLayout/delete/{unid}"); + } + + public async Task> GetSensorListAsync(int offset, int max) + { + return await FlexGetAsync>($"sensor/list?offset={offset}&max={max}"); + } + + public async Task> GetActuatorListAsync(int offset, int max) + { + return await FlexGetAsync>($"actuator/list?offset={offset}&max={max}"); + } + + private async Task EnsureAuthenticated() + { + if (!string.IsNullOrEmpty(_sessionToken)) + return; + if (string.IsNullOrEmpty(_username)) + throw new InvalidOperationException("Not authenticated"); + + var request = new FlexAuthenticateRequest { Username = _username, Password = _password }; + var response = await _httpClient.PostAsJsonAsync("authenticate", request); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(); + _sessionToken = result?.SessionToken; + } + + public async Task> SaveCredAsync(FlexCred cred) + { + return await FlexPostAsync>("cred/save", cred); + } + + public async Task DeleteCredAsync(int unid) + { + return await FlexPostAsync($"cred/delete/{unid}"); + } + + public async Task EnrollCredAsync(int credentialId, int personId) + { + return await FlexPostAsync($"cred/{credentialId}/enroll/{personId}"); + } + + public async Task> GetRawReadsAsync() + { + return await FlexGetAsync>("evt/raw-reads"); + } + + public async Task EnrollCredFromReadAsync(int personId, int evtId) + { + return await FlexPostAsync($"cred/{personId}/enroll-from-read/{evtId}"); + } + + public async Task> GetDoorListAsync(int offset, int max) + { + return await FlexGetAsync>($"door/list?offset={offset}&max={max}"); + } + + public async Task> GetAvailableCredReadersAsync() + { + return await FlexGetAsync>("door/available/readers"); + } + + public async Task CreateDoorAsync(Door door) + { + return await FlexPostAsync("door/create", door); + } + + public async Task DeleteDoorAsync(int unid) + { + return await FlexPostAsync($"door/delete/{unid}"); + } + + public virtual async Task> GetAvailableEndpointsAsync() + { + return await FlexGetAsync>("door/available/endpoints"); + } + + public async Task> GetAvailableSensorsAsync() + { + return await FlexGetAsync>("sensor/available"); + } + + public async Task> GetAvailableActuatorsAsync() + { + return await FlexGetAsync>("actuator/available"); + } + + public async Task> SaveSensorAsync(FlexDev sensor) + { + return await FlexPostAsync>("sensor/save", sensor); + } + + public async Task> SaveActuatorAsync(FlexDev actuator) + { + return await FlexPostAsync>("actuator/save", actuator); + } + + public async Task DeleteSensorAsync(int unid) + { + return await FlexPostAsync($"sensor/delete/{unid}"); + } + + public async Task DeleteActuatorAsync(int unid) + { + return await FlexPostAsync($"actuator/delete/{unid}"); + } + + public async Task> GetHolListAsync(int offset, int max) + { + return await FlexGetAsync>($"hol/list?offset={offset}&max={max}"); + } + + public async Task> SaveHolAsync(FlexHol hol) + { + return await FlexPostAsync>("hol/save", hol); + } + + public async Task DeleteHolAsync(int unid) + { + return await FlexPostAsync($"hol/delete/{unid}"); + } + + public async Task> GetHolCalListAsync(int offset, int max) + { + return await FlexGetAsync>($"holCal/list?offset={offset}&max={max}"); + } + + public async Task> SaveHolCalAsync(FlexHolCal holCal) + { + return await FlexPostAsync>("holCal/save", holCal); + } + + public async Task GetSensorStateAsync(int id) + { + var json = await FlexGetAsync($"sensor/state/{id}"); + return ParseStateResponse(json); + } + + public async Task GetActuatorStateAsync(int id) + { + var json = await FlexGetAsync($"actuator/state/{id}"); + return ParseStateResponse(json); + } + + public async Task SetActuatorStateAsync(int id, bool state) + { + await FlexPostAsync($"actuator/state/{id}?state={state}"); + } + + private static bool? ParseStateResponse(JsonElement json) + { + if (json.TryGetProperty("state", out var stateProp)) + { + if (stateProp.ValueKind == JsonValueKind.Null) + return null; + return stateProp.GetBoolean(); + } + return null; + } + + private async Task FlexGetAsync(string url) + { + await EnsureAuthenticated(); + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("sessionToken", _sessionToken); + var response = await _httpClient.SendAsync(request); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + // Token expired — re-authenticate and retry once + _sessionToken = null; + await EnsureAuthenticated(); + + request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("sessionToken", _sessionToken); + response = await _httpClient.SendAsync(request); + } + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + private async Task FlexPostAsync(string url, object body = null) + { + await EnsureAuthenticated(); + + var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.Add("sessionToken", _sessionToken); + if (body != null) + request.Content = JsonContent.Create(body); + + var response = await _httpClient.SendAsync(request); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + _sessionToken = null; + await EnsureAuthenticated(); + + request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.Add("sessionToken", _sessionToken); + if (body != null) + request.Content = JsonContent.Create(body); + response = await _httpClient.SendAsync(request); + } + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } +} diff --git a/src/Aporta.Shared/Calls/Paths.cs b/src/Aporta.Shared/Calls/Paths.cs index 1c0910c..4499281 100644 --- a/src/Aporta.Shared/Calls/Paths.cs +++ b/src/Aporta.Shared/Calls/Paths.cs @@ -4,10 +4,4 @@ public static class Paths { public const string Extensions = "api/Extensions"; public const string ExtensionPerformAction = "api/Extensions/{0}/action/{1}"; - public const string Doors = "api/Doors"; - public const string Events = "api/Events"; - public const string Outputs = "api/Outputs"; - public const string Inputs = "api/Inputs"; - public const string People = "/api/People"; - public const string Credentials = "/api/Credentials"; } \ No newline at end of file diff --git a/src/Aporta.Shared/Models/Credential.cs b/src/Aporta.Shared/Models/Credential.cs index 990d435..154d672 100644 --- a/src/Aporta.Shared/Models/Credential.cs +++ b/src/Aporta.Shared/Models/Credential.cs @@ -5,10 +5,6 @@ public class Credential public int Id { get; set; } public string Number { get; set; } - - public int LastEvent { get; set; } - - public int? AssignedPersonId { get; set; } - + public bool? Enabled { get; set; } } \ No newline at end of file diff --git a/src/Aporta.Shared/Models/Event.cs b/src/Aporta.Shared/Models/Event.cs deleted file mode 100644 index 2ca505c..0000000 --- a/src/Aporta.Shared/Models/Event.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; - -namespace Aporta.Shared.Models; - -public class Event -{ - public int Id { get; set; } - - public int EndpointId { get; set; } - - public DateTime Timestamp { get; set; } = DateTime.UtcNow; - - public EventType Type { get; set; } - - public string Data { get; set; } = ""; -} - -public enum EventType -{ - [Description("Access Granted")] - AccessGranted, - - [Description("Access Denied")] - AccessDenied -} \ No newline at end of file diff --git a/src/Aporta.Shared/Models/EventData.cs b/src/Aporta.Shared/Models/EventData.cs index 713fad0..82e6b47 100644 --- a/src/Aporta.Shared/Models/EventData.cs +++ b/src/Aporta.Shared/Models/EventData.cs @@ -14,6 +14,15 @@ public class EventData public string? CardNumber { get; init; } } +public enum EventType +{ + [Description("Access Granted")] + AccessGranted, + + [Description("Access Denied")] + AccessDenied +} + public enum EventReason { [Description("None")] @@ -25,5 +34,25 @@ public enum EventReason [Description("Access Not Assigned")] AccessNotAssigned, [Description("Credential Not Enrolled")] - CredentialNotEnrolled + CredentialNotEnrolled, + [Description("Credential Disabled")] + CredentialDisabled, + [Description("Credential Not Yet Effective")] + CredentialNotYetEffective, + [Description("Credential Expired")] + CredentialExpired, + [Description("No Privilege")] + NoPrivilege, + [Description("Outside Schedule")] + OutsideSchedule, + [Description("Door Locked")] + DoorLocked, + [Description("No Credential Template")] + NoCredentialTemplate, + [Description("Unknown Unique PIN")] + UnknownUniquePin, + [Description("No Confirming PIN")] + NoConfirmingPin, + [Description("Incorrect Confirming PIN")] + IncorrectConfirmingPin } \ No newline at end of file diff --git a/src/Aporta.Shared/Models/Flex/FlexCommon.cs b/src/Aporta.Shared/Models/Flex/FlexCommon.cs new file mode 100644 index 0000000..fa7ad7f --- /dev/null +++ b/src/Aporta.Shared/Models/Flex/FlexCommon.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Aporta.Shared.Models.Flex; + +public class FlexObjRef +{ + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("externalId")] + public string ExternalId { get; set; } +} + +public class FlexListResponse +{ + [JsonPropertyName("offset")] + public int Offset { get; set; } + + [JsonPropertyName("max")] + public int Max { get; set; } + + [JsonPropertyName("count")] + public int Count { get; set; } + + [JsonPropertyName("instanceList")] + public List InstanceList { get; set; } = new(); +} + +public class FlexInstanceResponse +{ + [JsonPropertyName("instance")] + public T Instance { get; set; } +} + +public class FlexVoid +{ +} + +public class FlexAuthenticateRequest +{ + [JsonPropertyName("username")] + public string Username { get; set; } + + [JsonPropertyName("password")] + public string Password { get; set; } + + [JsonPropertyName("apiClientType")] + public int? ApiClientType { get; set; } +} + +public class FlexAuthenticateResult +{ + [JsonPropertyName("authenticated")] + public bool Authenticated { get; set; } + + [JsonPropertyName("sessionToken")] + public string SessionToken { get; set; } + + [JsonPropertyName("evtDevRef")] + public FlexEvtDevRef EvtDevRef { get; set; } + + [JsonPropertyName("softwareVersion")] + public string SoftwareVersion { get; set; } + + [JsonPropertyName("softwareVersionTimestamp")] + public string SoftwareVersionTimestamp { get; set; } + + [JsonPropertyName("timeZone")] + public string TimeZone { get; set; } + + [JsonPropertyName("apiVersion")] + public string ApiVersion { get; set; } +} + +public class FlexDoorAccessModifiers +{ + [JsonPropertyName("extDoorTime")] + public bool? ExtDoorTime { get; set; } +} + +public class FlexSchedRestriction +{ + [JsonPropertyName("sched")] + public FlexObjRef Sched { get; set; } + + [JsonPropertyName("invert")] + public bool? Invert { get; set; } +} + +public class FlexDoorMode +{ + [JsonPropertyName("staticState")] + public int? StaticState { get; set; } + + [JsonPropertyName("allowUniquePin")] + public bool? AllowUniquePin { get; set; } + + [JsonPropertyName("allowCard")] + public bool? AllowCard { get; set; } + + [JsonPropertyName("requireConfirmingPinWithCard")] + public bool? RequireConfirmingPinWithCard { get; set; } +} diff --git a/src/Aporta.Shared/Models/Flex/FlexCred.cs b/src/Aporta.Shared/Models/Flex/FlexCred.cs new file mode 100644 index 0000000..ce15d10 --- /dev/null +++ b/src/Aporta.Shared/Models/Flex/FlexCred.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Aporta.Shared.Models.Flex; + +public class FlexCred +{ + [JsonPropertyName("version")] + public int? Version { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("enabled")] + public bool? Enabled { get; set; } + + [JsonPropertyName("effective")] + public string Effective { get; set; } + + [JsonPropertyName("expires")] + public string Expires { get; set; } + + [JsonPropertyName("credTemplate")] + public FlexObjRef CredTemplate { get; set; } + + [JsonPropertyName("cardPin")] + public FlexCardPin CardPin { get; set; } + + [JsonPropertyName("doorAccessModifiers")] + public FlexDoorAccessModifiers DoorAccessModifiers { get; set; } + + [JsonPropertyName("privBindings")] + public List PrivBindings { get; set; } +} + +public class FlexCardPin +{ + [JsonPropertyName("credNum")] + public string CredNum { get; set; } + + [JsonPropertyName("facilityCode")] + public int? FacilityCode { get; set; } + + [JsonPropertyName("pin")] + public string Pin { get; set; } + + [JsonPropertyName("pinUnique")] + public bool? PinUnique { get; set; } +} + +public class FlexCredPrivBinding +{ + [JsonPropertyName("schedRestriction")] + public FlexSchedRestriction SchedRestriction { get; set; } + + [JsonPropertyName("priv")] + public FlexObjRef Priv { get; set; } + + [JsonPropertyName("devAsDoorAccessPriv")] + public FlexObjRef DevAsDoorAccessPriv { get; set; } + + [JsonPropertyName("unid")] + public int? Unid { get; set; } +} + +public class FlexCredTemplate +{ + [JsonPropertyName("version")] + public int? Version { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("cardPinTemplate")] + public FlexCardPinTemplate CardPinTemplate { get; set; } + + [JsonPropertyName("priority")] + public int? Priority { get; set; } +} + +public class FlexCardPinTemplate +{ + [JsonPropertyName("credComponentPresence")] + public int? CredComponentPresence { get; set; } + + [JsonPropertyName("credNumPresence")] + public int? CredNumPresence { get; set; } + + [JsonPropertyName("pinPresence")] + public int? PinPresence { get; set; } + + [JsonPropertyName("pinUnique")] + public bool? PinUnique { get; set; } + + [JsonPropertyName("minPinLength")] + public int? MinPinLength { get; set; } + + [JsonPropertyName("maxPinLength")] + public int? MaxPinLength { get; set; } + + [JsonPropertyName("facilityCode")] + public int? FacilityCode { get; set; } + + [JsonPropertyName("dataLayout")] + public FlexObjRef DataLayout { get; set; } + + [JsonPropertyName("anyDataLayout")] + public bool? AnyDataLayout { get; set; } +} diff --git a/src/Aporta.Shared/Models/Flex/FlexDataFormat.cs b/src/Aporta.Shared/Models/Flex/FlexDataFormat.cs new file mode 100644 index 0000000..35b532a --- /dev/null +++ b/src/Aporta.Shared/Models/Flex/FlexDataFormat.cs @@ -0,0 +1,138 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Aporta.Shared.Models.Flex; + +public class FlexDataFormat +{ + [JsonPropertyName("version")] + public int? Version { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("dataFormatType")] + public int? DataFormatType { get; set; } + + // BinaryFormat extension (dataFormatType == 1) + [JsonPropertyName("minBits")] + public int? MinBits { get; set; } + + [JsonPropertyName("maxBits")] + public int? MaxBits { get; set; } + + [JsonPropertyName("elements")] + public List Elements { get; set; } + + [JsonPropertyName("supportReverseRead")] + public bool? SupportReverseRead { get; set; } +} + +public class FlexBinaryElement +{ + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("num")] + public int? Num { get; set; } + + [JsonPropertyName("type")] + public int? Type { get; set; } + + [JsonPropertyName("start")] + public int? Start { get; set; } + + [JsonPropertyName("len")] + public int? Len { get; set; } + + // FieldBinaryElement (type == 2) + [JsonPropertyName("field")] + public int? Field { get; set; } + + [JsonPropertyName("staticValue")] + public string StaticValue { get; set; } + + // ParityBinaryElement (type == 1) + [JsonPropertyName("odd")] + public bool? Odd { get; set; } + + [JsonPropertyName("srcStart")] + public int? SrcStart { get; set; } + + [JsonPropertyName("srcLen")] + public int? SrcLen { get; set; } + + [JsonPropertyName("mask")] + public string Mask { get; set; } + + // StaticBinaryElement (type == 0) + [JsonPropertyName("value")] + public string Value { get; set; } +} + +public class FlexDataLayout +{ + [JsonPropertyName("version")] + public int? Version { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("layoutType")] + public int? LayoutType { get; set; } + + [JsonPropertyName("priority")] + public int? Priority { get; set; } + + [JsonPropertyName("enabled")] + public bool? Enabled { get; set; } + + // BasicDataLayout extension (layoutType == 0) + [JsonPropertyName("dataFormat")] + public FlexObjRef DataFormat { get; set; } +} + +public class FlexEncryptionKey +{ + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("version")] + public int? Version { get; set; } + + [JsonPropertyName("algorithm")] + public string Algorithm { get; set; } + + [JsonPropertyName("size")] + public int? Size { get; set; } + + [JsonPropertyName("keyIdentifier")] + public string KeyIdentifier { get; set; } + + [JsonPropertyName("bytes")] + public string Bytes { get; set; } +} diff --git a/src/Aporta.Shared/Models/Flex/FlexDev.cs b/src/Aporta.Shared/Models/Flex/FlexDev.cs new file mode 100644 index 0000000..73d84d3 --- /dev/null +++ b/src/Aporta.Shared/Models/Flex/FlexDev.cs @@ -0,0 +1,193 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Aporta.Shared.Models.Flex; + +public class FlexDev +{ + [JsonPropertyName("version")] + public int? Version { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("externalId")] + public string ExternalId { get; set; } + + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("address")] + public string Address { get; set; } + + [JsonPropertyName("logicalAddress")] + public int? LogicalAddress { get; set; } + + [JsonPropertyName("macAddress")] + public string MacAddress { get; set; } + + [JsonPropertyName("enabled")] + public bool? Enabled { get; set; } + + [JsonPropertyName("port")] + public int? Port { get; set; } + + [JsonPropertyName("speed")] + public int? Speed { get; set; } + + [JsonPropertyName("devType")] + public int? DevType { get; set; } + + [JsonPropertyName("devSubType")] + public int? DevSubType { get; set; } + + [JsonPropertyName("devMod")] + public int? DevMod { get; set; } + + [JsonPropertyName("devPlatform")] + public int? DevPlatform { get; set; } + + [JsonPropertyName("devUse")] + public int? DevUse { get; set; } + + [JsonPropertyName("devModConfig")] + public FlexDevModConfig DevModConfig { get; set; } + + [JsonPropertyName("physicalParent")] + public FlexObjRef PhysicalParent { get; set; } + + [JsonPropertyName("logicalParent")] + public FlexObjRef LogicalParent { get; set; } + + [JsonPropertyName("timeZone")] + public string TimeZone { get; set; } + + [JsonPropertyName("ignoreDaylightSavings")] + public bool? IgnoreDaylightSavings { get; set; } + + [JsonPropertyName("logicalChildren")] + public List LogicalChildren { get; set; } + + [JsonPropertyName("physicalChildren")] + public List PhysicalChildren { get; set; } + + // Type-specific config (polymorphic based on devType) + [JsonPropertyName("doorConfig")] + public FlexDoorConfig DoorConfig { get; set; } + + [JsonPropertyName("credReaderConfig")] + public FlexCredReaderConfig CredReaderConfig { get; set; } + + [JsonPropertyName("controllerConfig")] + public FlexControllerConfig ControllerConfig { get; set; } + + [JsonPropertyName("actuatorConfig")] + public FlexActuatorConfig ActuatorConfig { get; set; } + + [JsonPropertyName("sensorConfig")] + public FlexSensorConfig SensorConfig { get; set; } + + [JsonPropertyName("nodeDevConfig")] + public FlexNodeDevConfig NodeDevConfig { get; set; } +} + +public class FlexDevModConfig +{ + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("version")] + public int? Version { get; set; } + + [JsonPropertyName("type")] + public int? Type { get; set; } +} + +public class FlexBaseDevConfig +{ + [JsonPropertyName("version")] + public int? Version { get; set; } + + [JsonPropertyName("username")] + public string Username { get; set; } + + [JsonPropertyName("password")] + public string Password { get; set; } + + [JsonPropertyName("devInitiatesConnection")] + public bool? DevInitiatesConnection { get; set; } + + [JsonPropertyName("encryptionKeyRef")] + public FlexObjRef EncryptionKeyRef { get; set; } + + [JsonPropertyName("encryptionKeyRefNext")] + public FlexObjRef EncryptionKeyRefNext { get; set; } + + [JsonPropertyName("disableEncryption")] + public bool? DisableEncryption { get; set; } + + [JsonPropertyName("unid")] + public int? Unid { get; set; } +} + +public class FlexDoorConfig : FlexBaseDevConfig +{ + [JsonPropertyName("defaultDoorMode")] + public FlexDoorMode DefaultDoorMode { get; set; } + + [JsonPropertyName("activateStrikeOnRex")] + public bool? ActivateStrikeOnRex { get; set; } + + [JsonPropertyName("strikeTime")] + public int? StrikeTime { get; set; } + + [JsonPropertyName("extendedStrikeTime")] + public int? ExtendedStrikeTime { get; set; } + + [JsonPropertyName("heldTime")] + public int? HeldTime { get; set; } + + [JsonPropertyName("extendedHeldTime")] + public int? ExtendedHeldTime { get; set; } +} + +public class FlexCredReaderConfig : FlexBaseDevConfig +{ + [JsonPropertyName("commType")] + public int? CommType { get; set; } + + [JsonPropertyName("tamperType")] + public int? TamperType { get; set; } + + [JsonPropertyName("ledType")] + public int? LedType { get; set; } + + [JsonPropertyName("serialPortAddress")] + public string SerialPortAddress { get; set; } +} + +public class FlexControllerConfig : FlexBaseDevConfig +{ +} + +public class FlexActuatorConfig : FlexBaseDevConfig +{ + [JsonPropertyName("invert")] + public bool? Invert { get; set; } +} + +public class FlexSensorConfig : FlexBaseDevConfig +{ + [JsonPropertyName("invert")] + public bool? Invert { get; set; } +} + +public class FlexNodeDevConfig : FlexBaseDevConfig +{ +} diff --git a/src/Aporta.Shared/Models/Flex/FlexDevStateRecord.cs b/src/Aporta.Shared/Models/Flex/FlexDevStateRecord.cs new file mode 100644 index 0000000..b7ea33f --- /dev/null +++ b/src/Aporta.Shared/Models/Flex/FlexDevStateRecord.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Aporta.Shared.Models.Flex; + +public class FlexDevStateRecord +{ + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("dev")] + public FlexObjRef Dev { get; set; } + + [JsonPropertyName("devState")] + public FlexDevState DevState { get; set; } +} + +public class FlexDevState +{ + [JsonPropertyName("devAspectStates")] + public List DevAspectStates { get; set; } = new(); +} + +public class FlexDevAspectStateEntry +{ + [JsonPropertyName("key")] + public int? Key { get; set; } + + [JsonPropertyName("value")] + public FlexDevAspectState Value { get; set; } +} + +public class FlexDevAspectState +{ + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("devAspect")] + public int? DevAspect { get; set; } + + [JsonPropertyName("hwTime")] + public string HwTime { get; set; } + + [JsonPropertyName("dbTime")] + public string DbTime { get; set; } + + [JsonPropertyName("commState")] + public int? CommState { get; set; } + + [JsonPropertyName("commStateStale")] + public bool? CommStateStale { get; set; } + + [JsonPropertyName("activityState")] + public int? ActivityState { get; set; } + + [JsonPropertyName("activityStateStale")] + public bool? ActivityStateStale { get; set; } + + [JsonPropertyName("doorMode")] + public FlexDoorMode DoorMode { get; set; } + + [JsonPropertyName("doorModeStale")] + public bool? DoorModeStale { get; set; } + + [JsonPropertyName("externalState")] + public string ExternalState { get; set; } + + [JsonPropertyName("externalStateStale")] + public bool? ExternalStateStale { get; set; } +} diff --git a/src/Aporta.Shared/Models/Flex/FlexEvt.cs b/src/Aporta.Shared/Models/Flex/FlexEvt.cs new file mode 100644 index 0000000..751ca75 --- /dev/null +++ b/src/Aporta.Shared/Models/Flex/FlexEvt.cs @@ -0,0 +1,186 @@ +using System.Text.Json.Serialization; + +namespace Aporta.Shared.Models.Flex; + +public class FlexEvt +{ + [JsonPropertyName("unid")] + public long? Unid { get; set; } + + [JsonPropertyName("hwTime")] + public string HwTime { get; set; } + + [JsonPropertyName("dbTime")] + public string DbTime { get; set; } + + [JsonPropertyName("hwTimeZone")] + public string HwTimeZone { get; set; } + + [JsonPropertyName("evtCode")] + public int? EvtCode { get; set; } + + [JsonPropertyName("evtCodeText")] + public string EvtCodeText { get; set; } + + [JsonPropertyName("externalEvtCodeText")] + public string ExternalEvtCodeText { get; set; } + + [JsonPropertyName("externalEvtCodeId")] + public string ExternalEvtCodeId { get; set; } + + [JsonPropertyName("evtSubCode")] + public int? EvtSubCode { get; set; } + + [JsonPropertyName("evtSubCodeText")] + public string EvtSubCodeText { get; set; } + + [JsonPropertyName("externalSubCodeText")] + public string ExternalSubCodeText { get; set; } + + [JsonPropertyName("externalSubCodeId")] + public string ExternalSubCodeId { get; set; } + + [JsonPropertyName("evtModifiers")] + public FlexEvtModifiers EvtModifiers { get; set; } + + [JsonPropertyName("priority")] + public int? Priority { get; set; } + + [JsonPropertyName("data")] + public string Data { get; set; } + + [JsonPropertyName("evtDevRef")] + public FlexEvtDevRef EvtDevRef { get; set; } + + [JsonPropertyName("evtControllerRef")] + public FlexEvtDevRef EvtControllerRef { get; set; } + + [JsonPropertyName("evtCredRef")] + public FlexEvtCredRef EvtCredRef { get; set; } + + [JsonPropertyName("evtSchedRef")] + public FlexEvtSchedRef EvtSchedRef { get; set; } + + [JsonPropertyName("consumed")] + public bool? Consumed { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } +} + +public class FlexEvtModifiers +{ + [JsonPropertyName("usedCard")] + public bool? UsedCard { get; set; } + + [JsonPropertyName("usedPin")] + public bool? UsedPin { get; set; } +} + +public class FlexEvtDevRef +{ + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("logicalAddress")] + public int? LogicalAddress { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("address")] + public string Address { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("externalId")] + public string ExternalId { get; set; } + + [JsonPropertyName("devPlatform")] + public int? DevPlatform { get; set; } + + [JsonPropertyName("devType")] + public int? DevType { get; set; } + + [JsonPropertyName("devSubType")] + public int? DevSubType { get; set; } + + [JsonPropertyName("devMod")] + public int? DevMod { get; set; } + + [JsonPropertyName("devUse")] + public int? DevUse { get; set; } + + [JsonPropertyName("externalDevTypeText")] + public string ExternalDevTypeText { get; set; } + + [JsonPropertyName("externalDevTypeId")] + public string ExternalDevTypeId { get; set; } + + [JsonPropertyName("externalDevModText")] + public string ExternalDevModText { get; set; } + + [JsonPropertyName("externalDevModId")] + public string ExternalDevModId { get; set; } +} + +public class FlexEvtCredRef +{ + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("credTemplateRef")] + public FlexEvtCredTemplateRef CredTemplateRef { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("credNum")] + public string CredNum { get; set; } + + [JsonPropertyName("facilityCode")] + public int? FacilityCode { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } +} + +public class FlexEvtCredTemplateRef +{ + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } +} + +public class FlexEvtSchedRef +{ + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("invert")] + public bool? Invert { get; set; } +} diff --git a/src/Aporta.Shared/Models/Flex/FlexHol.cs b/src/Aporta.Shared/Models/Flex/FlexHol.cs new file mode 100644 index 0000000..de7f1eb --- /dev/null +++ b/src/Aporta.Shared/Models/Flex/FlexHol.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Aporta.Shared.Models.Flex; + +public class FlexHol +{ + [JsonPropertyName("version")] + public int? Version { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("holTypes")] + public List HolTypes { get; set; } + + [JsonPropertyName("allHolTypes")] + public bool? AllHolTypes { get; set; } + + [JsonPropertyName("date")] + public string Date { get; set; } + + [JsonPropertyName("numDays")] + public int? NumDays { get; set; } + + [JsonPropertyName("repeat")] + public bool? Repeat { get; set; } + + [JsonPropertyName("numYearsRepeat")] + public int? NumYearsRepeat { get; set; } + + [JsonPropertyName("preserveSchedDay")] + public bool? PreserveSchedDay { get; set; } + + [JsonPropertyName("holCal")] + public FlexObjRef HolCal { get; set; } +} + +public class FlexHolCal +{ + [JsonPropertyName("version")] + public int? Version { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("unid")] + public int? Unid { get; set; } +} + +public class FlexHolType +{ + [JsonPropertyName("version")] + public int? Version { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("externalId")] + public string ExternalId { get; set; } +} diff --git a/src/Aporta.Shared/Models/Flex/FlexPriv.cs b/src/Aporta.Shared/Models/Flex/FlexPriv.cs new file mode 100644 index 0000000..a181658 --- /dev/null +++ b/src/Aporta.Shared/Models/Flex/FlexPriv.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Aporta.Shared.Models.Flex; + +public class FlexPriv +{ + [JsonPropertyName("version")] + public int? Version { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("externalId")] + public string ExternalId { get; set; } + + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("enabled")] + public bool? Enabled { get; set; } + + [JsonPropertyName("privType")] + public int? PrivType { get; set; } + + // DoorAccessPriv extension (privType == 0) + [JsonPropertyName("elements")] + public List Elements { get; set; } +} + +public class FlexDoorAccessPrivElement +{ + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("door")] + public FlexObjRef Door { get; set; } + + [JsonPropertyName("schedRestriction")] + public FlexSchedRestriction SchedRestriction { get; set; } +} diff --git a/src/Aporta.Shared/Models/Flex/FlexSched.cs b/src/Aporta.Shared/Models/Flex/FlexSched.cs new file mode 100644 index 0000000..fbb28a0 --- /dev/null +++ b/src/Aporta.Shared/Models/Flex/FlexSched.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Aporta.Shared.Models.Flex; + +public class FlexSched +{ + [JsonPropertyName("version")] + public int? Version { get; set; } + + [JsonPropertyName("tag")] + public string Tag { get; set; } + + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("externalId")] + public string ExternalId { get; set; } + + [JsonPropertyName("elements")] + public List Elements { get; set; } +} + +public class FlexSchedElement +{ + [JsonPropertyName("unid")] + public int? Unid { get; set; } + + [JsonPropertyName("schedDays")] + public List SchedDays { get; set; } + + [JsonPropertyName("holidays")] + public bool? Holidays { get; set; } + + [JsonPropertyName("holTypes")] + public List HolTypes { get; set; } + + [JsonPropertyName("start")] + public string Start { get; set; } + + [JsonPropertyName("stop")] + public string Stop { get; set; } + + [JsonPropertyName("plusDays")] + public int? PlusDays { get; set; } +} diff --git a/src/Aporta.Shared/Models/Input.cs b/src/Aporta.Shared/Models/Input.cs deleted file mode 100644 index bcec11e..0000000 --- a/src/Aporta.Shared/Models/Input.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Aporta.Shared.Models; - -public class Input -{ - public int Id { get; set; } - - public int EndpointId { get; set; } - - public string Name { get; set; } -} \ No newline at end of file diff --git a/src/Aporta.Shared/Models/Output.cs b/src/Aporta.Shared/Models/Output.cs deleted file mode 100644 index 3264ec3..0000000 --- a/src/Aporta.Shared/Models/Output.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Aporta.Shared.Models; - -public class Output -{ - public int Id { get; set; } - - public int EndpointId { get; set; } - - public string Name { get; set; } -} \ No newline at end of file diff --git a/src/Aporta.WebClient/App.razor b/src/Aporta.WebClient/App.razor index 4d637a8..d00974c 100644 --- a/src/Aporta.WebClient/App.razor +++ b/src/Aporta.WebClient/App.razor @@ -1,10 +1,43 @@ - - - - - - -

Sorry, there's nothing at this address.

-
-
-
\ No newline at end of file +@using Aporta.Shared.Calls +@inject FlexApiService FlexApi +@inject IJSRuntime JS + +@if (FlexApi.IsAuthenticated) +{ + + + + + + +

Sorry, there's nothing at this address.

+
+
+
+} +else if (_initialized) +{ + +} + +@code { + private bool _initialized; + + protected override async Task OnInitializedAsync() + { + try + { + var username = await JS.InvokeAsync("sessionStorage.getItem", "aporta_username"); + var password = await JS.InvokeAsync("sessionStorage.getItem", "aporta_password"); + if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password)) + { + await FlexApi.LoginAsync(username, password); + } + } + catch + { + // Server not ready or credentials invalid — show login form + } + _initialized = true; + } +} diff --git a/src/Aporta.WebClient/Pages/Monitoring.razor b/src/Aporta.WebClient/Pages/Monitoring.razor index e3e5574..3cf373e 100644 --- a/src/Aporta.WebClient/Pages/Monitoring.razor +++ b/src/Aporta.WebClient/Pages/Monitoring.razor @@ -1,21 +1,21 @@ @page "/monitoring" -@using System.Text.Json +@using System.Globalization @using Microsoft.AspNetCore.SignalR.Client -@using Aporta.Shared.Models +@using Aporta.Shared.Models.Flex @using Aporta.Shared.Calls @using Aporta.Shared.Messaging @using Aporta.WebClient.Messaging -@inject HttpClient Http +@inject FlexApiService FlexApi @inject NavigationManager NavigationManager Monitoring -@if (_outputs == null) +@if (_actuators == null) {
} @@ -28,35 +28,28 @@ else Timestamp Type Reason - Endpoint - Door - Person + Device + Credential - @foreach (var eventItem in _events.Items) + @foreach (var evt in _events.InstanceList) { - var eventData = JsonSerializer.Deserialize(eventItem.Data); - @eventItem.Timestamp.ToLocalTime() + @(ParseFlexTime(evt.HwTime)) - @(eventItem.Type.GetDescription()) + @evt.EvtCodeText - - - @(eventData.EventReason.GetDescription()) - - - @eventData.Endpoint?.Name + @evt.EvtSubCodeText - @eventData.Door?.Name + @evt.EvtDevRef?.Name - @eventData.Person?.FirstName @eventData.Person?.LastName + @evt.EvtCredRef?.Name } @@ -66,7 +59,7 @@ else - + @@ -93,7 +86,7 @@ else - + @@ -109,18 +102,18 @@ else - @foreach (var input in _inputs.OrderBy(output => output.Name)) + @foreach (var sensor in _sensors.InstanceList.OrderBy(s => s.Name)) { - @input.Name + @sensor.Name - @if (_inputStates[input.Id].HasValue && _inputStates[input.Id].Value) + @if (_inputStates.ContainsKey(sensor.Unid.Value) && _inputStates[sensor.Unid.Value].HasValue && _inputStates[sensor.Unid.Value].Value) { } - else if (_inputStates[input.Id].HasValue && !_inputStates[input.Id].Value) + else if (_inputStates.ContainsKey(sensor.Unid.Value) && _inputStates[sensor.Unid.Value].HasValue && !_inputStates[sensor.Unid.Value].Value) { } @@ -131,7 +124,7 @@ else - + @@ -147,24 +140,24 @@ else - @foreach (var output in _outputs.OrderBy(output => output.Name)) + @foreach (var actuator in _actuators.InstanceList.OrderBy(a => a.Name)) { - @output.Name + @actuator.Name - @if (_outputStates[output.Id].HasValue && _outputStates[output.Id].Value) + @if (_outputStates.ContainsKey(actuator.Unid.Value) && _outputStates[actuator.Unid.Value].HasValue && _outputStates[actuator.Unid.Value].Value) { } - else if (_outputStates[output.Id].HasValue && !_outputStates[output.Id].Value) + else if (_outputStates.ContainsKey(actuator.Unid.Value) && _outputStates[actuator.Unid.Value].HasValue && !_outputStates[actuator.Unid.Value].Value) { } - + } @@ -183,8 +176,8 @@ else @code { - private PaginatedItemsDto _events; - + private FlexListResponse _events; + private const string PreviousPageItem = "previous"; private const string NextPageItem = "next"; private const int PageSize = 5; @@ -192,96 +185,104 @@ else private int _pageItems = 1; private Color _eventRowColor = Color.Default; private bool _liveEventMonitoring; - - private List _inputs; + + private FlexListResponse _sensors; private readonly Dictionary _inputStates = new(); - - private List _outputs; + + private FlexListResponse _actuators; private readonly Dictionary _outputStates = new(); - + private HubConnection _hubConnection; - + private Snackbar _snackbar; private SnackbarColor _snackbarColor; private string _snackbarMessage = string.Empty; - + protected override async Task OnInitializedAsync() { await LoadEvents(); - - _inputs = await Http.GetFromJsonAsync>(Paths.Inputs); - if (_inputs != null) + + _sensors = await FlexApi.GetSensorListAsync(0, 1000); + foreach (var sensor in _sensors.InstanceList) { - foreach (var input in _inputs) - { - _inputStates[input.Id] = await GetInputState(input.Id); - } + _inputStates[sensor.Unid.Value] = await FlexApi.GetSensorStateAsync(sensor.Unid.Value); } - - _outputs = await Http.GetFromJsonAsync>(Paths.Outputs); - if (_outputs != null) + + _actuators = await FlexApi.GetActuatorListAsync(0, 1000); + foreach (var actuator in _actuators.InstanceList) { - foreach (var output in _outputs) - { - _outputStates[output.Id] = await GetOutputState(output.Id); - } + _outputStates[actuator.Unid.Value] = await FlexApi.GetActuatorStateAsync(actuator.Unid.Value); } _hubConnection = new HubConnectionBuilder() .WithUrl(NavigationManager.ToAbsoluteUri(Locations.DataChangeNotification)) .WithAutomaticReconnect(new SignalRRetryPolicy()) .Build(); - _hubConnection.On(Methods.OutputInserted, async outputId => + _hubConnection.On(Methods.OutputInserted, async _ => { - _outputs.Add( await Http.GetFromJsonAsync($"{Paths.Outputs}/{outputId}")); - _outputStates[outputId] = await GetOutputState(outputId); - + _actuators = await FlexApi.GetActuatorListAsync(0, 1000); + foreach (var actuator in _actuators.InstanceList) + { + if (!_outputStates.ContainsKey(actuator.Unid.Value)) + _outputStates[actuator.Unid.Value] = await FlexApi.GetActuatorStateAsync(actuator.Unid.Value); + } + StateHasChanged(); }); _hubConnection.On(Methods.OutputDeleted, outputId => { - var output = _outputs.FirstOrDefault(o => o.Id == outputId); - if (output == null) return; - - _outputs.Remove(output); - _outputStates.Remove(output.Id); + _actuators = new FlexListResponse + { + InstanceList = _actuators.InstanceList.Where(a => a.Unid != outputId).ToList(), + Count = _actuators.Count - 1, + Offset = _actuators.Offset, + Max = _actuators.Max, + }; + _outputStates.Remove(outputId); StateHasChanged(); }); _hubConnection.On(Methods.OutputStateChanged, (outputId, newState) => { _outputStates[outputId] = newState; - + StateHasChanged(); }); - _hubConnection.On(Methods.InputInserted, async inputId => + _hubConnection.On(Methods.InputInserted, async _ => { - _inputs.Add( await Http.GetFromJsonAsync($"{Paths.Inputs}/{inputId}")); - _inputStates[inputId] = await GetInputState(inputId); - + _sensors = await FlexApi.GetSensorListAsync(0, 1000); + foreach (var sensor in _sensors.InstanceList) + { + if (!_inputStates.ContainsKey(sensor.Unid.Value)) + _inputStates[sensor.Unid.Value] = await FlexApi.GetSensorStateAsync(sensor.Unid.Value); + } + StateHasChanged(); }); _hubConnection.On(Methods.InputDeleted, inputId => { - var input = _inputs.FirstOrDefault(i => i.Id == inputId); - if (input == null) return; - - _inputs.Remove(input); - _inputStates.Remove(input.Id); + _sensors = new FlexListResponse + { + InstanceList = _sensors.InstanceList.Where(s => s.Unid != inputId).ToList(), + Count = _sensors.Count - 1, + Offset = _sensors.Offset, + Max = _sensors.Max, + }; + _inputStates.Remove(inputId); StateHasChanged(); }); _hubConnection.On(Methods.InputStateChanged, (input, newState) => { _inputStates[input] = newState; - + StateHasChanged(); }); _hubConnection.On(Methods.NewEventReceived, async _ => { if (!_liveEventMonitoring) return; - - _events = await Http.GetFromJsonAsync>(Paths.Events + $"?pageNumber=1&pageSize={PageSize}"); + + _events = await FlexApi.GetEvtListAsync(0, PageSize); _eventRowColor = Color.Warning; StateHasChanged(); @@ -291,44 +292,14 @@ else }); await _hubConnection.StartAsync(); } - - private async Task GetInputState(int inputId) - { - string url = $"{Paths.Inputs}/state/{inputId}"; - var response = await Http.GetAsync(url); - if (!response.IsSuccessStatusCode) - { - return null; - } - return bool.Parse(await response.Content.ReadAsStringAsync()); - } - - private async Task GetOutputState(int outputId) + private async Task SetActuatorState(int actuatorId, bool? checkedValue) { - string url = $"{Paths.Outputs}/set/{outputId}"; - var response = await Http.GetAsync(url); - if (!response.IsSuccessStatusCode) - { - return null; - } + if (!checkedValue.HasValue) return; - return bool.Parse(await response.Content.ReadAsStringAsync()); - } - - private async Task SetOutputState(int outputId, bool? checkedValue) - { - string url = $"{Paths.Outputs}/set/{outputId}"; - url = QueryHelpers.AddQueryString(url, "state", checkedValue.ToString()); - var response = await Http.PostAsync(url, new StringContent(string.Empty)); - if (!response.IsSuccessStatusCode) - { - _snackbarMessage = "Unable to set output"; - _snackbarColor = SnackbarColor.Danger; - await _snackbar.Show(); - } + await FlexApi.SetActuatorStateAsync(actuatorId, checkedValue.Value); } - + private bool IsActive(string page) => _currentPage == page; private bool IsPageNavigationDisabled(string navigation) @@ -348,7 +319,7 @@ else { _currentPage = (currentPageAsInt - 1).ToString(); } - + await LoadEvents(); } @@ -359,7 +330,7 @@ else { _currentPage = (currentPageAsInt + 1).ToString(); } - + await LoadEvents(); } @@ -371,8 +342,9 @@ else private async Task LoadEvents() { - _events = await Http.GetFromJsonAsync>(Paths.Events + $"?pageNumber={_currentPage}&pageSize={PageSize}"); - _pageItems = _events.TotalItems / PageSize + (_events.TotalItems % PageSize > 0 ? 1 : 0); + var offset = (int.Parse(_currentPage) - 1) * PageSize; + _events = await FlexApi.GetEvtListAsync(offset, PageSize); + _pageItems = _events.Count / PageSize + (_events.Count % PageSize > 0 ? 1 : 0); } private async Task ToggleLiveEventMonitoring() @@ -381,7 +353,7 @@ else if (_liveEventMonitoring) { - _events = await Http.GetFromJsonAsync>(Paths.Events + $"?pageNumber=1&pageSize={PageSize}"); + _events = await FlexApi.GetEvtListAsync(0, PageSize); _eventRowColor = Color.Warning; StateHasChanged(); @@ -391,4 +363,14 @@ else } } -} \ No newline at end of file + private static string ParseFlexTime(string hwTime) + { + if (string.IsNullOrEmpty(hwTime)) + return ""; + + if (DateTime.TryParse(hwTime, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)) + return dt.ToLocalTime().ToString(); + + return hwTime; + } +} diff --git a/src/Aporta.WebClient/Pages/configuration/Outputs.razor b/src/Aporta.WebClient/Pages/configuration/Actuators.razor similarity index 61% rename from src/Aporta.WebClient/Pages/configuration/Outputs.razor rename to src/Aporta.WebClient/Pages/configuration/Actuators.razor index da2acff..b030ac2 100644 --- a/src/Aporta.WebClient/Pages/configuration/Outputs.razor +++ b/src/Aporta.WebClient/Pages/configuration/Actuators.razor @@ -1,22 +1,19 @@ -@page "/configuration/outputs" - -@using System.Text -@using System.Text.Json +@page "/configuration/actuators" @using Microsoft.AspNetCore.SignalR.Client -@using Aporta.Shared.Models +@using Aporta.Shared.Models.Flex @using Aporta.Shared.Calls @using Aporta.Shared.Messaging @using Aporta.WebClient.Messaging -@inject HttpClient Http +@inject FlexApiService FlexApi @inject NavigationManager NavigationManager -Outputs +Actuators -@if (_outputs == null) +@if (_actuators == null) {
} @@ -24,14 +21,14 @@ else { - @if (_availableControlPoints.Any()) + @if (_availableActuators != null && _availableActuators.InstanceList.Any()) { - + } else { - Add Output + Add Actuator No available outputs are configured in the drivers } @@ -49,14 +46,14 @@ else - @foreach (var output in _outputs.OrderBy(output => output.Name)) + @foreach (var actuator in _actuators.InstanceList.OrderBy(a => a.Name)) { - @output.Name + @actuator.Name - @@ -66,14 +63,14 @@ else
-
+
- + - Add Output + Add Actuator @@ -83,24 +80,24 @@ else Name - + - Please enter a name for the ouptut + Please enter a name for the actuator - + Device Endpoint - + @foreach (var available in _availableActuators.InstanceList) { - @controlPoint.Name + @available.Name } @@ -109,7 +106,7 @@ else - + @@ -124,66 +121,56 @@ else @code { - [Inject] + [Inject] IMessageService MessageService { get; set; } - - private List _outputs; - private Endpoint[] _availableControlPoints; - private Output _addOutput; - + + private FlexListResponse _actuators; + private FlexListResponse _availableActuators; + private string _addName; + private int _addEndpointUnid; + private HubConnection _hubConnection; - + private Modal _addOutputModal; private Validations _addOutputValidations; - + private Snackbar _snackbar; private SnackbarColor _snackbarColor; private string _snackbarMessage = string.Empty; - + protected override async Task OnInitializedAsync() { - _outputs = await Http.GetFromJsonAsync>(Paths.Outputs); + _actuators = await FlexApi.GetActuatorListAsync(0, 1000); + + await InitializeAvailableActuators(); - await InitializedAvailableOutputs(); - _hubConnection = new HubConnectionBuilder() .WithUrl(NavigationManager.ToAbsoluteUri(Locations.DataChangeNotification)) .WithAutomaticReconnect(new SignalRRetryPolicy()) .Build(); - _hubConnection.On(Methods.OutputInserted, async outputId => + _hubConnection.On(Methods.OutputInserted, async _ => { - _outputs.Add( await Http.GetFromJsonAsync($"{Paths.Outputs}/{outputId}")); - - await InitializedAvailableOutputs(); - + _actuators = await FlexApi.GetActuatorListAsync(0, 1000); + await InitializeAvailableActuators(); StateHasChanged(); }); - _hubConnection.On(Methods.OutputDeleted, async outputId => + _hubConnection.On(Methods.OutputDeleted, async _ => { - var output = _outputs.FirstOrDefault(o => o.Id == outputId); - if (output == null) return; - - _outputs.Remove(output); - - await InitializedAvailableOutputs(); - + _actuators = await FlexApi.GetActuatorListAsync(0, 1000); + await InitializeAvailableActuators(); StateHasChanged(); }); await _hubConnection.StartAsync(); } - private async Task InitializedAvailableOutputs() + private async Task InitializeAvailableActuators() { - _availableControlPoints = await Http.GetFromJsonAsync($"{Paths.Outputs}/available"); - - _addOutput = new Output(); - if (_availableControlPoints != null && _availableControlPoints.Any()) - { - _addOutput.EndpointId = _availableControlPoints.First().Id; - } + _availableActuators = await FlexApi.GetAvailableActuatorsAsync(); + _addName = ""; + _addEndpointUnid = _availableActuators.InstanceList.FirstOrDefault()?.Unid ?? 0; } - private async Task AddOutput() + private async Task AddActuator() { if (!await _addOutputValidations.ValidateAll()) { @@ -191,20 +178,13 @@ else } await _addOutputModal.Hide(); - string url = string.Format(Paths.Outputs); - var response = await Http.PutAsync(url, - new StringContent(JsonSerializer.Serialize(_addOutput), Encoding.UTF8, "application/json")); - if (!response.IsSuccessStatusCode) - { - _snackbarMessage = "Unable to add output"; - _snackbarColor = SnackbarColor.Danger; - await _snackbar.Show(); - } + var dev = new FlexDev { Unid = _addEndpointUnid, Name = _addName }; + await FlexApi.SaveActuatorAsync(dev); } - private async Task RemoveOutput(int outputId, string outputName) + private async Task RemoveActuator(FlexDev actuator) { - if (!await MessageService.Confirm($"Are you sure you want to delete output '{outputName}'?", "Delete Bus", options => + if (!await MessageService.Confirm($"Are you sure you want to delete actuator '{actuator.Name}'?", "Delete Actuator", options => { options.ShowMessageIcon = false; options.ConfirmButtonColor = Color.Danger; @@ -212,15 +192,8 @@ else { return; } - - string url = string.Format($"{Paths.Outputs}/{outputId}"); - var response = await Http.DeleteAsync(url); - if (!response.IsSuccessStatusCode) - { - _snackbarMessage = "Unable to remove output"; - _snackbarColor = SnackbarColor.Danger; - await _snackbar.Show(); - } + + await FlexApi.DeleteActuatorAsync(actuator.Unid.Value); } -} \ No newline at end of file +} diff --git a/src/Aporta.WebClient/Pages/configuration/CardFormats.razor b/src/Aporta.WebClient/Pages/configuration/CardFormats.razor new file mode 100644 index 0000000..04843d2 --- /dev/null +++ b/src/Aporta.WebClient/Pages/configuration/CardFormats.razor @@ -0,0 +1,484 @@ +@page "/configuration/cardformats" + +@using System.Linq +@using Aporta.Shared.Calls +@using Aporta.Shared.Models.Flex + +@inject FlexApiService FlexApi + +Card Formats + + +@if (_layouts == null) +{ +
+} +else +{ + + + + + + + + + + + + + Name + Bits + Elements + Priority + + + + + @foreach (var dl in _layouts.InstanceList.OrderBy(l => l.Name ?? "")) + { + var dataLayout = dl; + var format = GetFormatForLayout(dataLayout); + + + @dataLayout.Name + + + @FormatBitsRange(format) + + + @(format?.Elements?.Count ?? 0) + + + @dataLayout.Priority + + + + + + + } + +
+
+
+
+
+ + + + + + @(_isEditing ? "Edit Card Format" : "Add Card Format") + + + + + + + + Name + + + + + Please enter a name for the card format + + + + + + + + + + + + Min Bits + + + + + + + + Max Bits + + + + + + + + Priority + + + + + + + + + Enabled + + + + Binary Elements + + @for (var i = 0; i < _editElements.Count; i++) + { + var index = i; + var element = _editElements[index]; + + + + + + Type + + + + + + + + Start Bit + + + + + + + + Length + + + + + + + + @if (element.Type == 2) + { + + + + Field + + + + + + + } + else if (element.Type == 1) + { + + + + Odd Parity + + + + + Src Start + + + + + + + + Src Length + + + + + + + } + else if (element.Type == 0) + { + + + + Value + + + + + + + } + + + + + + + + + } + + + + + + + + + +} + + + + @_snackbarMessage + + + + + +@code { + [Inject] + IMessageService MessageService { get; set; } + + private FlexListResponse _layouts; + private Dictionary _formatsByUnid = new(); + + private FlexDataLayout _editLayout = new(); + private FlexDataFormat _editFormat = new(); + private List _editElements = new(); + private bool _isEditing; + + private Modal _editModal; + private Validations _editValidations; + +#pragma warning disable 649 + private Snackbar _snackbar; +#pragma warning restore 649 + private SnackbarColor _snackbarColor; + private string _snackbarMessage = string.Empty; + + protected override async Task OnInitializedAsync() + { + await LoadCardFormats(); + } + + private async Task LoadCardFormats() + { + _layouts = await FlexApi.GetBasicDataLayoutListAsync(0, 1000); + var formats = await FlexApi.GetBinaryFormatListAsync(0, 1000); + _formatsByUnid = new Dictionary(); + foreach (var f in formats.InstanceList) + { + if (f.Unid.HasValue) + _formatsByUnid[f.Unid.Value] = f; + } + } + + private FlexDataFormat GetFormatForLayout(FlexDataLayout layout) + { + if (layout.DataFormat?.Unid != null && _formatsByUnid.TryGetValue(layout.DataFormat.Unid.Value, out var format)) + return format; + return null; + } + + private static string FormatBitsRange(FlexDataFormat format) + { + if (format == null) return ""; + if (format.MinBits == format.MaxBits) return format.MinBits?.ToString() ?? ""; + return $"{format.MinBits}–{format.MaxBits}"; + } + + private async Task ShowAddModal() + { + _isEditing = false; + _editLayout = new FlexDataLayout { Enabled = true }; + _editFormat = new FlexDataFormat(); + _editElements = new List(); + await _editModal.Show(); + } + + private async Task ShowEditModal(FlexDataLayout layout) + { + _isEditing = true; + _editLayout = new FlexDataLayout + { + Unid = layout.Unid, + Name = layout.Name, + Version = layout.Version, + Tag = layout.Tag, + Uuid = layout.Uuid, + LayoutType = layout.LayoutType, + Priority = layout.Priority, + Enabled = layout.Enabled, + DataFormat = layout.DataFormat + }; + + var format = GetFormatForLayout(layout); + if (format != null) + { + _editFormat = new FlexDataFormat + { + Unid = format.Unid, + Name = format.Name, + Version = format.Version, + Tag = format.Tag, + Uuid = format.Uuid, + DataFormatType = format.DataFormatType, + MinBits = format.MinBits, + MaxBits = format.MaxBits, + SupportReverseRead = format.SupportReverseRead + }; + _editElements = (format.Elements ?? new List()) + .OrderBy(e => e.Num) + .Select(e => new BinaryElementViewModel + { + Unid = e.Unid, + Type = e.Type ?? 0, + Start = e.Start, + Len = e.Len, + Field = e.Field, + Odd = e.Odd, + SrcStart = e.SrcStart, + SrcLen = e.SrcLen, + Value = e.Value + }).ToList(); + } + else + { + _editFormat = new FlexDataFormat(); + _editElements = new List(); + } + + await _editModal.Show(); + } + + private async Task SaveCardFormat() + { + if (!await _editValidations.ValidateAll()) + { + return; + } + await _editModal.Hide(); + + _editFormat.Name = _editLayout.Name; + _editFormat.DataFormatType = 1; // Binary + + _editFormat.Elements = _editElements.Select((e, i) => + { + var elem = new FlexBinaryElement + { + Unid = e.Unid, + Num = i + 1, + Type = e.Type, + Start = e.Start, + Len = e.Len + }; + + switch (e.Type) + { + case 0: // Static + elem.Value = e.Value; + break; + case 1: // Parity + elem.Odd = e.Odd; + elem.SrcStart = e.SrcStart; + elem.SrcLen = e.SrcLen; + break; + case 2: // Field + elem.Field = e.Field; + break; + } + + return elem; + }).ToList(); + + try + { + var formatResult = await FlexApi.SaveDataFormatAsync(_editFormat); + var savedFormatUnid = formatResult.Instance?.Unid; + + _editLayout.DataFormat = new FlexObjRef { Unid = savedFormatUnid }; + _editLayout.LayoutType = 0; // Basic + + await FlexApi.SaveDataLayoutAsync(_editLayout); + + _snackbarMessage = _isEditing ? "Successfully updated card format" : "Successfully added card format"; + _snackbarColor = SnackbarColor.Success; + } + catch + { + _snackbarMessage = _isEditing ? "Unable to update card format" : "Unable to add card format"; + _snackbarColor = SnackbarColor.Danger; + } + + await LoadCardFormats(); + await _snackbar.Show(); + } + + private async Task RemoveCardFormat(FlexDataLayout layout) + { + if (!await MessageService.Confirm($"Are you sure you want to delete card format '{layout.Name}'?", "Delete Card Format", options => + { + options.ShowMessageIcon = false; + options.ConfirmButtonColor = Color.Danger; + })) + { + return; + } + + try + { + await FlexApi.DeleteDataLayoutAsync(layout.Unid.Value); + var format = GetFormatForLayout(layout); + if (format?.Unid != null) + { + await FlexApi.DeleteDataFormatAsync(format.Unid.Value); + } + _snackbarMessage = "Successfully deleted card format"; + _snackbarColor = SnackbarColor.Success; + } + catch + { + _snackbarMessage = "Unable to delete card format"; + _snackbarColor = SnackbarColor.Danger; + } + + await LoadCardFormats(); + await _snackbar.Show(); + } + + private void AddElement() + { + _editElements.Add(new BinaryElementViewModel()); + } + + private class BinaryElementViewModel + { + public int? Unid { get; set; } + public int Type { get; set; } // 0=Static, 1=Parity, 2=Field + public int? Start { get; set; } + public int? Len { get; set; } + public int? Field { get; set; } // 0=CRED_NUM, 1=FACILITY_CODE (type=2) + public bool? Odd { get; set; } // type=1 + public int? SrcStart { get; set; } // type=1 + public int? SrcLen { get; set; } // type=1 + public string Value { get; set; } // type=0 + } +} diff --git a/src/Aporta.WebClient/Pages/configuration/Credentials.razor b/src/Aporta.WebClient/Pages/configuration/Credentials.razor new file mode 100644 index 0000000..767fe4c --- /dev/null +++ b/src/Aporta.WebClient/Pages/configuration/Credentials.razor @@ -0,0 +1,643 @@ +@page "/configuration/credentials" + +@using System.Linq + +@using Microsoft.AspNetCore.SignalR.Client +@using Aporta.Shared.Calls +@using Aporta.Shared.Messaging +@using Aporta.Shared.Models.Flex +@using Aporta.WebClient.Messaging + +@inject FlexApiService FlexApi +@inject NavigationManager NavigationManager + +Credentials + + +@if (_credentials == null) +{ +
+} +else +{ + + + + + + + + + + + + + Status + First Name + Last Name + Badge Number + Door Access + + + + + @foreach (var cred in _credentials.InstanceList.OrderBy(c => c.Name ?? "")) + { + + + @if (cred.Enabled == true) + { + + + + } + else + { + + + + } + @if (IsEnrolled(cred)) + { + + + + } + + + @ParseFirstName(cred.Name) + + + @ParseLastName(cred.Name) + + + @(cred.CardPin?.CredNum) + + + @FormatDoorAccess(cred) + + + @if (!string.IsNullOrEmpty(cred.Name)) + { + + + Action + + + @if (!IsEnrolled(cred)) + { + Enroll + } + Edit Access + + Delete + + + } + + + } + +
+
+
+
+
+ + + + + + Add Credential + + + + + + + + First Name + + + + + Please enter the person's first name + + + + + + + + + Last Name + + + + + Please enter the person's last name + + + + + + + + Enabled + + + + + + + + + + + + + + + + + + Enroll credential + + + + + + + Select a card read to enroll + + + + + + + + + + + + + + + + + + Credential details + + + + + + + + + Description + Value + + + + + + First name + + + @ParseFirstName(_detailsCred?.Name) + + + + + Last name + + + @ParseLastName(_detailsCred?.Name) + + + + + Badge is enrolled + + + @IsEnrolled(_detailsCred) + + + @if (IsEnrolled(_detailsCred)) + { + + + Badge number + + + @(_detailsCred?.CardPin?.CredNum) + + + } + + + Credential is enabled + + + @(_detailsCred?.Enabled) + + + +
+ +
+ + + +
+
+ + + + + + Edit Door Access — @(_editAccessCred?.Name) + + + + + @if (_editAccessEntries.Count > 0) + { + + + + Door + Schedule + + + + + @for (var i = 0; i < _editAccessEntries.Count; i++) + { + var entry = _editAccessEntries[i]; + + + + + + + + + + + + } + +
+ } + else + { + No door access configured. + } + +
+ + + + +
+
+} + + + + @_snackbarMessage + + + + + + +@code { + [Inject] + IMessageService MessageService { get; set; } + + private FlexListResponse _credentials; + private List _doorList = new(); + private List _schedList = new(); + private Dictionary _doorNames = new(); + private Dictionary _schedNames = new(); + private string _addFirstName; + private string _addLastName; + private bool _addEnabled = true; + private FlexCred _enrollCred; + private FlexCred _detailsCred; + private int _enrollRawReadId; + + private List _availableRawReadsForEnrollment = new(); + + private HubConnection _hubConnection; + + private Modal _addPersonModal; + private Validations _addPersonValidations; + + private Modal _enrollModal; + + private Modal _personDetailsModal; + + private Modal _editAccessModal; + private FlexCred _editAccessCred; + private List _editAccessEntries = new(); + + private Snackbar _snackbar; + private SnackbarColor _snackbarColor; + private string _snackbarMessage = string.Empty; + + protected override async Task OnInitializedAsync() + { + await LoadAllData(); + + _hubConnection = new HubConnectionBuilder() + .WithUrl(NavigationManager.ToAbsoluteUri(Locations.DataChangeNotification)) + .WithAutomaticReconnect(new SignalRRetryPolicy()) + .Build(); + _hubConnection.On(Methods.PersonInserted, async _ => + { + await LoadAllData(); + StateHasChanged(); + }); + _hubConnection.On(Methods.PersonUpdated, async _ => + { + await LoadAllData(); + StateHasChanged(); + }); + _hubConnection.On(Methods.PersonDeleted, async _ => + { + await LoadAllData(); + StateHasChanged(); + }); + + await _hubConnection.StartAsync(); + } + + private async Task AddPerson() + { + if (!await _addPersonValidations.ValidateAll()) + { + return; + } + await _addPersonModal.Hide(); + + var cred = new FlexCred + { + Name = $"{_addLastName}, {_addFirstName}", + Enabled = _addEnabled + }; + + try + { + await FlexApi.SaveCredAsync(cred); + _snackbarMessage = "Successfully added credential"; + _snackbarColor = SnackbarColor.Success; + } + catch + { + _snackbarMessage = "Unable to add credential"; + _snackbarColor = SnackbarColor.Danger; + } + + _addFirstName = null; + _addLastName = null; + _addEnabled = true; + + await _snackbar.Show(); + } + + private async Task RemovePerson(FlexCred cred) + { + var firstName = ParseFirstName(cred.Name); + var lastName = ParseLastName(cred.Name); + if (!await MessageService.Confirm($"Are you sure you want to delete credential '{firstName} {lastName}'?", "Delete Credential", options => + { + options.ShowMessageIcon = false; + options.ConfirmButtonColor = Color.Danger; + })) + { + return; + } + + try + { + await FlexApi.DeleteCredAsync(cred.Unid.Value); + _snackbarMessage = "Successfully removed credential"; + _snackbarColor = SnackbarColor.Success; + } + catch + { + _snackbarMessage = "Unable to remove credential"; + _snackbarColor = SnackbarColor.Danger; + } + await _snackbar.Show(); + } + + private async Task ShowDetails(FlexCred cred) + { + _detailsCred = cred; + await _personDetailsModal.Show(); + } + + private async Task ShowEnroll(FlexCred cred) + { + _enrollCred = cred; + + var rawReadsResponse = await FlexApi.GetRawReadsAsync(); + var rawReads = rawReadsResponse?.InstanceList ?? new List(); + + if (rawReads.Count == 0) + { + _snackbarMessage = "No card reads available. Swipe an unassigned card at a reader."; + _snackbarColor = SnackbarColor.Warning; + await _snackbar.Show(); + return; + } + + _availableRawReadsForEnrollment = rawReads; + _enrollRawReadId = (int)(_availableRawReadsForEnrollment.First().Unid ?? 0); + + await _enrollModal.Show(); + } + + private async Task EnrollPerson() + { + await _enrollModal.Hide(); + + try + { + await FlexApi.EnrollCredFromReadAsync(_enrollCred.Unid.Value, _enrollRawReadId); + _snackbarMessage = "Successfully enrolled credential"; + _snackbarColor = SnackbarColor.Success; + } + catch + { + _snackbarMessage = "Unable to enroll credential"; + _snackbarColor = SnackbarColor.Danger; + } + await _snackbar.Show(); + } + + private async Task LoadAllData() + { + _credentials = await FlexApi.GetCredListAsync(0, 1000); + var doors = await FlexApi.GetDoorListAsync(0, 1000); + var schedules = await FlexApi.GetSchedListAsync(0, 1000); + _doorList = doors.InstanceList; + _schedList = schedules.InstanceList; + _doorNames = _doorList.ToDictionary(d => d.Unid ?? 0, d => d.Name ?? ""); + _schedNames = _schedList.ToDictionary(s => s.Unid ?? 0, s => s.Name ?? ""); + } + + private string FormatDoorAccess(FlexCred cred) + { + if (cred.PrivBindings == null || cred.PrivBindings.Count == 0) + return ""; + + var entries = new List(); + foreach (var binding in cred.PrivBindings) + { + if (binding.DevAsDoorAccessPriv == null) + continue; + + var doorUnid = binding.DevAsDoorAccessPriv.Unid ?? 0; + var doorName = _doorNames.TryGetValue(doorUnid, out var name) ? name : $"Door {doorUnid}"; + + if (binding.SchedRestriction?.Sched?.Unid != null) + { + var schedUnid = binding.SchedRestriction.Sched.Unid.Value; + var schedName = _schedNames.TryGetValue(schedUnid, out var sName) ? sName : $"Schedule {schedUnid}"; + entries.Add($"{doorName} ({schedName})"); + } + else + { + entries.Add($"{doorName} (Always)"); + } + } + + return string.Join(", ", entries); + } + + private static bool IsEnrolled(FlexCred cred) + { + return !string.IsNullOrEmpty(cred?.CardPin?.CredNum); + } + + private static string ParseFirstName(string name) + { + if (string.IsNullOrEmpty(name)) return ""; + var commaIndex = name.IndexOf(','); + if (commaIndex < 0) return name; + return name.Substring(commaIndex + 1).Trim(); + } + + private static string ParseLastName(string name) + { + if (string.IsNullOrEmpty(name)) return ""; + var commaIndex = name.IndexOf(','); + if (commaIndex < 0) return ""; + return name.Substring(0, commaIndex).Trim(); + } + + private async Task ShowEditAccess(FlexCred cred) + { + _editAccessCred = cred; + _editAccessEntries = (cred.PrivBindings ?? new List()) + .Where(b => b.DevAsDoorAccessPriv != null) + .Select(b => new DoorAccessEntry + { + OriginalBinding = b, + DoorUnid = b.DevAsDoorAccessPriv.Unid ?? 0, + SchedUnid = b.SchedRestriction?.Sched?.Unid ?? 0 + }) + .ToList(); + await _editAccessModal.Show(); + } + + private void AddAccessEntry() + { + _editAccessEntries.Add(new DoorAccessEntry()); + } + + private void RemoveAccessEntry(DoorAccessEntry entry) + { + _editAccessEntries.Remove(entry); + } + + private async Task SaveEditAccess() + { + await _editAccessModal.Hide(); + + // Preserve any non-door-access bindings (e.g., Priv-based) + var nonDoorBindings = (_editAccessCred.PrivBindings ?? new List()) + .Where(b => b.DevAsDoorAccessPriv == null) + .ToList(); + + var doorBindings = _editAccessEntries + .Where(e => e.DoorUnid > 0) + .Select(e => + { + // Reuse original binding to preserve all FlexObjRef fields + var binding = e.OriginalBinding ?? new FlexCredPrivBinding + { + DevAsDoorAccessPriv = new FlexObjRef { Unid = e.DoorUnid } + }; + + // Update door ref only if changed or new + if (e.OriginalBinding == null || (binding.DevAsDoorAccessPriv?.Unid ?? 0) != e.DoorUnid) + { + binding.DevAsDoorAccessPriv = new FlexObjRef { Unid = e.DoorUnid }; + } + + // Update schedule restriction + binding.SchedRestriction = e.SchedUnid > 0 + ? new FlexSchedRestriction { Sched = new FlexObjRef { Unid = e.SchedUnid } } + : null; + + return binding; + }) + .ToList(); + + _editAccessCred.PrivBindings = nonDoorBindings.Concat(doorBindings).ToList(); + + try + { + await FlexApi.SaveCredAsync(_editAccessCred); + _snackbarMessage = "Successfully updated door access"; + _snackbarColor = SnackbarColor.Success; + } + catch + { + _snackbarMessage = "Unable to update door access"; + _snackbarColor = SnackbarColor.Danger; + } + await _snackbar.Show(); + } + + private class DoorAccessEntry + { + public FlexCredPrivBinding OriginalBinding { get; set; } + public int DoorUnid { get; set; } + public int SchedUnid { get; set; } + } +} diff --git a/src/Aporta.WebClient/Pages/configuration/Doors.razor b/src/Aporta.WebClient/Pages/configuration/Doors.razor index b6201a9..2a4a357 100644 --- a/src/Aporta.WebClient/Pages/configuration/Doors.razor +++ b/src/Aporta.WebClient/Pages/configuration/Doors.razor @@ -1,16 +1,14 @@ @page "/configuration/doors" -@using System.Text -@using System.Text.Json - @using Microsoft.AspNetCore.SignalR.Client @using Aporta.Shared.Models +@using Aporta.Shared.Models.Flex @using Aporta.Shared.Calls @using Aporta.Shared.Messaging @using Aporta.WebClient.Messaging -@inject HttpClient Http +@inject FlexApiService FlexApi @inject NavigationManager NavigationManager Doors @@ -24,7 +22,7 @@ else { - @if (_availableAccessPoints.Any()) + @if (_availableAccessPoints.InstanceList.Any()) { } @@ -49,14 +47,14 @@ else - @foreach (var door in _doors.OrderBy(door => door.Name)) + @foreach (var door in _doors.InstanceList.OrderBy(door => door.Name)) { @door.Name - @@ -66,9 +64,9 @@ else - + - + @@ -93,7 +91,7 @@ else - + Access Reader In @@ -101,9 +99,9 @@ else None - @foreach (var accessPoint in _availableAccessPoints) + @foreach (var available in _availableAccessPoints.InstanceList) { - @accessPoint.Name + @available.Name } @@ -145,12 +143,12 @@ else - @foreach (var availableCredential in _availableCredentialsForEnrollment) - { - @availableCredential.Item2.Timestamp.ToLocalTime() at @availableCredential.Item3?.Door?.Name - } - - - - - - - - - - - - - - - - - Person details - - - - - - - - - Description - Value - - - - - - First name - - - @_detailsPerson.FirstName - - - - - Last name - - - @_detailsPerson.LastName - - - - - Badge is enrolled - - - @PersonIsEnrolled(_detailsPerson.Id) - - - @if (PersonIsEnrolled(_detailsPerson.Id)) - { - - - Badge number - - - @PersonsBadgeIdentifier(_detailsPerson.Id) - - - } - - - Person is enabled - - - @_detailsPerson.Enabled - - - -
- -
- - - -
-
-} - - - - @_snackbarMessage - - - - - - -@code { - [Inject] - IMessageService MessageService { get; set; } - - private List _people; - private List _assigned; - private Person _addPerson; - private Person _enrollPerson; - private Person _detailsPerson; - private int _enrollCredentialId; - - private readonly List> _availableCredentialsForEnrollment = new(); - - private HubConnection _hubConnection; - - private Modal _addPersonModal; - private Validations _addPersonValidations; - - private Modal _enrollModal; - - private Modal _personDetailsModal; - - private Snackbar _snackbar; - private SnackbarColor _snackbarColor; - private string _snackbarMessage = string.Empty; - - protected override async Task OnInitializedAsync() - { - _people = await Http.GetFromJsonAsync>(Paths.People); - _assigned = await Http.GetFromJsonAsync>($"{Paths.Credentials}/assigned"); - - _addPerson = new Person{Enabled = true}; - - _hubConnection = new HubConnectionBuilder() - .WithUrl(NavigationManager.ToAbsoluteUri(Locations.DataChangeNotification)) - .WithAutomaticReconnect(new SignalRRetryPolicy()) - .Build(); - _hubConnection.On(Methods.PersonInserted, async personId => - { - _people.Add( await Http.GetFromJsonAsync($"{Paths.People}/{personId}")); - - StateHasChanged(); - }); - _hubConnection.On(Methods.PersonUpdated, async personId => - { - var person = await Http.GetFromJsonAsync($"{Paths.People}/{personId}"); - - int indexToReplace = _people.FindIndex(matchedPerson => matchedPerson.Id == personId); - - if (indexToReplace >= 0) _people[indexToReplace] = person; - else _people.Add(person); - - _assigned = await Http.GetFromJsonAsync>($"{Paths.Credentials}/assigned"); - - StateHasChanged(); - }); - _hubConnection.On(Methods.PersonDeleted, personId => - { - var person = _people.FirstOrDefault(o => o.Id == personId); - if (person == null) return; - - _people.Remove(person); - - StateHasChanged(); - }); - - await _hubConnection.StartAsync(); - } - - private async Task AddPerson() - { - if (!await _addPersonValidations.ValidateAll()) - { - return; - } - await _addPersonModal.Hide(); - - const string url = Paths.People; - var response = await Http.PutAsync(url, - new StringContent(JsonSerializer.Serialize(_addPerson), Encoding.UTF8, "application/json")); - if (response.IsSuccessStatusCode) - { - _snackbarMessage = "Successfully added person"; - _snackbarColor = SnackbarColor.Success; - } - else - { - _snackbarMessage = "Unable to add person"; - _snackbarColor = SnackbarColor.Danger; - } - } - - private async Task RemovePerson(Person person) - { - if (!await MessageService.Confirm($"Are you sure you want to delete person '{person.FirstName} {person.LastName}'?", "Delete Person", options => - { - options.ShowMessageIcon = false; - options.ConfirmButtonColor = Color.Danger; - })) - { - return; - } - - string url = $"{Paths.People}/{person.Id}"; - var response = await Http.DeleteAsync(url); - - if (response.IsSuccessStatusCode) - { - _snackbarMessage = "Successfully removed person"; - _snackbarColor = SnackbarColor.Success; - } - else - { - _snackbarMessage = "Unable to remove person"; - _snackbarColor = SnackbarColor.Danger; - } - await _snackbar.Show(); - } - - private async Task ShowDetails(Person person) - { - _detailsPerson = person; - - await _personDetailsModal.Show(); - } - - private async Task ShowEnroll(Person person) - { - _enrollPerson = person; - _availableCredentialsForEnrollment.Clear(); - - string url = $"{Paths.Credentials}/unassigned"; - var unassignedCredentials = await Http.GetFromJsonAsync>(url); - if (unassignedCredentials.Count == 0) - { - _snackbarMessage = "No unassigned credentials are available. Swipe an unassigned card at a reader."; - _snackbarColor = SnackbarColor.Warning; - await _snackbar.Show(); - return; - } - - foreach (var credential in unassignedCredentials) - { - url = $"{Paths.Events}/{credential.LastEvent}"; - try - { - var lastEvent = await Http.GetFromJsonAsync(url); - var eventData = JsonSerializer.Deserialize(lastEvent.Data); - _availableCredentialsForEnrollment.Add(new Tuple(credential, lastEvent, eventData)); - } - catch - { - // ignored - } - } - - _availableCredentialsForEnrollment.Sort(CompareLastEventDescending); - - _enrollCredentialId = _availableCredentialsForEnrollment.First().Item1.Id; - - await _enrollModal.Show(); - return; - - static int CompareLastEventDescending(Tuple x, Tuple y) - { - return y.Item1.LastEvent.CompareTo(x.Item1.LastEvent); - } - } - - private async Task EnrollPerson() - { - await _enrollModal.Hide(); - - string url = $"{Paths.Credentials}/{_enrollCredentialId}/enroll/{_enrollPerson.Id}"; - var response = await Http.PostAsync(url, new StringContent(string.Empty)); - - if (response.IsSuccessStatusCode) - { - _snackbarMessage = "Successfully enrolled person"; - _snackbarColor = SnackbarColor.Success; - } - else - { - _snackbarMessage = "Unable to enroll person"; - _snackbarColor = SnackbarColor.Danger; - } - await _snackbar.Show(); - } - - private bool PersonIsEnrolled(int personId) - { - return _assigned.Any(credential => credential.AssignedPersonId == personId); - } - - private string PersonsBadgeIdentifier(int personId) - { - return _assigned.FirstOrDefault(credential => credential.AssignedPersonId == personId)?.Number ?? string.Empty; - } -} \ No newline at end of file diff --git a/src/Aporta.WebClient/Pages/configuration/Schedules.razor b/src/Aporta.WebClient/Pages/configuration/Schedules.razor new file mode 100644 index 0000000..1789240 --- /dev/null +++ b/src/Aporta.WebClient/Pages/configuration/Schedules.razor @@ -0,0 +1,371 @@ +@page "/configuration/schedules" + +@using System.Linq +@using Aporta.Shared.Calls +@using Aporta.Shared.Models.Flex + +@inject FlexApiService FlexApi + +Schedules + + +@if (_schedules == null) +{ +
+} +else +{ + + + + + + + + + + + + + Name + Time Intervals + + + + + @foreach (var sched in _schedules.InstanceList.OrderBy(s => s.Name ?? "")) + { + + + @sched.Name + + + @FormatScheduleSummary(sched) + + + + + + + } + +
+
+
+
+
+ + + + + + @(_isEditing ? "Edit Schedule" : "Add Schedule") + + + + + + + + Name + + + + + Please enter a name for the schedule + + + + + + + + + + Time Intervals + + @for (var i = 0; i < _editElements.Count; i++) + { + var index = i; + var element = _editElements[index]; + + + + + + Days + + @for (var d = 0; d < 7; d++) + { + var dayIndex = d; + @DayNames[dayIndex] + } + + + + + + + + Start Time + + + + + + + + Stop Time + + + + + + + + + + + + + Holidays + + + + + + } + + + + + + + + + +} + + + + @_snackbarMessage + + + + + +@code { + [Inject] + IMessageService MessageService { get; set; } + + private static readonly string[] DayNames = { "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" }; + + private FlexListResponse _schedules; + + private FlexSched _editSched = new(); + private List _editElements = new(); + private bool _isEditing; + + private Modal _editModal; + private Validations _editValidations; + +#pragma warning disable 649 + private Snackbar _snackbar; +#pragma warning restore 649 + private SnackbarColor _snackbarColor; + private string _snackbarMessage = string.Empty; + + protected override async Task OnInitializedAsync() + { + await LoadSchedules(); + } + + private async Task LoadSchedules() + { + _schedules = await FlexApi.GetSchedListAsync(0, 1000); + } + + private async Task ShowAddModal() + { + _isEditing = false; + _editSched = new FlexSched(); + _editElements = new List(); + await _editModal.Show(); + } + + private async Task ShowEditModal(FlexSched sched) + { + _isEditing = true; + _editSched = new FlexSched + { + Unid = sched.Unid, + Name = sched.Name, + Version = sched.Version, + Tag = sched.Tag, + Uuid = sched.Uuid, + ExternalId = sched.ExternalId + }; + _editElements = (sched.Elements ?? new List()) + .Select(e => new ElementViewModel + { + Unid = e.Unid, + Days = Enumerable.Range(0, 7).Select(d => e.SchedDays?.Contains(d) == true).ToArray(), + Start = StripSeconds(e.Start), + Stop = StripSeconds(e.Stop), + Holidays = e.Holidays == true + }).ToList(); + await _editModal.Show(); + } + + private async Task SaveSchedule() + { + if (!await _editValidations.ValidateAll()) + { + return; + } + await _editModal.Hide(); + + _editSched.Elements = _editElements.Select(e => new FlexSchedElement + { + Unid = e.Unid, + SchedDays = Enumerable.Range(0, 7).Where(d => e.Days[d]).ToList(), + Start = AppendSeconds(e.Start), + Stop = AppendSeconds(e.Stop), + Holidays = e.Holidays, + PlusDays = 0, + HolTypes = new List() + }).ToList(); + + try + { + await FlexApi.SaveSchedAsync(_editSched); + _snackbarMessage = _isEditing ? "Successfully updated schedule" : "Successfully added schedule"; + _snackbarColor = SnackbarColor.Success; + } + catch + { + _snackbarMessage = _isEditing ? "Unable to update schedule" : "Unable to add schedule"; + _snackbarColor = SnackbarColor.Danger; + } + + await LoadSchedules(); + await _snackbar.Show(); + } + + private async Task RemoveSchedule(FlexSched sched) + { + if (!await MessageService.Confirm($"Are you sure you want to delete schedule '{sched.Name}'?", "Delete Schedule", options => + { + options.ShowMessageIcon = false; + options.ConfirmButtonColor = Color.Danger; + })) + { + return; + } + + try + { + await FlexApi.DeleteSchedAsync(sched.Unid.Value); + _snackbarMessage = "Successfully deleted schedule"; + _snackbarColor = SnackbarColor.Success; + } + catch + { + _snackbarMessage = "Unable to delete schedule"; + _snackbarColor = SnackbarColor.Danger; + } + + await LoadSchedules(); + await _snackbar.Show(); + } + + private void AddElement() + { + _editElements.Add(new ElementViewModel + { + Days = new bool[7] + }); + } + + private static string FormatScheduleSummary(FlexSched sched) + { + if (sched.Elements == null || sched.Elements.Count == 0) + return ""; + + return string.Join("; ", sched.Elements.Select(FormatElement)); + } + + private static string FormatElement(FlexSchedElement element) + { + var days = FormatDays(element.SchedDays ?? new List()); + var start = StripSeconds(element.Start); + var stop = StripSeconds(element.Stop); + return $"{days} {start}-{stop}"; + } + + private static string FormatDays(List schedDays) + { + if (schedDays.Count == 0) return ""; + + var sorted = schedDays.OrderBy(d => d).ToList(); + var ranges = new List(); + var rangeStart = sorted[0]; + var rangeEnd = sorted[0]; + + for (var i = 1; i < sorted.Count; i++) + { + if (sorted[i] == rangeEnd + 1) + { + rangeEnd = sorted[i]; + } + else + { + ranges.Add(FormatDayRange(rangeStart, rangeEnd)); + rangeStart = sorted[i]; + rangeEnd = sorted[i]; + } + } + ranges.Add(FormatDayRange(rangeStart, rangeEnd)); + + return string.Join(",", ranges); + } + + private static string FormatDayRange(int start, int end) + { + if (start == end) return DayNames[start]; + return $"{DayNames[start]}-{DayNames[end]}"; + } + + private static string StripSeconds(string time) + { + if (string.IsNullOrEmpty(time)) return ""; + var parts = time.Split(':'); + if (parts.Length >= 2) return $"{parts[0]}:{parts[1]}"; + return time; + } + + private static string AppendSeconds(string time) + { + if (string.IsNullOrEmpty(time)) return "00:00:00"; + var parts = time.Split(':'); + if (parts.Length == 2) return $"{time}:00"; + return time; + } + + private class ElementViewModel + { + public int? Unid { get; set; } + public bool[] Days { get; set; } = new bool[7]; + public string Start { get; set; } = ""; + public string Stop { get; set; } = ""; + public bool Holidays { get; set; } + } +} diff --git a/src/Aporta.WebClient/Pages/configuration/Inputs.razor b/src/Aporta.WebClient/Pages/configuration/Sensors.razor similarity index 62% rename from src/Aporta.WebClient/Pages/configuration/Inputs.razor rename to src/Aporta.WebClient/Pages/configuration/Sensors.razor index 2173864..0227e2b 100644 --- a/src/Aporta.WebClient/Pages/configuration/Inputs.razor +++ b/src/Aporta.WebClient/Pages/configuration/Sensors.razor @@ -1,22 +1,19 @@ -@page "/configuration/inputs" - -@using System.Text -@using System.Text.Json +@page "/configuration/sensors" @using Microsoft.AspNetCore.SignalR.Client -@using Aporta.Shared.Models +@using Aporta.Shared.Models.Flex @using Aporta.Shared.Calls @using Aporta.Shared.Messaging @using Aporta.WebClient.Messaging -@inject HttpClient Http +@inject FlexApiService FlexApi @inject NavigationManager NavigationManager -Inputs +Sensors -@if (_inputs == null) +@if (_sensors == null) {
} @@ -24,14 +21,14 @@ else { - @if (_availableMonitorPoints.Any()) + @if (_availableSensors != null && _availableSensors.InstanceList.Any()) { - + } else { - Add Input + Add Sensor No available inputs are configured in the drivers } @@ -49,14 +46,14 @@ else - @foreach (var input in _inputs.OrderBy(input => input.Name)) + @foreach (var sensor in _sensors.InstanceList.OrderBy(s => s.Name)) { - @input.Name + @sensor.Name - @@ -66,14 +63,14 @@ else - + - + - Add Input + Add Sensor @@ -83,24 +80,24 @@ else Name - + - Please enter a name for the input + Please enter a name for the sensor - + Device Endpoint - + @foreach (var available in _availableSensors.InstanceList) { - @monitorPoint.Name + @available.Name } @@ -109,7 +106,7 @@ else - + @@ -124,65 +121,55 @@ else @code { - [Inject] + [Inject] IMessageService MessageService { get; set; } - - private List _inputs; - private Endpoint[] _availableMonitorPoints; - private Input _addInput; - + + private FlexListResponse _sensors; + private FlexListResponse _availableSensors; + private string _addName; + private int _addEndpointUnid; + private HubConnection _hubConnection; - + private Modal _addInputModal; private Validations _addInputValidations; private Snackbar _snackbar; private SnackbarColor _snackbarColor; private string _snackbarMessage = string.Empty; - + protected override async Task OnInitializedAsync() { - _inputs = await Http.GetFromJsonAsync>(Paths.Inputs); + _sensors = await FlexApi.GetSensorListAsync(0, 1000); + + await InitializeAvailableSensors(); - await InitializedAvailableInputs(); - _hubConnection = new HubConnectionBuilder() .WithUrl(NavigationManager.ToAbsoluteUri(Locations.DataChangeNotification)) .WithAutomaticReconnect(new SignalRRetryPolicy()) .Build(); - _hubConnection.On(Methods.InputInserted, async inputId => + _hubConnection.On(Methods.InputInserted, async _ => { - _inputs.Add( await Http.GetFromJsonAsync($"{Paths.Inputs}/{inputId}")); - - await InitializedAvailableInputs(); - + _sensors = await FlexApi.GetSensorListAsync(0, 1000); + await InitializeAvailableSensors(); StateHasChanged(); }); - _hubConnection.On(Methods.InputDeleted, async inputId => + _hubConnection.On(Methods.InputDeleted, async _ => { - var input = _inputs.FirstOrDefault(o => o.Id == inputId); - if (input == null) return; - - _inputs.Remove(input); - - await InitializedAvailableInputs(); - + _sensors = await FlexApi.GetSensorListAsync(0, 1000); + await InitializeAvailableSensors(); StateHasChanged(); }); await _hubConnection.StartAsync(); } - private async Task InitializedAvailableInputs() + private async Task InitializeAvailableSensors() { - _availableMonitorPoints = await Http.GetFromJsonAsync($"{Paths.Inputs}/available"); - - _addInput = new Input(); - if (_availableMonitorPoints != null && _availableMonitorPoints.Any()) - { - _addInput.EndpointId = _availableMonitorPoints.First().Id; - } + _availableSensors = await FlexApi.GetAvailableSensorsAsync(); + _addName = ""; + _addEndpointUnid = _availableSensors.InstanceList.FirstOrDefault()?.Unid ?? 0; } - private async Task AddInput() + private async Task AddSensor() { if (! await _addInputValidations.ValidateAll()) { @@ -190,20 +177,13 @@ else } await _addInputModal.Hide(); - string url = string.Format(Paths.Inputs); - var response = await Http.PutAsync(url, - new StringContent(JsonSerializer.Serialize(_addInput), Encoding.UTF8, "application/json")); - if (!response.IsSuccessStatusCode) - { - _snackbarMessage = "Unable to add input"; - _snackbarColor = SnackbarColor.Danger; - await _snackbar.Show(); - } + var dev = new FlexDev { Unid = _addEndpointUnid, Name = _addName }; + await FlexApi.SaveSensorAsync(dev); } - private async Task RemoveInput(int inputId, string inputName) + private async Task RemoveSensor(FlexDev sensor) { - if (!await MessageService.Confirm($"Are you sure you want to delete input '{inputName}'?", "Delete Bus", options => + if (!await MessageService.Confirm($"Are you sure you want to delete sensor '{sensor.Name}'?", "Delete Sensor", options => { options.ShowMessageIcon = false; options.ConfirmButtonColor = Color.Danger; @@ -211,14 +191,7 @@ else { return; } - - string url = string.Format($"{Paths.Inputs}/{inputId}"); - var response = await Http.DeleteAsync(url); - if (!response.IsSuccessStatusCode) - { - _snackbarMessage = "Unable to remove input"; - _snackbarColor = SnackbarColor.Danger; - await _snackbar.Show(); - } + + await FlexApi.DeleteSensorAsync(sensor.Unid.Value); } -} \ No newline at end of file +} diff --git a/src/Aporta.WebClient/Program.cs b/src/Aporta.WebClient/Program.cs index cbbcfb8..f520d46 100644 --- a/src/Aporta.WebClient/Program.cs +++ b/src/Aporta.WebClient/Program.cs @@ -32,11 +32,11 @@ public static async Task Main(string[] args) builder.Services.AddScoped(_ => new ExtensionCalls(new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) })); - builder.Services.AddScoped(_ => new DoorCalls(new HttpClient - { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) })); - builder.Services.AddScoped(); - + + builder.Services.AddScoped(_ => new FlexApiService(new HttpClient + { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) })); + builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); diff --git a/src/Aporta.WebClient/Shared/Login.razor b/src/Aporta.WebClient/Shared/Login.razor new file mode 100644 index 0000000..8ded0e1 --- /dev/null +++ b/src/Aporta.WebClient/Shared/Login.razor @@ -0,0 +1,64 @@ +@using Aporta.Shared.Calls +@inject IJSRuntime JS + +
+ + + Aporta Login + + + + Username + + + + Password + + + @if (!string.IsNullOrEmpty(_errorMessage)) + { + @_errorMessage + } + + + +
+ +@code { + [Inject] private FlexApiService FlexApi { get; set; } + [Parameter] public EventCallback OnLoginSuccess { get; set; } + + private string _username = ""; + private string _password = ""; + private string _errorMessage; + private bool _loading; + + private async Task OnKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") await DoLogin(); + } + + private async Task DoLogin() + { + _errorMessage = null; + _loading = true; + try + { + var result = await FlexApi.LoginAsync(_username, _password); + if (result?.Authenticated == true) + { + await JS.InvokeVoidAsync("sessionStorage.setItem", "aporta_username", _username); + await JS.InvokeVoidAsync("sessionStorage.setItem", "aporta_password", _password); + await OnLoginSuccess.InvokeAsync(); + } + else + _errorMessage = "Invalid username or password"; + } + catch + { + _errorMessage = "Unable to connect to server"; + } + _loading = false; + } +} diff --git a/src/Aporta.WebClient/Shared/MainLayout.razor b/src/Aporta.WebClient/Shared/MainLayout.razor index 78e0a55..4545785 100644 --- a/src/Aporta.WebClient/Shared/MainLayout.razor +++ b/src/Aporta.WebClient/Shared/MainLayout.razor @@ -42,7 +42,10 @@ Access - People + Credentials + Schedules + Card Formats + Holidays @@ -52,8 +55,8 @@ Drivers Doors - Inputs - Outputs + Sensors + Actuators diff --git a/src/Aporta.sln b/src/Aporta.sln index 53a3be2..cad18fe 100644 --- a/src/Aporta.sln +++ b/src/Aporta.sln @@ -98,6 +98,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aporta.Drivers.Virtual.WebC EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aporta.Drivers.Virtual.Tests", "Aporta.Drivers.Virtual.Tests\Aporta.Drivers.Virtual.Tests.csproj", "{1E07F94D-F040-4C9F-8236-39A679556D59}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aporta.Migration", "Aporta.Migration\Aporta.Migration.csproj", "{7024A0B0-6025-49BE-992D-27CE1EA3402A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aporta.Migration.Tests", "..\test\Aporta.Migration.Tests\Aporta.Migration.Tests.csproj", "{3C97ED4A-576C-4EC6-B002-A64351E046B7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aporta.FlexClient.Integration.Tests", "..\test\Aporta.FlexClient.Integration.Tests\Aporta.FlexClient.Integration.Tests.csproj", "{D13BE6EB-6B5F-455F-8300-5F0A2B76115C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aporta.Selenium.Tests", "..\test\Aporta.Selenium.Tests\Aporta.Selenium.Tests.csproj", "{04CC6030-B7E0-4453-B9BE-59FE530AD134}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -272,6 +280,38 @@ Global {1E07F94D-F040-4C9F-8236-39A679556D59}.Release|Any CPU.Build.0 = Release|Any CPU {1E07F94D-F040-4C9F-8236-39A679556D59}.Release|x86.ActiveCfg = Release|Any CPU {1E07F94D-F040-4C9F-8236-39A679556D59}.Release|x86.Build.0 = Release|Any CPU + {7024A0B0-6025-49BE-992D-27CE1EA3402A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7024A0B0-6025-49BE-992D-27CE1EA3402A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7024A0B0-6025-49BE-992D-27CE1EA3402A}.Debug|x86.ActiveCfg = Debug|Any CPU + {7024A0B0-6025-49BE-992D-27CE1EA3402A}.Debug|x86.Build.0 = Debug|Any CPU + {7024A0B0-6025-49BE-992D-27CE1EA3402A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7024A0B0-6025-49BE-992D-27CE1EA3402A}.Release|Any CPU.Build.0 = Release|Any CPU + {7024A0B0-6025-49BE-992D-27CE1EA3402A}.Release|x86.ActiveCfg = Release|Any CPU + {7024A0B0-6025-49BE-992D-27CE1EA3402A}.Release|x86.Build.0 = Release|Any CPU + {3C97ED4A-576C-4EC6-B002-A64351E046B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C97ED4A-576C-4EC6-B002-A64351E046B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C97ED4A-576C-4EC6-B002-A64351E046B7}.Debug|x86.ActiveCfg = Debug|Any CPU + {3C97ED4A-576C-4EC6-B002-A64351E046B7}.Debug|x86.Build.0 = Debug|Any CPU + {3C97ED4A-576C-4EC6-B002-A64351E046B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C97ED4A-576C-4EC6-B002-A64351E046B7}.Release|Any CPU.Build.0 = Release|Any CPU + {3C97ED4A-576C-4EC6-B002-A64351E046B7}.Release|x86.ActiveCfg = Release|Any CPU + {3C97ED4A-576C-4EC6-B002-A64351E046B7}.Release|x86.Build.0 = Release|Any CPU + {D13BE6EB-6B5F-455F-8300-5F0A2B76115C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D13BE6EB-6B5F-455F-8300-5F0A2B76115C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D13BE6EB-6B5F-455F-8300-5F0A2B76115C}.Debug|x86.ActiveCfg = Debug|Any CPU + {D13BE6EB-6B5F-455F-8300-5F0A2B76115C}.Debug|x86.Build.0 = Debug|Any CPU + {D13BE6EB-6B5F-455F-8300-5F0A2B76115C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D13BE6EB-6B5F-455F-8300-5F0A2B76115C}.Release|Any CPU.Build.0 = Release|Any CPU + {D13BE6EB-6B5F-455F-8300-5F0A2B76115C}.Release|x86.ActiveCfg = Release|Any CPU + {D13BE6EB-6B5F-455F-8300-5F0A2B76115C}.Release|x86.Build.0 = Release|Any CPU + {04CC6030-B7E0-4453-B9BE-59FE530AD134}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {04CC6030-B7E0-4453-B9BE-59FE530AD134}.Debug|Any CPU.Build.0 = Debug|Any CPU + {04CC6030-B7E0-4453-B9BE-59FE530AD134}.Debug|x86.ActiveCfg = Debug|Any CPU + {04CC6030-B7E0-4453-B9BE-59FE530AD134}.Debug|x86.Build.0 = Debug|Any CPU + {04CC6030-B7E0-4453-B9BE-59FE530AD134}.Release|Any CPU.ActiveCfg = Release|Any CPU + {04CC6030-B7E0-4453-B9BE-59FE530AD134}.Release|Any CPU.Build.0 = Release|Any CPU + {04CC6030-B7E0-4453-B9BE-59FE530AD134}.Release|x86.ActiveCfg = Release|Any CPU + {04CC6030-B7E0-4453-B9BE-59FE530AD134}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -302,6 +342,8 @@ Global {72A10DAD-4C7D-4F7B-A8B2-BCE3D391F4A2} = {6F57EF26-FA40-4A1E-BD23-DA25755334F2} {FD09709D-A3DC-487B-AD25-26E5452DC811} = {72A10DAD-4C7D-4F7B-A8B2-BCE3D391F4A2} {1E07F94D-F040-4C9F-8236-39A679556D59} = {72A10DAD-4C7D-4F7B-A8B2-BCE3D391F4A2} + {D13BE6EB-6B5F-455F-8300-5F0A2B76115C} = {2DFBFA3A-B289-4723-A630-C5240C7ADE85} + {04CC6030-B7E0-4453-B9BE-59FE530AD134} = {2DFBFA3A-B289-4723-A630-C5240C7ADE85} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65C2BC8A-D61C-4643-BD0F-2670F6AA9D01} diff --git a/src/Aporta/Aporta.csproj b/src/Aporta/Aporta.csproj index 1bdcd24..91bc5a5 100644 --- a/src/Aporta/Aporta.csproj +++ b/src/Aporta/Aporta.csproj @@ -13,6 +13,7 @@ + @@ -22,6 +23,10 @@ + + + + true diff --git a/src/Aporta/Controllers/CredentialsController.cs b/src/Aporta/Controllers/CredentialsController.cs deleted file mode 100644 index b504a96..0000000 --- a/src/Aporta/Controllers/CredentialsController.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Aporta.Core.Services; -using Microsoft.AspNetCore.Mvc; - -using Aporta.Shared.Models; - -namespace Aporta.Controllers; - -[ApiController] -[Route("api/[controller]")] -public class CredentialsController -{ - private readonly CredentialService _credentialService; - - public CredentialsController(CredentialService credentialService) - { - _credentialService = credentialService; - } - - [HttpGet] - public async Task> Get() - { - return await _credentialService.GetAll(); - } - - [HttpGet("{credentialId:int}")] - public async Task Get(int credentialId) - { - return await _credentialService.Get(credentialId); - } - - [HttpPut] - public async Task Put([FromBody]Credential credential) - { - await _credentialService.Insert(credential); - } - - [HttpDelete("{credentialId:int}")] - public async Task Delete(int credentialId) - { - await _credentialService.Delete(credentialId); - } - - [HttpGet("assigned")] - public async Task> GetAssigned() - { - return await _credentialService.GetAssigned(); - } - - [HttpGet("unassigned")] - public async Task> GetUnassigned() - { - return await _credentialService.GetUnassigned(); - } - - [HttpPost("{credentialId:int}/enroll/{personId:int}")] - public async Task Enroll(int credentialId, int personId) - { - await _credentialService.Enroll(credentialId, personId); - } -} \ No newline at end of file diff --git a/src/Aporta/Controllers/DoorsController.cs b/src/Aporta/Controllers/DoorsController.cs deleted file mode 100644 index c9ed677..0000000 --- a/src/Aporta/Controllers/DoorsController.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Aporta.Core.Services; -using Aporta.Shared.Models; -using Microsoft.AspNetCore.Mvc; - -namespace Aporta.Controllers; - -[ApiController] -[Route("api/[controller]")] -public class DoorsController : ControllerBase -{ - private readonly DoorConfigurationService _doorConfigurationService; - - public DoorsController(DoorConfigurationService doorConfigurationService) - { - _doorConfigurationService = doorConfigurationService; - } - - [HttpGet] - public async Task> Get() - { - return await _doorConfigurationService.GetAll(); - } - - [HttpGet("{doorId:int}")] - public async Task Get(int doorId) - { - return await _doorConfigurationService.Get(doorId); - } - - [HttpPut] - public async Task Put([FromBody]Door door) - { - await _doorConfigurationService.Insert(door); - } - - [HttpDelete("{doorId:int}")] - public async Task Delete(int doorId) - { - await _doorConfigurationService.Delete(doorId); - } - - [HttpGet("available")] - public async Task> Available() - { - return await _doorConfigurationService.AvailableAccessPoints(); - } - - [HttpGet("endpointsavailable")] - public async Task> AvailableEndPoints() - { - return await _doorConfigurationService.AvailableEndPoints(); - } -} \ No newline at end of file diff --git a/src/Aporta/Controllers/EventsController.cs b/src/Aporta/Controllers/EventsController.cs deleted file mode 100644 index 236119f..0000000 --- a/src/Aporta/Controllers/EventsController.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Mvc; - -using Aporta.Shared.Models; -using Aporta.Core.Services; - -namespace Aporta.Controllers; - -[ApiController] -[Route("api/[controller]")] -public class EventsController -{ - private readonly EventService _eventService; - - public EventsController(EventService eventService) - { - _eventService = eventService; - } - - [HttpGet("{eventId:int}")] - public async Task Get(int eventId) - { - return await _eventService.Get(eventId); - } - - [HttpGet] - public async Task> GetPagination([FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 10) - { - return await _eventService.GetAll(pageNumber, pageSize); - } -} \ No newline at end of file diff --git a/src/Aporta/Controllers/Flex/FlexActionController.cs b/src/Aporta/Controllers/Flex/FlexActionController.cs new file mode 100644 index 0000000..0b7a0e7 --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexActionController.cs @@ -0,0 +1,68 @@ +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Z9.Protobuf; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +public class FlexActionController : ControllerBase +{ + private readonly Z9OpenCommunityProtocolService _protocolService; + + public FlexActionController(Z9OpenCommunityProtocolService protocolService) + { + _protocolService = protocolService; + } + + [HttpGet("json/doorModeChange")] + public IActionResult DoorModeChange([FromQuery] int? unid, [FromQuery] string uuid, [FromQuery] string tag, [FromQuery] string value) + { + var doorUnid = unid ?? 0; + + var req = new DevActionReq + { + RequestId = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + DevActionType = DevActionType.DoorModeChange, + DevUnid = doorUnid, + }; + + if (string.Equals(value, "RESET", System.StringComparison.OrdinalIgnoreCase)) + { + req.DevActionParams = new DevActionParams + { + Type = DevActionParamsType.DoorMode, + ExtDoorModeDevActionParams = new DoorModeDevActionParams { ResetToDefault = true }, + }; + } + else if (int.TryParse(value, out var modeInt)) + { + var doorMode = CommonDoorModes.ForDoorModeType((DoorModeType)modeInt); + req.DevActionParams = new DevActionParams + { + Type = DevActionParamsType.DoorMode, + ExtDoorModeDevActionParams = new DoorModeDevActionParams { DoorMode = doorMode }, + }; + } + + _protocolService.DispatchDevAction(req); + return Ok(new FlexVoid()); + } + + [HttpGet("json/doorMomentaryUnlock")] + public IActionResult DoorMomentaryUnlock([FromQuery] int? unid, [FromQuery] string uuid, [FromQuery] string tag, [FromQuery] bool? extDoorTime) + { + var doorUnid = unid ?? 0; + + var req = new DevActionReq + { + RequestId = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + DevActionType = DevActionType.DoorMomentaryUnlock, + DevUnid = doorUnid, + }; + + _protocolService.DispatchDevAction(req); + return Ok(new FlexVoid()); + } +} diff --git a/src/Aporta/Controllers/Flex/FlexAuthenticateController.cs b/src/Aporta/Controllers/Flex/FlexAuthenticateController.cs new file mode 100644 index 0000000..f5c24e9 --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexAuthenticateController.cs @@ -0,0 +1,31 @@ +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Aporta.Controllers.Flex; + +[ApiController] +public class FlexAuthenticateController : ControllerBase +{ + private readonly FlexSessionService _sessionService; + + public FlexAuthenticateController(FlexSessionService sessionService) + { + _sessionService = sessionService; + } + + [HttpPost("authenticate")] + public IActionResult Authenticate([FromBody] FlexAuthenticateRequest request) + { + var result = _sessionService.Authenticate(request.Username, request.Password); + return Ok(result); + } + + [HttpGet("terminate")] + public IActionResult Terminate() + { + var token = Request.Headers["sessionToken"].ToString(); + _sessionService.Terminate(token); + return Ok(new FlexVoid()); + } +} diff --git a/src/Aporta/Controllers/Flex/FlexBasicDataLayoutController.cs b/src/Aporta/Controllers/Flex/FlexBasicDataLayoutController.cs new file mode 100644 index 0000000..72a9b73 --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexBasicDataLayoutController.cs @@ -0,0 +1,47 @@ +using System.Linq; +using System.Threading.Tasks; +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("basicDataLayout")] +public class FlexBasicDataLayoutController : FlexCrudControllerBase +{ + private readonly DataLayoutRepository _repository; + + public FlexBasicDataLayoutController(IDataAccess dataAccess) + { + _repository = new DataLayoutRepository(dataAccess); + } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexDataLayout ToFlex(DataLayout proto) => FlexMapper.ToFlex(proto); + protected override DataLayout ToProto(FlexDataLayout flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexDataLayout flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexDataLayout flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(DataLayout proto) => proto.Uuid; + + [HttpGet("list")] + public override async Task List([FromQuery] int offset = 0, [FromQuery] int max = 50) + { + var all = (await Repository.GetAll()) + .Where(dl => dl.LayoutType == DataLayoutType.Basic) + .ToList(); + var count = all.Count; + var page = all.Skip(offset).Take(max).Select(ToFlex).ToList(); + + return Ok(new FlexListResponse + { + Offset = offset, + Max = max, + Count = count, + InstanceList = page, + }); + } +} diff --git a/src/Aporta/Controllers/Flex/FlexBinaryFormatController.cs b/src/Aporta/Controllers/Flex/FlexBinaryFormatController.cs new file mode 100644 index 0000000..a92fd4f --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexBinaryFormatController.cs @@ -0,0 +1,51 @@ +using System.Linq; +using System.Threading.Tasks; +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +/// +/// BinaryFormat is a subclass of DataFormat with dataFormatType == BINARY (1). +/// This controller filters DataFormats to only those with binary format extensions. +/// +[ApiController] +[Route("binaryFormat")] +public class FlexBinaryFormatController : FlexCrudControllerBase +{ + private readonly DataFormatRepository _repository; + + public FlexBinaryFormatController(IDataAccess dataAccess) + { + _repository = new DataFormatRepository(dataAccess); + } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexDataFormat ToFlex(DataFormat proto) => FlexMapper.ToFlex(proto); + protected override DataFormat ToProto(FlexDataFormat flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexDataFormat flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexDataFormat flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(DataFormat proto) => proto.Uuid; + + [HttpGet("list")] + public override async Task List([FromQuery] int offset = 0, [FromQuery] int max = 50) + { + var all = (await Repository.GetAll()) + .Where(df => df.DataFormatType == DataFormatType.Binary) + .ToList(); + var count = all.Count; + var page = all.Skip(offset).Take(max).Select(ToFlex).ToList(); + + return Ok(new FlexListResponse + { + Offset = offset, + Max = max, + Count = count, + InstanceList = page, + }); + } +} diff --git a/src/Aporta/Controllers/Flex/FlexCredController.cs b/src/Aporta/Controllers/Flex/FlexCredController.cs new file mode 100644 index 0000000..84e42b4 --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexCredController.cs @@ -0,0 +1,168 @@ +using System.Numerics; +using System.Text.Json; +using System.Threading.Tasks; +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Core.Hubs; +using Aporta.Shared.Messaging; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Z9.Protobuf; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("cred")] +public class FlexCredController : FlexCrudControllerBase +{ + private readonly Z9CredRepository _repository; + private readonly CredTemplateRepository _credTemplateRepository; + private readonly Z9EvtRepository _evtRepository; + private readonly Z9DevRepository _devRepository; + private readonly IHubContext _hubContext; + + public FlexCredController(IDataAccess dataAccess, IHubContext hubContext) + { + _repository = new Z9CredRepository(dataAccess); + _credTemplateRepository = new CredTemplateRepository(dataAccess); + _evtRepository = new Z9EvtRepository(dataAccess); + _devRepository = new Z9DevRepository(dataAccess); + _hubContext = hubContext; + } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexCred ToFlex(Cred proto) => FlexMapper.ToFlex(proto); + protected override Cred ToProto(FlexCred flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexCred flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexCred flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(Cred proto) => proto.Uuid; + + public override async Task Save([FromBody] FlexCred body) + { + if (body.Unid == null || body.Unid == 0) + body.Unid = await _repository.NextId(); + + if (!string.IsNullOrEmpty(body.Name)) + { + await EnsureDefaultCredTemplate(); + body.CredTemplate ??= new FlexObjRef { Unid = 1 }; + } + + var proto = ToProto(body); + SpCoreProtoUtil.InitRequired(proto); + await _repository.Upsert(proto); + + if (!string.IsNullOrEmpty(body.Name)) + await _hubContext.Clients.All.SendAsync(Methods.PersonInserted, body.Unid); + + return Ok(new FlexInstanceResponse { Instance = ToFlex(proto) }); + } + + public override async Task Delete(string id) + { + var result = await base.Delete(id); + + if (int.TryParse(id, out var unid)) + await _hubContext.Clients.All.SendAsync(Methods.PersonDeleted, unid); + + return result; + } + + [HttpPost("{credentialId:int}/enroll/{personId:int}")] + public async Task Enroll(int credentialId, int personId) + { + var swipeZ9Cred = await _repository.Get(credentialId); + var personZ9Cred = await _repository.Get(personId); + + if (swipeZ9Cred == null || personZ9Cred == null) + return NotFound(); + + if (swipeZ9Cred.CardPin != null) + personZ9Cred.CardPin = swipeZ9Cred.CardPin; + + personZ9Cred.PrivBindings.Add(swipeZ9Cred.PrivBindings); + + await _repository.Delete(credentialId); + await _repository.Upsert(personZ9Cred); + + await _hubContext.Clients.All.SendAsync(Methods.PersonUpdated, personId); + + return Ok(new FlexVoid()); + } + + [HttpPost("{personId:int}/enroll-from-read/{evtId:int}")] + public async Task EnrollFromRead(int personId, int evtId) + { + var personZ9Cred = await _repository.Get(personId); + if (personZ9Cred == null) + return NotFound("Person credential not found"); + + var evt = await _evtRepository.Get(evtId); + if (evt == null || evt.EvtCode != EvtCode.RawCredRead) + return NotFound("Raw read event not found"); + + var cardData = evt.Data; + if (string.IsNullOrEmpty(cardData)) + return BadRequest("Raw read event has no card data"); + + personZ9Cred.CardPin = new CardPin + { + CredNum = SpCoreProtoUtil.ToBigIntegerData(BigInteger.Parse(cardData)) + }; + + if (evt.EvtDevRef?.UnidCase == EvtDevRef.UnidOneofCase.Unid) + { + var readerDev = await _devRepository.Get(evt.EvtDevRef.Unid); + if (readerDev?.LogicalParentUnidCase == Dev.LogicalParentUnidOneofCase.LogicalParentUnid) + { + var doorDevUnid = readerDev.LogicalParentUnid; + var parentDev = await _devRepository.Get(doorDevUnid); + if (parentDev?.LogicalParentUnidCase == Dev.LogicalParentUnidOneofCase.LogicalParentUnid) + { + var grandparent = await _devRepository.Get(parentDev.LogicalParentUnid); + if (grandparent?.DevType == DevType.Door) + doorDevUnid = grandparent.Unid; + } + + personZ9Cred.PrivBindings.Add(new CredPrivBinding + { + DevAsDoorAccessPrivUnid = doorDevUnid + }); + } + } + + SpCoreProtoUtil.InitRequired(personZ9Cred); + await _repository.Upsert(personZ9Cred); + + await _evtRepository.MarkConsumed(evtId); + + await _hubContext.Clients.All.SendAsync(Methods.PersonUpdated, personId); + + return Ok(new FlexVoid()); + } + + private async Task EnsureDefaultCredTemplate() + { + var existing = await _credTemplateRepository.Get(1); + if (existing != null) return; + + var credTemplate = new CredTemplate + { + Unid = 1, + Name = "Card (Auto)", + Priority = 0, + CardPinTemplate = new CardPinTemplate + { + CredComponentPresence = CredComponentPresence.Required, + CredNumPresence = CredComponentPresence.Required, + PinPresence = CredComponentPresence.Absent, + AnyDataLayout = true + } + }; + SpCoreProtoUtil.InitRequired(credTemplate); + await _credTemplateRepository.Upsert(credTemplate); + } +} diff --git a/src/Aporta/Controllers/Flex/FlexCredTemplateController.cs b/src/Aporta/Controllers/Flex/FlexCredTemplateController.cs new file mode 100644 index 0000000..2945609 --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexCredTemplateController.cs @@ -0,0 +1,27 @@ +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("credTemplate")] +public class FlexCredTemplateController : FlexCrudControllerBase +{ + private readonly CredTemplateRepository _repository; + + public FlexCredTemplateController(IDataAccess dataAccess) + { + _repository = new CredTemplateRepository(dataAccess); + } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexCredTemplate ToFlex(CredTemplate proto) => FlexMapper.ToFlex(proto); + protected override CredTemplate ToProto(FlexCredTemplate flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexCredTemplate flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexCredTemplate flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(CredTemplate proto) => proto.Uuid; +} diff --git a/src/Aporta/Controllers/Flex/FlexCrudControllerBase.cs b/src/Aporta/Controllers/Flex/FlexCrudControllerBase.cs new file mode 100644 index 0000000..1059ba9 --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexCrudControllerBase.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models.Flex; +using Google.Protobuf; +using Microsoft.AspNetCore.Mvc; + +namespace Aporta.Controllers.Flex; + +public abstract class FlexCrudControllerBase : ControllerBase + where TProto : IMessage, new() +{ + protected abstract ProtoJsonRepository Repository { get; } + protected abstract TFlex ToFlex(TProto proto); + protected abstract TProto ToProto(TFlex flex); + protected abstract int GetUnid(TFlex flex); + protected abstract void SetUnid(TFlex flex, int unid); + + protected virtual async Task FindByIdString(string id) + { + if (int.TryParse(id, out var unid)) + return await Repository.Get(unid); + + // Try uuid lookup + var all = await Repository.GetAll(); + return all.FirstOrDefault(item => GetUuidFromProto(item) == id); + } + + protected virtual string GetUuidFromProto(TProto proto) => null; + + [HttpGet("list")] + public virtual async Task List([FromQuery] int offset = 0, [FromQuery] int max = 50) + { + var all = (await Repository.GetAll()).ToList(); + var count = all.Count; + var page = all.Skip(offset).Take(max).Select(ToFlex).ToList(); + + return Ok(new FlexListResponse + { + Offset = offset, + Max = max, + Count = count, + InstanceList = page, + }); + } + + [HttpGet("show/{id}")] + public virtual async Task Show(string id) + { + var proto = await FindByIdString(id); + if (proto == null) + return NotFound(); + + return Ok(new FlexInstanceResponse { Instance = ToFlex(proto) }); + } + + [HttpPost("save")] + public virtual async Task Save([FromBody] TFlex body) + { + if (GetUnid(body) == 0) + SetUnid(body, await Repository.NextId()); + + var proto = ToProto(body); + await Repository.Upsert(proto); + return Ok(new FlexInstanceResponse { Instance = ToFlex(proto) }); + } + + [HttpPost("update/{id}")] + public virtual async Task Update(string id, [FromBody] TFlex body) + { + var existing = await FindByIdString(id); + if (existing == null) + return NotFound(); + + var proto = ToProto(body); + await Repository.Upsert(proto); + return Ok(new FlexInstanceResponse { Instance = ToFlex(proto) }); + } + + [HttpPost("delete/{id}")] + public virtual async Task Delete(string id) + { + if (int.TryParse(id, out var unid)) + { + await Repository.Delete(unid); + return Ok(new FlexVoid()); + } + + var proto = await FindByIdString(id); + if (proto == null) + return NotFound(); + + await Repository.Delete(GetUnid(ToFlex(proto))); + return Ok(new FlexVoid()); + } +} diff --git a/src/Aporta/Controllers/Flex/FlexDataFormatController.cs b/src/Aporta/Controllers/Flex/FlexDataFormatController.cs new file mode 100644 index 0000000..1296686 --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexDataFormatController.cs @@ -0,0 +1,27 @@ +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("dataFormat")] +public class FlexDataFormatController : FlexCrudControllerBase +{ + private readonly DataFormatRepository _repository; + + public FlexDataFormatController(IDataAccess dataAccess) + { + _repository = new DataFormatRepository(dataAccess); + } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexDataFormat ToFlex(DataFormat proto) => FlexMapper.ToFlex(proto); + protected override DataFormat ToProto(FlexDataFormat flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexDataFormat flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexDataFormat flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(DataFormat proto) => proto.Uuid; +} diff --git a/src/Aporta/Controllers/Flex/FlexDataLayoutController.cs b/src/Aporta/Controllers/Flex/FlexDataLayoutController.cs new file mode 100644 index 0000000..b500b5a --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexDataLayoutController.cs @@ -0,0 +1,27 @@ +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("dataLayout")] +public class FlexDataLayoutController : FlexCrudControllerBase +{ + private readonly DataLayoutRepository _repository; + + public FlexDataLayoutController(IDataAccess dataAccess) + { + _repository = new DataLayoutRepository(dataAccess); + } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexDataLayout ToFlex(DataLayout proto) => FlexMapper.ToFlex(proto); + protected override DataLayout ToProto(FlexDataLayout flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexDataLayout flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexDataLayout flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(DataLayout proto) => proto.Uuid; +} diff --git a/src/Aporta/Controllers/Flex/FlexDevController.cs b/src/Aporta/Controllers/Flex/FlexDevController.cs new file mode 100644 index 0000000..fd2ae16 --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexDevController.cs @@ -0,0 +1,390 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Core.Hubs; +using Aporta.Shared.Messaging; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("dev")] +public class FlexDevController : FlexCrudControllerBase +{ + private readonly Z9DevRepository _repository; + + public FlexDevController(IDataAccess dataAccess) + { + _repository = new Z9DevRepository(dataAccess); + } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexDev ToFlex(Dev proto) => FlexMapper.ToFlex(proto); + protected override Dev ToProto(FlexDev flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexDev flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexDev flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(Dev proto) => proto.Uuid; +} + +/// +/// Base class for device-type-specific controllers that filter by DevType. +/// +public abstract class FlexDevTypeControllerBase : FlexCrudControllerBase +{ + private readonly Z9DevRepository _repository; + + protected FlexDevTypeControllerBase(IDataAccess dataAccess) + { + _repository = new Z9DevRepository(dataAccess); + } + + protected abstract DevType FilterDevType { get; } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexDev ToFlex(Dev proto) => FlexMapper.ToFlex(proto); + protected override Dev ToProto(FlexDev flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexDev flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexDev flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(Dev proto) => proto.Uuid; + + [HttpGet("list")] + public override async Task List([FromQuery] int offset = 0, [FromQuery] int max = 50) + { + var all = (await Repository.GetAll()) + .Where(d => d.DevType == FilterDevType) + .ToList(); + var count = all.Count; + var page = all.Skip(offset).Take(max).Select(ToFlex).ToList(); + + return Ok(new FlexListResponse + { + Offset = offset, + Max = max, + Count = count, + InstanceList = page, + }); + } +} + +[ApiController] +[Route("door")] +public class FlexDoorController : FlexDevTypeControllerBase +{ + private readonly Z9DevRepository _repository; + private readonly DoorConfigurationService _doorConfigurationService; + + public FlexDoorController(IDataAccess dataAccess, + DoorConfigurationService doorConfigurationService) : base(dataAccess) + { + _repository = new Z9DevRepository(dataAccess); + _doorConfigurationService = doorConfigurationService; + } + + protected override DevType FilterDevType => DevType.Door; + + [HttpGet("list")] + public override async Task List([FromQuery] int offset = 0, [FromQuery] int max = 50) + { + var doors = await _doorConfigurationService.GetAll(); + var all = doors.ToList(); + var count = all.Count; + var page = all.Skip(offset).Take(max).Select(d => + new FlexDev { Unid = d.Id, Name = d.Name }).ToList(); + + return Ok(new FlexListResponse + { + Offset = offset, + Max = max, + Count = count, + InstanceList = page, + }); + } + + [HttpPost("create")] + public async Task Create([FromBody] Aporta.Shared.Models.Door door) + { + await _doorConfigurationService.Insert(door); + return Ok(new FlexVoid()); + } + + public override async Task Delete(string id) + { + if (int.TryParse(id, out var unid)) + { + await _doorConfigurationService.Delete(unid); + return Ok(new FlexVoid()); + } + return BadRequest(); + } + + [HttpGet("available/readers")] + public async Task AvailableReaders() + { + var available = await _repository.GetAvailableByDevType(DevType.CredReader); + var list = available.Select(ToFlex).ToList(); + return Ok(new FlexListResponse + { + Offset = 0, + Max = list.Count, + Count = list.Count, + InstanceList = list, + }); + } + + [HttpGet("available/endpoints")] + public async Task AvailableEndpoints() + { + var endpoints = await _doorConfigurationService.AvailableEndPoints(); + var list = endpoints.Select(e => new FlexDev + { + Unid = e.Id, + Name = e.Name, + ExternalId = e.DriverEndpointId, + }).ToList(); + return Ok(new FlexListResponse + { + Offset = 0, + Max = list.Count, + Count = list.Count, + InstanceList = list, + }); + } +} + +[ApiController] +[Route("credReader")] +public class FlexCredReaderController : FlexDevTypeControllerBase +{ + public FlexCredReaderController(IDataAccess dataAccess) : base(dataAccess) { } + protected override DevType FilterDevType => DevType.CredReader; +} + +[ApiController] +[Route("controller")] +public class FlexControllerController : FlexDevTypeControllerBase +{ + public FlexControllerController(IDataAccess dataAccess) : base(dataAccess) { } + protected override DevType FilterDevType => DevType.IoController; +} + +[ApiController] +[Route("sensor")] +public class FlexSensorController : FlexDevTypeControllerBase +{ + private readonly Z9DevRepository _repository; + private readonly IHubContext _hubContext; + private readonly ExtensionService _extensionService; + + public FlexSensorController(IDataAccess dataAccess, + IHubContext hubContext, + ExtensionService extensionService) : base(dataAccess) + { + _repository = new Z9DevRepository(dataAccess); + _hubContext = hubContext; + _extensionService = extensionService; + } + + protected override DevType FilterDevType => DevType.Sensor; + + [HttpGet("list")] + public override async Task List([FromQuery] int offset = 0, [FromQuery] int max = 50) + { + var all = (await Repository.GetAll()) + .Where(d => d.DevType == DevType.Sensor && d.Enabled) + .ToList(); + var count = all.Count; + var page = all.Skip(offset).Take(max).Select(ToFlex).ToList(); + + return Ok(new FlexListResponse + { + Offset = offset, + Max = max, + Count = count, + InstanceList = page, + }); + } + + public override async Task Save([FromBody] FlexDev body) + { + var poolDev = await _repository.Get(body.Unid ?? 0); + if (poolDev == null) + return NotFound(); + + poolDev.Name = body.Name; + poolDev.Enabled = true; + await _repository.Upsert(poolDev); + + await _hubContext.Clients.All.SendAsync(Methods.InputInserted, poolDev.Unid); + + return Ok(new FlexInstanceResponse { Instance = ToFlex(poolDev) }); + } + + public override async Task Delete(string id) + { + var result = await base.Delete(id); + + if (int.TryParse(id, out var unid)) + await _hubContext.Clients.All.SendAsync(Methods.InputDeleted, unid); + + return result; + } + + [HttpGet("available")] + public async Task Available() + { + var available = await _repository.GetAvailableByDevType(DevType.Sensor); + var list = available.Select(ToFlex).ToList(); + return Ok(new FlexListResponse + { + Offset = 0, + Max = list.Count, + Count = list.Count, + InstanceList = list, + }); + } + + [HttpGet("state/{id:int}")] + public async Task GetState(int id) + { + var dev = await _repository.Get(id); + if (dev == null) + return NotFound(); + + var extensionId = await _repository.GetExtensionId(dev); + if (!extensionId.HasValue) + return Ok(new { state = (bool?)null }); + + var state = await _extensionService.GetMonitorPoint(extensionId.Value, dev.ExternalId).GetState(); + return Ok(new { state }); + } +} + +[ApiController] +[Route("actuator")] +public class FlexActuatorController : FlexDevTypeControllerBase +{ + private readonly Z9DevRepository _repository; + private readonly IHubContext _hubContext; + private readonly ExtensionService _extensionService; + private readonly DevStateService _devStateService; + + public FlexActuatorController(IDataAccess dataAccess, + IHubContext hubContext, + ExtensionService extensionService, + DevStateService devStateService) : base(dataAccess) + { + _repository = new Z9DevRepository(dataAccess); + _hubContext = hubContext; + _extensionService = extensionService; + _devStateService = devStateService; + } + + protected override DevType FilterDevType => DevType.Actuator; + + [HttpGet("list")] + public override async Task List([FromQuery] int offset = 0, [FromQuery] int max = 50) + { + var all = (await Repository.GetAll()) + .Where(d => d.DevType == DevType.Actuator && d.Enabled) + .ToList(); + var count = all.Count; + var page = all.Skip(offset).Take(max).Select(ToFlex).ToList(); + + return Ok(new FlexListResponse + { + Offset = offset, + Max = max, + Count = count, + InstanceList = page, + }); + } + + public override async Task Save([FromBody] FlexDev body) + { + var poolDev = await _repository.Get(body.Unid ?? 0); + if (poolDev == null) + return NotFound(); + + poolDev.Name = body.Name; + poolDev.Enabled = true; + await _repository.Upsert(poolDev); + + await _hubContext.Clients.All.SendAsync(Methods.OutputInserted, poolDev.Unid); + + return Ok(new FlexInstanceResponse { Instance = ToFlex(poolDev) }); + } + + public override async Task Delete(string id) + { + var result = await base.Delete(id); + + if (int.TryParse(id, out var unid)) + await _hubContext.Clients.All.SendAsync(Methods.OutputDeleted, unid); + + return result; + } + + [HttpGet("available")] + public async Task Available() + { + var available = await _repository.GetAvailableByDevType(DevType.Actuator); + var list = available.Select(ToFlex).ToList(); + return Ok(new FlexListResponse + { + Offset = 0, + Max = list.Count, + Count = list.Count, + InstanceList = list, + }); + } + + [HttpGet("state/{id:int}")] + public async Task GetState(int id) + { + var dev = await _repository.Get(id); + if (dev == null) + return NotFound(); + + var extensionId = await _repository.GetExtensionId(dev); + if (!extensionId.HasValue) + return Ok(new { state = (bool?)null }); + + var state = await _extensionService.GetControlPoint(extensionId.Value, dev.ExternalId).GetState(); + return Ok(new { state }); + } + + [HttpPost("state/{id:int}")] + public async Task SetState(int id, [FromQuery] bool state) + { + var dev = await _repository.Get(id); + if (dev == null) + return NotFound(); + + var extensionId = await _repository.GetExtensionId(dev); + if (!extensionId.HasValue) + return NotFound(); + + await _extensionService.GetControlPoint(extensionId.Value, dev.ExternalId).SetState(state); + + await _hubContext.Clients.All.SendAsync(Methods.OutputStateChanged, dev.Unid, state); + + _devStateService.UpdateAspect(dev.Unid, DevAspect.Primary, + s => s.ActivityState = state ? ActivityState.Active : ActivityState.Inactive); + + return Ok(new FlexVoid()); + } +} + +[ApiController] +[Route("nodeDev")] +public class FlexNodeDevController : FlexDevTypeControllerBase +{ + public FlexNodeDevController(IDataAccess dataAccess) : base(dataAccess) { } + protected override DevType FilterDevType => DevType.Reserved0; +} diff --git a/src/Aporta/Controllers/Flex/FlexDevStateRecordController.cs b/src/Aporta/Controllers/Flex/FlexDevStateRecordController.cs new file mode 100644 index 0000000..9ea7975 --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexDevStateRecordController.cs @@ -0,0 +1,33 @@ +using System.Linq; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("devStateRecord")] +public class FlexDevStateRecordController : ControllerBase +{ + private readonly DevStateService _devStateService; + + public FlexDevStateRecordController(DevStateService devStateService) + { + _devStateService = devStateService; + } + + [HttpGet("list")] + public IActionResult List([FromQuery] int offset = 0, [FromQuery] int max = 50) + { + var all = _devStateService.GetAllDevStateRecords(); + var paged = all.Skip(offset).Take(max).Select(FlexMapper.ToFlex).ToList(); + + return Ok(new FlexListResponse + { + Offset = offset, + Max = max, + Count = all.Count, + InstanceList = paged, + }); + } +} diff --git a/src/Aporta/Controllers/Flex/FlexDoorAccessPrivController.cs b/src/Aporta/Controllers/Flex/FlexDoorAccessPrivController.cs new file mode 100644 index 0000000..4cf4ecb --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexDoorAccessPrivController.cs @@ -0,0 +1,27 @@ +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("doorAccessPriv")] +public class FlexDoorAccessPrivController : FlexCrudControllerBase +{ + private readonly PrivRepository _repository; + + public FlexDoorAccessPrivController(IDataAccess dataAccess) + { + _repository = new PrivRepository(dataAccess); + } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexPriv ToFlex(Priv proto) => FlexMapper.ToFlex(proto); + protected override Priv ToProto(FlexPriv flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexPriv flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexPriv flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(Priv proto) => proto.Uuid; +} diff --git a/src/Aporta/Controllers/Flex/FlexEncryptionKeyController.cs b/src/Aporta/Controllers/Flex/FlexEncryptionKeyController.cs new file mode 100644 index 0000000..ab69e1e --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexEncryptionKeyController.cs @@ -0,0 +1,27 @@ +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("encryptionKey")] +public class FlexEncryptionKeyController : FlexCrudControllerBase +{ + private readonly EncryptionKeyRepository _repository; + + public FlexEncryptionKeyController(IDataAccess dataAccess) + { + _repository = new EncryptionKeyRepository(dataAccess); + } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexEncryptionKey ToFlex(EncryptionKey proto) => FlexMapper.ToFlex(proto); + protected override EncryptionKey ToProto(FlexEncryptionKey flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexEncryptionKey flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexEncryptionKey flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(EncryptionKey proto) => proto.Uuid; +} diff --git a/src/Aporta/Controllers/Flex/FlexEvtController.cs b/src/Aporta/Controllers/Flex/FlexEvtController.cs new file mode 100644 index 0000000..488ed2f --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexEvtController.cs @@ -0,0 +1,65 @@ +using System.Linq; +using System.Threading.Tasks; +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("evt")] +public class FlexEvtController : ControllerBase +{ + private readonly Z9EvtRepository _repository; + + public FlexEvtController(IDataAccess dataAccess) + { + _repository = new Z9EvtRepository(dataAccess); + } + + [HttpGet("list")] + public async Task List([FromQuery] int offset = 0, [FromQuery] int max = 50) + { + // Z9EvtRepository uses page-based pagination; convert offset/max + var pageNumber = (offset / max) + 1; + var page = await _repository.GetAll(pageNumber, max); + + return Ok(new FlexListResponse + { + Offset = offset, + Max = max, + Count = page.TotalItems, + InstanceList = page.Items.Select(FlexMapper.ToFlex).ToList(), + }); + } + + [HttpGet("raw-reads")] + public async Task RawReads() + { + var rawReads = await _repository.GetUnconsumedRawReads(); + + return Ok(new FlexListResponse + { + Offset = 0, + Max = 100, + Count = rawReads.Count(), + InstanceList = rawReads.Select(FlexMapper.ToFlex).ToList(), + }); + } + + [HttpGet("show/{id}")] + public async Task Show(string id) + { + if (!int.TryParse(id, out var unid)) + return NotFound(); + + var evt = await _repository.Get(unid); + if (evt == null) + return NotFound(); + + return Ok(new FlexInstanceResponse { Instance = FlexMapper.ToFlex(evt) }); + } +} diff --git a/src/Aporta/Controllers/Flex/FlexHolCalController.cs b/src/Aporta/Controllers/Flex/FlexHolCalController.cs new file mode 100644 index 0000000..3efa791 --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexHolCalController.cs @@ -0,0 +1,27 @@ +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("holCal")] +public class FlexHolCalController : FlexCrudControllerBase +{ + private readonly HolCalRepository _repository; + + public FlexHolCalController(IDataAccess dataAccess) + { + _repository = new HolCalRepository(dataAccess); + } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexHolCal ToFlex(HolCal proto) => FlexMapper.ToFlex(proto); + protected override HolCal ToProto(FlexHolCal flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexHolCal flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexHolCal flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(HolCal proto) => proto.Uuid; +} diff --git a/src/Aporta/Controllers/Flex/FlexHolController.cs b/src/Aporta/Controllers/Flex/FlexHolController.cs new file mode 100644 index 0000000..ddd238f --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexHolController.cs @@ -0,0 +1,27 @@ +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("hol")] +public class FlexHolController : FlexCrudControllerBase +{ + private readonly HolRepository _repository; + + public FlexHolController(IDataAccess dataAccess) + { + _repository = new HolRepository(dataAccess); + } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexHol ToFlex(Hol proto) => FlexMapper.ToFlex(proto); + protected override Hol ToProto(FlexHol flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexHol flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexHol flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(Hol proto) => proto.Uuid; +} diff --git a/src/Aporta/Controllers/Flex/FlexHolTypeController.cs b/src/Aporta/Controllers/Flex/FlexHolTypeController.cs new file mode 100644 index 0000000..9726038 --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexHolTypeController.cs @@ -0,0 +1,27 @@ +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("holType")] +public class FlexHolTypeController : FlexCrudControllerBase +{ + private readonly HolTypeRepository _repository; + + public FlexHolTypeController(IDataAccess dataAccess) + { + _repository = new HolTypeRepository(dataAccess); + } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexHolType ToFlex(HolType proto) => FlexMapper.ToFlex(proto); + protected override HolType ToProto(FlexHolType flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexHolType flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexHolType flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(HolType proto) => proto.Uuid; +} diff --git a/src/Aporta/Controllers/Flex/FlexSchedController.cs b/src/Aporta/Controllers/Flex/FlexSchedController.cs new file mode 100644 index 0000000..8d3af47 --- /dev/null +++ b/src/Aporta/Controllers/Flex/FlexSchedController.cs @@ -0,0 +1,27 @@ +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models.Flex; +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Z9.Spcore.Proto; + +namespace Aporta.Controllers.Flex; + +[ApiController] +[Route("sched")] +public class FlexSchedController : FlexCrudControllerBase +{ + private readonly SchedRepository _repository; + + public FlexSchedController(IDataAccess dataAccess) + { + _repository = new SchedRepository(dataAccess); + } + + protected override ProtoJsonRepository Repository => _repository; + protected override FlexSched ToFlex(Sched proto) => FlexMapper.ToFlex(proto); + protected override Sched ToProto(FlexSched flex) => FlexMapper.ToProto(flex); + protected override int GetUnid(FlexSched flex) => flex.Unid ?? 0; + protected override void SetUnid(FlexSched flex, int unid) => flex.Unid = unid; + protected override string GetUuidFromProto(Sched proto) => proto.Uuid; +} diff --git a/src/Aporta/Controllers/InputsController.cs b/src/Aporta/Controllers/InputsController.cs deleted file mode 100644 index 696e2ae..0000000 --- a/src/Aporta/Controllers/InputsController.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Aporta.Core.Services; -using Aporta.Shared.Models; -using Microsoft.AspNetCore.Mvc; - -namespace Aporta.Controllers; - -[ApiController] -[Route("api/[controller]")] -public class InputsController : ControllerBase -{ - private readonly InputService _inputService; - - public InputsController(InputService inputService) - { - _inputService = inputService; - } - - [HttpGet] - public async Task> Get() - { - return await _inputService.GetAll(); - } - - [HttpGet("{inputId}")] - public async Task Get(int inputId) - { - return await _inputService.Get(inputId); - } - - [HttpPut] - public async Task Put([FromBody]Input input) - { - await _inputService.Insert(input); - } - - [HttpDelete("{inputId:int}")] - public async Task Delete(int inputId) - { - await _inputService.Delete(inputId); - } - - [HttpGet("available")] - public async Task> Available() - { - return await _inputService.AvailableMonitorPoints(); - } - - [HttpGet("state/{inputId:int}")] - public async Task GetState(int inputId) - { - return await _inputService.GetState(inputId); - } -} \ No newline at end of file diff --git a/src/Aporta/Controllers/OutputsController.cs b/src/Aporta/Controllers/OutputsController.cs deleted file mode 100644 index 9eada93..0000000 --- a/src/Aporta/Controllers/OutputsController.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Aporta.Core.Services; -using Aporta.Shared.Models; -using Microsoft.AspNetCore.Mvc; - -namespace Aporta.Controllers; - -[ApiController] -[Route("api/[controller]")] -public class OutputsController : ControllerBase -{ - private readonly OutputService _outputService; - - public OutputsController(OutputService outputService) - { - _outputService = outputService; - } - - [HttpGet] - public async Task> Get() - { - return await _outputService.GetAll(); - } - - [HttpGet("{outputId}")] - public async Task Get(int outputId) - { - return await _outputService.Get(outputId); - } - - [HttpPut] - public async Task Put([FromBody]Output output) - { - await _outputService.Insert(output); - } - - [HttpDelete("{outputId:int}")] - public async Task Delete(int outputId) - { - await _outputService.Delete(outputId); - } - - [HttpGet("available")] - public async Task> Available() - { - return await _outputService.AvailableControlPoints(); - } - - [HttpGet("set/{outputId:int}")] - public async Task GetState(int outputId) - { - return await _outputService.GetState(outputId); - } - - [HttpPost("set/{outputId:int}")] - public async Task SetState(int outputId, [FromQuery]bool state) - { - await _outputService.SetState(outputId, state); - - return NoContent(); - } -} \ No newline at end of file diff --git a/src/Aporta/Controllers/PeopleController.cs b/src/Aporta/Controllers/PeopleController.cs deleted file mode 100644 index c49ad16..0000000 --- a/src/Aporta/Controllers/PeopleController.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Aporta.Core.Services; -using Microsoft.AspNetCore.Mvc; - -using Aporta.Shared.Models; - -namespace Aporta.Controllers; - -[ApiController] -[Route("api/[controller]")] -public class PeopleController -{ - private readonly PeopleService _peopleService; - - public PeopleController(PeopleService peopleService) - { - _peopleService = peopleService; - } - - [HttpGet] - public async Task> Get() - { - return await _peopleService.GetAll(); - } - - [HttpGet("{personId}")] - public async Task Get(int personId) - { - return await _peopleService.Get(personId); - } - - [HttpPut] - public async Task Put([FromBody]Person person) - { - await _peopleService.Insert(person); - } - - [HttpDelete("{personId:int}")] - public async Task Delete(int personId) - { - await _peopleService.Delete(personId); - } -} \ No newline at end of file diff --git a/src/Aporta/Filters/FlexAuthFilter.cs b/src/Aporta/Filters/FlexAuthFilter.cs new file mode 100644 index 0000000..6ce8596 --- /dev/null +++ b/src/Aporta/Filters/FlexAuthFilter.cs @@ -0,0 +1,39 @@ +using Aporta.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Aporta.Filters; + +public class FlexAuthFilter : IActionFilter +{ + private readonly FlexSessionService _sessionService; + + public FlexAuthFilter(FlexSessionService sessionService) + { + _sessionService = sessionService; + } + + public void OnActionExecuting(ActionExecutingContext context) + { + var controllerType = context.Controller.GetType(); + + // Only apply to Flex controllers (namespace Aporta.Controllers.Flex) + if (controllerType.Namespace == null || + !controllerType.Namespace.StartsWith("Aporta.Controllers.Flex")) + return; + + // Skip auth check for authenticate/terminate controller + if (controllerType.Name == "FlexAuthenticateController") + return; + + var token = context.HttpContext.Request.Headers["sessionToken"].ToString(); + if (!_sessionService.ValidateSession(token)) + { + context.Result = new UnauthorizedResult(); + } + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } +} diff --git a/src/Aporta/Startup.cs b/src/Aporta/Startup.cs index 6e8fe42..9f4dce3 100644 --- a/src/Aporta/Startup.cs +++ b/src/Aporta/Startup.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using NReco.Logging.File; @@ -21,6 +22,13 @@ namespace Aporta; public class Startup { + private readonly IConfiguration _configuration; + + public Startup(IConfiguration configuration) + { + _configuration = configuration; + } + // This method gets called by the runtime. Use this method to add services to the container. // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 public void ConfigureServices(IServiceCollection services) @@ -33,17 +41,20 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSignalR(); - services.AddControllersWithViews(); + services.AddControllersWithViews(options => + { + options.Filters.Add(); + }); services.AddRazorPages(); services.AddResponseCompression(opts => { @@ -51,12 +62,13 @@ public void ConfigureServices(IServiceCollection services) new[] { "application/octet-stream" }); }); - // For nix based OSs, write logs to /var/log + // For nix based OSs, write logs to file if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + var logFilePath = _configuration.GetValue("Logging:File:Path") ?? "/var/log/aporta.log"; services.AddLogging(loggingBuilder => { - loggingBuilder.AddFile("/var/log/aporta.log", options => + loggingBuilder.AddFile(logFilePath, options => { options.Append = true; options.MaxRollingFiles = 10; diff --git a/src/Aporta/Workers/StartupWorker.cs b/src/Aporta/Workers/StartupWorker.cs index c27dbaa..eb4576b 100644 --- a/src/Aporta/Workers/StartupWorker.cs +++ b/src/Aporta/Workers/StartupWorker.cs @@ -1,31 +1,65 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; using Aporta.Core.Services; +using Aporta.Drivers.OSDP.Shared; +using Aporta.Extensions.Hardware; +using Aporta.Shared.Models; +using Z9.Protobuf; +using Z9.Spcore.Proto; +using Aporta.Drivers.OSDP.Shared.Actions; +using Dapper; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Newtonsoft.Json; namespace Aporta.Workers; public class StartupWorker : BackgroundService { + private const int DefaultZ9OpenCommunityPort = 9723; + private static readonly Guid OsdpDriverId = Guid.Parse("D3C5DE68-E019-48D6-AB58-76F4B15CD0D5"); + private readonly IHostApplicationLifetime _applicationLifetime; private readonly ILogger _logger; + private readonly IConfiguration _configuration; private readonly IDataAccess _dataAccess; private readonly ExtensionService _extensionService; private readonly AccessService _accessService; + private readonly Z9OpenCommunityProtocolService _z9OpenCommunityProtocolService; + private readonly DevStateService _devStateService; + private readonly Z9DevRepository _z9DevRepository; + private readonly HashSet _configuredOsdpBuses = new(); + private readonly Dictionary _doorStrikeActive = new(); + private readonly Dictionary _doorForced = new(); + private readonly Dictionary _doorHeld = new(); + private readonly Dictionary _doorUseExtendedTime = new(); + private readonly Dictionary _doorHeldTimers = new(); + private readonly Dictionary _doorCurrentMode = new(); - public StartupWorker(IDataAccess dataAccess, - ExtensionService extensionService, + public StartupWorker(IDataAccess dataAccess, + ExtensionService extensionService, AccessService accessService, + Z9OpenCommunityProtocolService z9OpenCommunityProtocolService, + DevStateService devStateService, + IConfiguration configuration, ILogger logger, IHostApplicationLifetime applicationLifetime) { _dataAccess = dataAccess; _extensionService = extensionService; _accessService = accessService; + _z9OpenCommunityProtocolService = z9OpenCommunityProtocolService; + _devStateService = devStateService; + _configuration = configuration; _logger = logger; _applicationLifetime = applicationLifetime; + _z9DevRepository = new Z9DevRepository(dataAccess); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -33,23 +67,1038 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _logger.LogInformation("Application is starting up services"); try { + // Check for --cleanDatabase argument to delete existing database + var cleanDatabase = _configuration.GetValue("cleanDatabase"); + if (cleanDatabase) + { + DeleteDatabaseFile(); + } + await _dataAccess.UpdateSchema(); + + // Check if we're in Z9 Open Community mode or primary config mode + // BEFORE starting extensions — prevents OSDP from auto-connecting using stale saved configuration + var z9OpenCommunityHost = _configuration["z9OpenCommunityHost"]; + var isZ9OpenCommunityMode = !string.IsNullOrWhiteSpace(z9OpenCommunityHost); + var primaryConfigPath = _configuration["primaryConfig"]; + var isPrimaryConfigMode = !string.IsNullOrWhiteSpace(primaryConfigPath); + if (isZ9OpenCommunityMode || isPrimaryConfigMode) + { + await DisableOsdpExtensionForZ9OpenCommunityMode(); + } + await _extensionService.Startup(); _accessService.Startup(); + + if (isZ9OpenCommunityMode) + { + var z9OpenCommunityPort = int.TryParse(_configuration["z9OpenCommunityPort"], out var port) + ? port + : DefaultZ9OpenCommunityPort; + var z9OpenCommunityId = _configuration["z9OpenCommunityId"]; + + // Wire up door unid lookup for access privilege checking + _accessService.SetDoorUnidLookup(endpointId => + _z9OpenCommunityProtocolService.GetConfigForEndpoint(endpointId)?.DoorUnid); + + // Wire up strike time lookup for access grant strike duration + _accessService.SetStrikeTimeLookup(endpointId => + { + var config = _z9OpenCommunityProtocolService.GetConfigForEndpoint(endpointId); + return (config?.StrikeTimeMs, config?.ExtendedStrikeTimeMs); + }); + + // Wire up door mode lookup for access decisions + _accessService.SetDoorModeLookup(endpointId => + { + var config = _z9OpenCommunityProtocolService.GetConfigForEndpoint(endpointId); + if (config?.DoorUnid == null) return null; + return _doorCurrentMode.TryGetValue(config.DoorUnid.Value, out var mode) ? mode : null; + }); + + // Wire up card data decoder: decode raw bits to credNum for credential matching + _accessService.SetCardDataDecoder(rawBits => + _z9OpenCommunityProtocolService.DecodeCardRead(rawBits)?.credNum.ToString()); + + // Subscribe to OSDP configuration events before starting the service + _z9OpenCommunityProtocolService.OsdpConfigurationReceived += OnOsdpConfigurationReceived; + + // Subscribe to device online status changes to forward via community protocol + _extensionService.OnlineStatusChanged += OnOnlineStatusChanged; + + // Subscribe to local status changes (tamper, power cycle) from OSDP readers + _extensionService.LocalStatusChanged += OnLocalStatusChanged; + + // Subscribe to access decisions to forward via community protocol + _accessService.AccessDecisionMade += OnAccessDecisionMade; + + // Subscribe to device action requests from the host + _z9OpenCommunityProtocolService.DevActionRequested += OnDevActionRequested; + + // Subscribe to state changes (door strike output, door contact input) for door state events + _extensionService.StateChanged += OnStateChanged; + + _z9OpenCommunityProtocolService.Start(z9OpenCommunityHost, z9OpenCommunityPort, z9OpenCommunityId); + } + else + { + _logger.LogInformation( + "Z9/Open Community protocol service not started" + + " (use --z9OpenCommunityHost to specify upstream host)"); + + // Primary config mode: configure OSDP from a JSON file + if (isPrimaryConfigMode) + { + await ConfigurePrimaryMode(primaryConfigPath); + _z9OpenCommunityProtocolService.StartScheduleEvaluation(); + } + } } catch (Exception exception) { _logger.LogCritical(exception, "An error occurred during startup"); _applicationLifetime.StopApplication(); } - + _logger.LogInformation("Application completed startup routine"); - + stoppingToken.Register(() => { _logger.LogWarning("Application is shutting down"); + CancelAllDoorHeldTimers(); + _z9OpenCommunityProtocolService.StopScheduleEvaluation(); + _accessService.AccessDecisionMade -= OnAccessDecisionMade; + _extensionService.StateChanged -= OnStateChanged; + _z9OpenCommunityProtocolService.DevActionRequested -= OnDevActionRequested; + _extensionService.LocalStatusChanged -= OnLocalStatusChanged; + _extensionService.OnlineStatusChanged -= OnOnlineStatusChanged; + _z9OpenCommunityProtocolService.OsdpConfigurationReceived -= OnOsdpConfigurationReceived; + _z9OpenCommunityProtocolService.Stop(); _accessService.Shutdown(); _extensionService.Shutdown(); }); } -} \ No newline at end of file + + private void DeleteDatabaseFile() + { + // Database file is stored in Data/Aporta.sqlite relative to the assembly location + var assemblyPath = System.Reflection.Assembly.GetEntryAssembly()?.Location; + var dataDir = System.IO.Path.GetDirectoryName(assemblyPath) ?? Environment.CurrentDirectory; + var dbPath = System.IO.Path.Combine(dataDir, "Data", "Aporta.sqlite"); + + if (System.IO.File.Exists(dbPath)) + { + _logger.LogInformation("Deleting existing database file: {DbPath}", dbPath); + System.IO.File.Delete(dbPath); + } + else + { + _logger.LogDebug("No database file to delete at: {DbPath}", dbPath); + } + } + + private async Task DisableOsdpExtensionForZ9OpenCommunityMode() + { + // Use direct database access because ExtensionService hasn't been started yet + // This ensures OSDP extension won't be loaded at startup with stale config + using var connection = _dataAccess.CreateDbConnection(); + connection.Open(); + + var data = await connection.QueryFirstOrDefaultAsync( + "SELECT data FROM extension WHERE id = @id", + new { id = OsdpDriverId.ToString() }); + + if (data == null) + { + _logger.LogDebug("OSDP extension not found in database, nothing to disable"); + return; + } + + _logger.LogInformation( + "Z9 Open Community mode: Disabling OSDP extension and clearing saved configuration"); + + // Set enabled=false AND clear the configuration to prevent stale buses/devices + // from being loaded when the extension is re-enabled + using var stream = new System.IO.MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + + using var doc = JsonDocument.Parse(data); + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (prop.Name == "enabled") + writer.WriteBoolean("enabled", false); + else if (prop.Name == "configuration") + writer.WriteNull("configuration"); // Clear stale config + else + prop.WriteTo(writer); + } + writer.WriteEndObject(); + writer.Flush(); + var newData = System.Text.Encoding.UTF8.GetString(stream.ToArray()); + + await connection.ExecuteAsync( + "UPDATE extension SET data = @data WHERE id = @id", + new { id = OsdpDriverId.ToString(), data = newData }); + } + + /// + /// Configuration model for --primaryConfig JSON file. + /// + public class PrimaryConfig + { + [System.Text.Json.Serialization.JsonPropertyName("readers")] + public List Readers { get; set; } = new(); + } + + public class PrimaryReaderConfig + { + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("host")] + public string Host { get; set; } = "localhost"; + + [System.Text.Json.Serialization.JsonPropertyName("tcpPort")] + public int TcpPort { get; set; } = 9843; + + [System.Text.Json.Serialization.JsonPropertyName("osdpAddress")] + public int OsdpAddress { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("baudRate")] + public int BaudRate { get; set; } = 9600; + + [System.Text.Json.Serialization.JsonPropertyName("strikeOutputNumber")] + public int? StrikeOutputNumber { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("doorContactInputNumber")] + public int? DoorContactInputNumber { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("rexInputNumber")] + public int? RexInputNumber { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("activateStrikeOnRex")] + public bool ActivateStrikeOnRex { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("strikeTimeMs")] + public int? StrikeTimeMs { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("extendedStrikeTimeMs")] + public int? ExtendedStrikeTimeMs { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("heldTimeMs")] + public int? HeldTimeMs { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("extendedHeldTimeMs")] + public int? ExtendedHeldTimeMs { get; set; } + } + + private async Task ConfigurePrimaryMode(string configPath) + { + _logger.LogInformation("Loading primary config from {Path}", configPath); + + var json = await System.IO.File.ReadAllTextAsync(configPath); + var primaryConfig = System.Text.Json.JsonSerializer.Deserialize(json); + + if (primaryConfig?.Readers == null || primaryConfig.Readers.Count == 0) + { + _logger.LogWarning("Primary config has no readers defined"); + return; + } + + // Wire strike time lookup + _accessService.SetStrikeTimeLookup(endpointId => + { + var config = _z9OpenCommunityProtocolService.GetConfigForEndpoint(endpointId); + return (config?.StrikeTimeMs, config?.ExtendedStrikeTimeMs); + }); + + // Subscribe to online status changes for auto Door creation + _extensionService.OnlineStatusChanged += OnOnlineStatusChanged; + + // Subscribe to local status changes (tamper, power cycle) from OSDP readers + _extensionService.LocalStatusChanged += OnLocalStatusChanged; + + // Subscribe to state changes for door contact/REX/strike tracking + _extensionService.StateChanged += OnStateChanged; + + // Convert and register each reader config, then configure the OSDP driver + for (int i = 0; i < primaryConfig.Readers.Count; i++) + { + var reader = primaryConfig.Readers[i]; + var osdpConfig = new Z9OpenCommunityProtocolService.OsdpReaderConfig + { + Unid = i + 1, + Name = reader.Name ?? $"Reader {i + 1}", + Host = reader.Host, + TcpPort = reader.TcpPort, + OsdpAddress = reader.OsdpAddress, + BaudRate = reader.BaudRate, + StrikeOutputNumber = reader.StrikeOutputNumber, + DoorContactInputNumber = reader.DoorContactInputNumber, + RexInputNumber = reader.RexInputNumber, + ActivateStrikeOnRex = reader.ActivateStrikeOnRex, + StrikeTimeMs = reader.StrikeTimeMs, + ExtendedStrikeTimeMs = reader.ExtendedStrikeTimeMs, + HeldTimeMs = reader.HeldTimeMs, + ExtendedHeldTimeMs = reader.ExtendedHeldTimeMs + }; + + _z9OpenCommunityProtocolService.RegisterOsdpReaderConfig(osdpConfig); + await ConfigureOsdpDriver(osdpConfig); + } + + _logger.LogInformation("Primary config loaded: {Count} reader(s) configured", primaryConfig.Readers.Count); + } + + private void OnOsdpConfigurationReceived(object sender, Z9OpenCommunityProtocolService.OsdpReaderConfig config) + { + Task.Run(async () => + { + try + { + await ConfigureOsdpDriver(config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to configure OSDP driver for reader {Name}", config.Name); + } + }); + } + + private async Task ConfigureOsdpDriver(Z9OpenCommunityProtocolService.OsdpReaderConfig config) + { + _logger.LogInformation( + "Configuring OSDP driver for reader: {Name} at {Host}:{TcpPort} address={OsdpAddress}", + config.Name, config.Host, config.TcpPort, config.OsdpAddress); + + // Check if OSDP driver extension is available and enabled + var extensions = _extensionService.GetExtensions().ToList(); + var osdpExtension = extensions.FirstOrDefault(e => e.Id == OsdpDriverId); + + if (osdpExtension == null) + { + _logger.LogWarning("OSDP driver extension not found"); + return; + } + + if (!osdpExtension.Enabled) + { + _logger.LogInformation("Enabling OSDP driver extension"); + await _extensionService.EnableExtension(OsdpDriverId, true); + } + + // Create a unique bus identifier based on host:port + var busKey = $"{config.Host}:{config.TcpPort}"; + + // Only add the bus once per host:port combination + if (!_configuredOsdpBuses.Contains(busKey)) + { + _logger.LogInformation("Adding TCP OSDP bus: {BusKey}", busKey); + + var bus = new Bus + { + ConnectionType = ConnectionType.Tcp, + PortName = busKey, // Use as identifier + TcpHost = config.Host, + TcpPort = config.TcpPort, + BaudRate = config.BaudRate + }; + + var busAction = new BusAction { Bus = bus }; + await _extensionService.PerformAction(OsdpDriverId, ActionType.AddSerialBus.ToString(), + JsonConvert.SerializeObject(busAction)); + + _configuredOsdpBuses.Add(busKey); + } + + // Add the device to the bus + _logger.LogInformation("Adding OSDP device: {Name} address={OsdpAddress}", config.Name, config.OsdpAddress); + + var device = new Device + { + Name = config.Name, + Address = (byte)config.OsdpAddress, + PortName = busKey, // Reference to bus + RequireSecurity = false // Start without secure channel for testing + }; + + var deviceAction = new DeviceAction { Device = device }; + await _extensionService.PerformAction(OsdpDriverId, ActionType.AddUpdateDevice.ToString(), + JsonConvert.SerializeObject(deviceAction)); + + _logger.LogInformation("OSDP driver configured for reader: {Name}", config.Name); + } + + private void OnOnlineStatusChanged(object sender, OnlineStatusChangedEventArgs e) + { + // Only process reader endpoints (e.g., "localhost:9843:0:R0") + // Skip output endpoints (e.g., "localhost:9843:0:O0") which share the same prefix + var endpointId = e.Endpoint?.Id; + if (string.IsNullOrEmpty(endpointId) || !IsReaderEndpoint(endpointId)) + { + _logger.LogDebug("Skipping non-reader endpoint {EndpointId}", endpointId); + return; + } + + // Get the Z9 config for this endpoint to send event with correct device unid + var config = _z9OpenCommunityProtocolService.GetConfigForEndpoint(endpointId); + if (config == null) + { + _logger.LogDebug("No Z9 config found for endpoint {EndpointId}, skipping event", + endpointId); + return; + } + + _logger.LogInformation("OSDP reader {Name} is now {Status}", + config.Name, e.IsOnline ? "online" : "offline"); + + // When reader comes online, ensure a Door exists for access control decisions + if (e.IsOnline) + { + Task.Run(async () => + { + try + { + await EnsureDoorExistsForReader(endpointId, config.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create door for reader {Name}", config.Name); + } + }); + } + + _z9OpenCommunityProtocolService.SendCredReaderOnlineEvent(config, e.IsOnline); + + _devStateService.UpdateAspect(config.Unid, DevAspect.Primary, + s => s.CommState = e.IsOnline ? CommState.Online : CommState.Offline); + } + + private void OnLocalStatusChanged(object sender, LocalStatusChangedEventArgs e) + { + var endpointId = e.Endpoint?.Id; + if (string.IsNullOrEmpty(endpointId) || !IsReaderEndpoint(endpointId)) + return; + + var config = _z9OpenCommunityProtocolService.GetConfigForEndpoint(endpointId); + if (config == null) + { + _logger.LogDebug("No Z9 config found for endpoint {EndpointId}, skipping local status event", endpointId); + return; + } + + if (e.TamperState != null) + { + _logger.LogInformation("Reader {Name} tamper state: {TamperState}", + config.Name, e.TamperState.Value ? "TAMPER" : "NORMAL"); + _z9OpenCommunityProtocolService.SendTamperEvent(config, e.TamperState.Value); + _devStateService.UpdateAspect(config.Unid, DevAspect.Primary, + s => s.TamperState = e.TamperState.Value ? TamperState.Tamper : TamperState.Normal); + } + + if (e.PowerCycleDetected) + { + _logger.LogInformation("Reader {Name} power cycle detected", config.Name); + _z9OpenCommunityProtocolService.SendCredReaderPowerCycleEvent(config); + } + } + + /// + /// Checks if an OSDP endpoint ID represents a reader (vs output, input, etc.). + /// Reader endpoint IDs end with ":R{number}" (e.g., "localhost:9843:0:R0"). + /// + private static bool IsReaderEndpoint(string endpointId) + { + var lastColon = endpointId.LastIndexOf(':'); + return lastColon >= 0 && lastColon < endpointId.Length - 1 && endpointId[lastColon + 1] == 'R'; + } + + private async Task EnsureDoorExistsForReader(string driverEndpointId, string readerName) + { + // Wait for the pool z9_dev for this reader to appear (created by DriverOnUpdatedEndpoints) + var readerPoolDev = await WaitForPoolDev(driverEndpointId); + if (readerPoolDev == null) + { + _logger.LogWarning("Pool z9_dev for reader endpoint {EndpointId} not found after retries", driverEndpointId); + return; + } + + // If already assigned to a door (has logicalParentUnid), door already exists + if (readerPoolDev.LogicalParentUnidCase == Dev.LogicalParentUnidOneofCase.LogicalParentUnid) + { + _logger.LogDebug("CredReader z9_dev already assigned to door for endpoint {EndpointId}", driverEndpointId); + return; + } + + // Create Door Dev + var doorDev = new Dev + { + Name = $"Door - {readerName}", + DevType = DevType.Door, + }; + SpCoreProtoUtil.InitRequired(doorDev); + var doorUnid = await _z9DevRepository.Insert(doorDev); + + // Assign the reader pool dev as door child + readerPoolDev.LogicalParentUnid = doorUnid; + readerPoolDev.Enabled = true; + await _z9DevRepository.Upsert(readerPoolDev); + doorDev.LogicalChildrenUnid.Add(readerPoolDev.Unid); + + var config = _z9OpenCommunityProtocolService.GetConfigForEndpoint(driverEndpointId); + + // If the reader has a strike output configured, assign Actuator pool dev + if (config?.StrikeOutputNumber != null) + { + var strikeDriverEndpointId = $"{config.Host}:{config.TcpPort}:{config.OsdpAddress}:O{config.StrikeOutputNumber}"; + _logger.LogInformation("Looking for strike output pool z9_dev: {StrikeEndpointId}", strikeDriverEndpointId); + + var strikePoolDev = await WaitForPoolDev(strikeDriverEndpointId); + if (strikePoolDev != null) + { + strikePoolDev.LogicalParentUnid = doorUnid; + strikePoolDev.Enabled = true; + strikePoolDev.DevUse = DevUse.ActuatorDoorStrike; + await _z9DevRepository.Upsert(strikePoolDev); + doorDev.LogicalChildrenUnid.Add(strikePoolDev.Unid); + _logger.LogInformation("Assigned Actuator pool z9_dev for door strike {EndpointId}", strikeDriverEndpointId); + } + else + { + _logger.LogWarning("Strike output pool z9_dev {StrikeEndpointId} not found after retries", strikeDriverEndpointId); + } + } + + _logger.LogInformation("Created door '{DoorName}' (unid={DoorUnid}) for reader endpoint {EndpointId}", + doorDev.Name, doorUnid, driverEndpointId); + + // If the reader has a door contact input configured, assign Sensor pool dev + if (config?.DoorContactInputNumber != null) + { + var contactDriverEndpointId = $"{config.Host}:{config.TcpPort}:{config.OsdpAddress}:I{config.DoorContactInputNumber}"; + _logger.LogInformation("Looking for door contact input pool z9_dev: {ContactEndpointId}", contactDriverEndpointId); + + var contactPoolDev = await WaitForPoolDev(contactDriverEndpointId); + if (contactPoolDev != null) + { + contactPoolDev.LogicalParentUnid = doorUnid; + contactPoolDev.Enabled = true; + contactPoolDev.DevUse = DevUse.SensorDoorContact; + await _z9DevRepository.Upsert(contactPoolDev); + doorDev.LogicalChildrenUnid.Add(contactPoolDev.Unid); + _logger.LogInformation("Assigned Sensor pool z9_dev for door contact {EndpointId}", contactDriverEndpointId); + } + else + { + _logger.LogWarning("Door contact input pool z9_dev {ContactEndpointId} not found after retries", contactDriverEndpointId); + } + } + + // If the reader has a REX input configured, assign Sensor pool dev + if (config?.RexInputNumber != null) + { + var rexDriverEndpointId = $"{config.Host}:{config.TcpPort}:{config.OsdpAddress}:I{config.RexInputNumber}"; + _logger.LogInformation("Looking for REX input pool z9_dev: {RexEndpointId}", rexDriverEndpointId); + + var rexPoolDev = await WaitForPoolDev(rexDriverEndpointId); + if (rexPoolDev != null) + { + rexPoolDev.LogicalParentUnid = doorUnid; + rexPoolDev.Enabled = true; + rexPoolDev.DevUse = DevUse.SensorRex; + await _z9DevRepository.Upsert(rexPoolDev); + doorDev.LogicalChildrenUnid.Add(rexPoolDev.Unid); + _logger.LogInformation("Assigned Sensor pool z9_dev for REX {EndpointId}", rexDriverEndpointId); + } + else + { + _logger.LogWarning("REX input pool z9_dev {RexEndpointId} not found after retries", rexDriverEndpointId); + } + } + + // Update Door Dev with children + await _z9DevRepository.Upsert(doorDev); + + // Apply default door mode if configured + if (config?.DefaultDoorMode != null && config.DoorUnid.HasValue) + { + await ApplyDoorMode(config.DoorUnid.Value, config.DefaultDoorMode, config); + } + } + + private async Task WaitForPoolDev(string driverEndpointId, int maxRetries = 20) + { + for (int retry = 0; retry < maxRetries; retry++) + { + var dev = await _z9DevRepository.GetByExternalId(driverEndpointId); + if (dev != null) + return dev; + await Task.Delay(500); + } + return null; + } + + private void OnDevActionRequested(object sender, DevActionReq req) + { + if (req.DevActionType == DevActionType.DoorMomentaryUnlock) + { + var doorUnid = req.DevUnid; + _logger.LogInformation("DevActionReq: DOOR_MOMENTARY_UNLOCK for door unid={DoorUnid}", doorUnid); + + Task.Run(async () => + { + try + { + await HandleDoorMomentaryUnlock(doorUnid, isHostInitiated: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to handle DOOR_MOMENTARY_UNLOCK for door unid={DoorUnid}", doorUnid); + } + }); + } + else if (req.DevActionType == DevActionType.DoorModeChange) + { + var doorUnid = req.DevUnid; + _logger.LogInformation("DevActionReq: DOOR_MODE_CHANGE for door unid={DoorUnid}", doorUnid); + + Task.Run(async () => + { + try + { + await HandleDoorModeChange(doorUnid, req.DevActionParams); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to handle DOOR_MODE_CHANGE for door unid={DoorUnid}", doorUnid); + } + }); + } + else + { + _logger.LogInformation("DevActionReq: unhandled action type {Type} for device unid={DevUnid}", + req.DevActionType, req.DevUnid); + } + } + + private async Task HandleDoorMomentaryUnlock(int doorUnid, bool isHostInitiated = false) + { + // Find the reader config whose DoorUnid matches + var config = _z9OpenCommunityProtocolService.OsdpReaderConfigs + .FirstOrDefault(c => c.DoorUnid == doorUnid); + + if (config == null) + { + _logger.LogWarning("No reader config found for door unid={DoorUnid}", doorUnid); + return; + } + + if (config.StrikeOutputNumber == null) + { + _logger.LogWarning("No strike output configured for reader {Name}", config.Name); + return; + } + + var strikeDriverEndpointId = $"{config.Host}:{config.TcpPort}:{config.OsdpAddress}:O{config.StrikeOutputNumber}"; + + var strikeDev = await _z9DevRepository.GetByExternalId(strikeDriverEndpointId); + if (strikeDev == null) + { + _logger.LogWarning("Strike z9_dev {EndpointId} not found", strikeDriverEndpointId); + return; + } + + var extensionId = await _z9DevRepository.GetExtensionId(strikeDev); + if (!extensionId.HasValue) + { + _logger.LogWarning("No extension ID found for strike z9_dev {EndpointId}", strikeDriverEndpointId); + return; + } + + var controlPoint = _extensionService.GetControlPoint(extensionId.Value, strikeDev.ExternalId); + if (controlPoint == null) + { + _logger.LogWarning("No control point found for strike endpoint {EndpointId}", strikeDriverEndpointId); + return; + } + + _logger.LogInformation("Activating door strike for door unid={DoorUnid} (endpoint={EndpointId})", + doorUnid, strikeDriverEndpointId); + + var strikeTimeMs = config.StrikeTimeMs ?? 3000; + + await controlPoint.SetState(true); + + if (isHostInitiated) + { + _z9OpenCommunityProtocolService.SendDoorStateEvent(config, EvtCode.MomentaryUnlock); + } + + await Task.Delay(TimeSpan.FromMilliseconds(strikeTimeMs)); + await controlPoint.SetState(false); + + _logger.LogInformation("Door strike deactivated for door unid={DoorUnid} (strikeTime={StrikeTimeMs}ms)", + doorUnid, strikeTimeMs); + } + + private async Task HandleDoorModeChange(int doorUnid, DevActionParams devActionParams) + { + var modeParams = devActionParams?.ExtDoorModeDevActionParams; + if (modeParams == null) + { + _logger.LogWarning("DOOR_MODE_CHANGE missing DoorModeDevActionParams for door unid={DoorUnid}", doorUnid); + return; + } + + // Find the reader config for this door + var config = _z9OpenCommunityProtocolService.OsdpReaderConfigs + .FirstOrDefault(c => c.DoorUnid == doorUnid); + if (config == null) + { + _logger.LogWarning("No reader config found for door unid={DoorUnid}", doorUnid); + return; + } + + // Determine which DoorMode to apply + DoorMode doorMode; + if (modeParams.ResetToDefaultCase == DoorModeDevActionParams.ResetToDefaultOneofCase.ResetToDefault + && modeParams.ResetToDefault) + { + doorMode = config.DefaultDoorMode; + if (doorMode == null) + { + _logger.LogWarning("ResetToDefault requested but no default door mode for door unid={DoorUnid}", doorUnid); + return; + } + _logger.LogInformation("Resetting door unid={DoorUnid} to default mode", doorUnid); + } + else + { + doorMode = modeParams.DoorMode; + if (doorMode == null) + { + _logger.LogWarning("DOOR_MODE_CHANGE has no DoorMode for door unid={DoorUnid}", doorUnid); + return; + } + } + + await ApplyDoorMode(doorUnid, doorMode, config); + } + + private async Task ApplyDoorMode(int doorUnid, DoorMode doorMode, Z9OpenCommunityProtocolService.OsdpReaderConfig config) + { + var doorModeType = CommonDoorModes.DoorModeToDoorModeType(doorMode); + if (!doorModeType.HasValue) + { + _logger.LogWarning("Could not determine DoorModeType for door unid={DoorUnid}", doorUnid); + return; + } + + var effectiveMode = doorModeType.Value; + + var previousMode = _doorCurrentMode.TryGetValue(doorUnid, out var prev) ? prev : (DoorModeType?)null; + _doorCurrentMode[doorUnid] = effectiveMode; + + _logger.LogInformation("Door unid={DoorUnid} mode changed to {Mode}", doorUnid, effectiveMode); + + _devStateService.UpdateAspect(doorUnid, DevAspect.Primary, + s => s.DoorMode = doorMode.Clone()); + + // Actuate strike based on mode + if (config.StrikeOutputNumber != null) + { + var strikeDriverEndpointId = $"{config.Host}:{config.TcpPort}:{config.OsdpAddress}:O{config.StrikeOutputNumber}"; + var strikeDev = await _z9DevRepository.GetByExternalId(strikeDriverEndpointId); + + if (strikeDev != null) + { + var strikeExtensionId = await _z9DevRepository.GetExtensionId(strikeDev); + var controlPoint = strikeExtensionId.HasValue + ? _extensionService.GetControlPoint(strikeExtensionId.Value, strikeDev.ExternalId) + : null; + if (controlPoint != null) + { + if (effectiveMode == DoorModeType.StaticStateUnlocked) + { + await controlPoint.SetState(true); + _z9OpenCommunityProtocolService.SendDoorStateEvent(config, EvtCode.DoorUnlocked); + } + else if (previousMode == DoorModeType.StaticStateUnlocked) + { + // Switching away from unlocked — deactivate strike + await controlPoint.SetState(false); + _z9OpenCommunityProtocolService.SendDoorStateEvent(config, EvtCode.DoorLocked); + } + } + } + } + + // Send the door mode event + var modeEvtCode = effectiveMode switch + { + DoorModeType.StaticStateUnlocked => EvtCode.DoorModeStaticStateUnlocked, + DoorModeType.StaticStateLocked => EvtCode.DoorModeStaticStateLocked, + DoorModeType.CardOnly => EvtCode.DoorModeCardOnly, + DoorModeType.CardAndConfirmingPin => EvtCode.DoorModeCardAndConfirmingPin, + DoorModeType.UniquePinOnly => EvtCode.DoorModeUniquePinOnly, + DoorModeType.CardOnlyOrUniquePin => EvtCode.DoorModeCardOnlyOrUniquePin, + _ => EvtCode.DoorModeCardOnly + }; + _z9OpenCommunityProtocolService.SendDoorStateEvent(config, modeEvtCode); + } + + private void OnStateChanged(object sender, StateChangedEventArgs e) + { + var driverEndpointId = e.Endpoint?.Id; + if (string.IsNullOrEmpty(driverEndpointId)) + return; + + Task.Run(async () => + { + try + { + await HandleStateChanged(driverEndpointId, e.State); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to handle state change for endpoint {EndpointId}", driverEndpointId); + } + }); + } + + private async Task HandleStateChanged(string driverEndpointId, bool state) + { + // Look up the Dev by ExternalId (DriverEndpointId) + var dev = await _z9DevRepository.GetByExternalId(driverEndpointId); + if (dev == null) + return; + + // Get the door unid (parent) for state tracking + var doorUnid = dev.LogicalParentUnidCase == Dev.LogicalParentUnidOneofCase.LogicalParentUnid + ? dev.LogicalParentUnid : 0; + + // Check if this is a REX sensor + if (dev.DevType == DevType.Sensor && dev.DevUseCase == Dev.DevUseOneofCase.DevUse && dev.DevUse == DevUse.SensorRex && state) + { + var config = _z9OpenCommunityProtocolService.GetConfigForEndpoint(driverEndpointId); + if (config != null) + { + // Use the canonical door unid from OSDP config (matches what SpCoreDriver knows) + if (config.DoorUnid.HasValue) + doorUnid = config.DoorUnid.Value; + _logger.LogInformation("REX activated for door unid={DoorUnid}", doorUnid); + _z9OpenCommunityProtocolService.SendDoorStateEvent(config, EvtCode.ExitRequested); + + // Determine REX behavior based on door mode + var currentMode = doorUnid > 0 && _doorCurrentMode.TryGetValue(doorUnid, out var mode) + ? mode : (DoorModeType?)null; + + if (currentMode == DoorModeType.StaticStateUnlocked) + { + // Already unlocked — no need for momentary unlock + } + else if (currentMode == DoorModeType.StaticStateLocked) + { + // Door is locked — do not unlock on REX + _logger.LogInformation("REX ignored for door unid={DoorUnid} — door is in locked mode", doorUnid); + } + else if (config.ActivateStrikeOnRex && config.DoorUnid.HasValue) + { + await HandleDoorMomentaryUnlock(config.DoorUnid.Value); + } + } + return; + } + + // Check if this is a door strike actuator — track state for forced-open detection + if (dev.DevType == DevType.Actuator && dev.DevUseCase == Dev.DevUseOneofCase.DevUse && dev.DevUse == DevUse.ActuatorDoorStrike) + { + var config2 = _z9OpenCommunityProtocolService.GetConfigForEndpoint(driverEndpointId); + // Use the canonical door unid from OSDP config (matches what SpCoreDriver knows) + if (config2?.DoorUnid != null) + doorUnid = config2.DoorUnid.Value; + if (doorUnid > 0) + { + _doorStrikeActive[doorUnid] = state; + _devStateService.UpdateAspect(doorUnid, DevAspect.DoorUnlocked, + s => s.ActivityState = state ? ActivityState.Active : ActivityState.Inactive); + } + var evtCode = state ? EvtCode.DoorUnlocked : EvtCode.DoorLocked; + if (config2 != null) + _z9OpenCommunityProtocolService.SendDoorStateEvent(config2, evtCode); + return; + } + + // Check if this is a door contact sensor + if (dev.DevType == DevType.Sensor && dev.DevUseCase == Dev.DevUseOneofCase.DevUse && dev.DevUse == DevUse.SensorDoorContact) + { + var config2 = _z9OpenCommunityProtocolService.GetConfigForEndpoint(driverEndpointId); + if (config2 == null) + return; + // Use the canonical door unid from OSDP config (matches what SpCoreDriver knows) + if (config2.DoorUnid.HasValue) + doorUnid = config2.DoorUnid.Value; + + if (!state) // Door opened + { + _z9OpenCommunityProtocolService.SendDoorStateEvent(config2, EvtCode.DoorOpened); + _devStateService.UpdateAspect(doorUnid, DevAspect.DoorOpen, + s => s.ActivityState = ActivityState.Active); + + _doorStrikeActive.TryGetValue(doorUnid, out var strikeActive); + if (!strikeActive) + { + _logger.LogWarning("Door forced open: door unid={DoorUnid}", doorUnid); + _z9OpenCommunityProtocolService.SendDoorStateEvent(config2, EvtCode.DoorForced); + _doorForced[doorUnid] = true; + _devStateService.UpdateAspect(doorUnid, DevAspect.DoorForced, + s => s.ActivityState = ActivityState.Active); + } + + StartDoorHeldTimer(doorUnid, dev.Name, config2); + } + else // Door closed + { + CancelDoorHeldTimer(doorUnid); + + _z9OpenCommunityProtocolService.SendDoorStateEvent(config2, EvtCode.DoorClosed); + _devStateService.UpdateAspect(doorUnid, DevAspect.DoorOpen, + s => s.ActivityState = ActivityState.Inactive); + + if (_doorForced.TryGetValue(doorUnid, out var wasForced) && wasForced) + { + _logger.LogInformation("Door forced cleared: door unid={DoorUnid}", doorUnid); + _z9OpenCommunityProtocolService.SendDoorStateEvent(config2, EvtCode.DoorNotForced); + _doorForced[doorUnid] = false; + _devStateService.UpdateAspect(doorUnid, DevAspect.DoorForced, + s => s.ActivityState = ActivityState.Inactive); + } + + if (_doorHeld.TryGetValue(doorUnid, out var wasHeld) && wasHeld) + { + _logger.LogInformation("Door held cleared: door unid={DoorUnid}", doorUnid); + _z9OpenCommunityProtocolService.SendDoorStateEvent(config2, EvtCode.DoorNotHeld); + _doorHeld[doorUnid] = false; + _devStateService.UpdateAspect(doorUnid, DevAspect.DoorHeld, + s => s.ActivityState = ActivityState.Inactive); + } + } + return; + } + } + + private void OnAccessDecisionMade(object sender, AccessDecisionEventArgs e) + { + // Store extended time flag for door held timer (consumed when door opens) + if (e.IsGranted) + { + var readerConfig = _z9OpenCommunityProtocolService.GetConfigForEndpoint(e.EndpointId); + if (readerConfig?.DoorUnid != null) + { + _doorUseExtendedTime[readerConfig.DoorUnid.Value] = e.UseExtendedTime; + } + } + + // Get the Z9 config for this endpoint to send event with correct device unid + var config = _z9OpenCommunityProtocolService.GetConfigForEndpoint(e.EndpointId); + if (config == null) + { + _logger.LogDebug("No Z9 config found for endpoint {EndpointId}, skipping access event", + e.EndpointId); + return; + } + + var rawBits = e.CardNumber; + + // Try to decode the raw bits using known card formats + System.Numerics.BigInteger? credNum = null; + int? facilityCode = null; + if (!string.IsNullOrEmpty(rawBits)) + { + var decoded = _z9OpenCommunityProtocolService.DecodeCardRead(rawBits); + if (decoded.HasValue) + { + credNum = decoded.Value.credNum; + facilityCode = decoded.Value.facilityCode; + _logger.LogDebug("Decoded card: credNum={CredNum}, fc={FacilityCode}, format={Format}", + credNum, facilityCode, decoded.Value.formatName); + } + } + + // Map EventReason to EvtSubCode via shared mapping + var eventType = e.IsGranted ? EventType.AccessGranted : EventType.AccessDenied; + var (_, evtSubCode) = EventReasonMapping.ToEvtCodes(eventType, e.Reason); + var subCode = evtSubCode ?? EvtSubCode.AccessDeniedInactive; + + _logger.LogInformation("Access {Decision} for reader {Name}: person={Person}, reason={Reason}", + e.IsGranted ? "GRANTED" : "DENIED", config.Name, e.PersonName ?? "(unknown)", e.Reason); + + _z9OpenCommunityProtocolService.SendAccessEvent( + config, + isGranted: e.IsGranted, + credNum: credNum, + facilityCode: facilityCode, + rawBits: rawBits, + subCode: subCode); + } + + private void StartDoorHeldTimer(int doorId, string doorName, Z9OpenCommunityProtocolService.OsdpReaderConfig config) + { + // Pick held time: use extended if last access grant had extDoorTime + var useExtended = _doorUseExtendedTime.TryGetValue(doorId, out var ext) && ext; + var heldTimeMs = useExtended && config.ExtendedHeldTimeMs.HasValue + ? config.ExtendedHeldTimeMs.Value + : config.HeldTimeMs ?? 0; + + // Clear one-shot extended time flag + _doorUseExtendedTime.Remove(doorId); + + if (heldTimeMs <= 0) + return; + + // Cancel any existing timer for this door + CancelDoorHeldTimer(doorId); + + var cts = new CancellationTokenSource(); + _doorHeldTimers[doorId] = cts; + + _ = Task.Run(async () => + { + try + { + await Task.Delay(heldTimeMs, cts.Token); + _logger.LogWarning("Door held open: '{DoorName}' (heldTime={HeldTimeMs}ms, extended={UseExtended})", + doorName, heldTimeMs, useExtended); + _doorHeld[doorId] = true; + _z9OpenCommunityProtocolService.SendDoorStateEvent(config, EvtCode.DoorHeld); + _devStateService.UpdateAspect(doorId, DevAspect.DoorHeld, + s => s.ActivityState = ActivityState.Active); + } + catch (TaskCanceledException) + { + // Timer cancelled — door closed in time + } + }); + } + + private void CancelDoorHeldTimer(int doorId) + { + if (_doorHeldTimers.TryGetValue(doorId, out var cts)) + { + cts.Cancel(); + cts.Dispose(); + _doorHeldTimers.Remove(doorId); + } + } + + private void CancelAllDoorHeldTimers() + { + foreach (var cts in _doorHeldTimers.Values) + { + cts.Cancel(); + cts.Dispose(); + } + _doorHeldTimers.Clear(); + } +} diff --git a/src/Aporta/appsettings.Development.json b/src/Aporta/appsettings.Development.json index 1e4c961..885c058 100644 --- a/src/Aporta/appsettings.Development.json +++ b/src/Aporta/appsettings.Development.json @@ -3,7 +3,11 @@ "LogLevel": { "Default": "Information", "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" + "Microsoft.Hosting.Lifetime": "Information", + "OSDP.Net": "Debug" + }, + "File": { + "Path": "aporta.log" } } } diff --git a/src/Aporta/appsettings.json b/src/Aporta/appsettings.json index 45fe774..1a50858 100644 --- a/src/Aporta/appsettings.json +++ b/src/Aporta/appsettings.json @@ -4,6 +4,9 @@ "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" + }, + "File": { + "Path": "/var/log/aporta.log" } } -} \ No newline at end of file +} diff --git a/src/Drivers/Aporta.Drivers.IonoPi/IonoPiDriver.cs b/src/Drivers/Aporta.Drivers.IonoPi/IonoPiDriver.cs index 84c7891..f74191d 100644 --- a/src/Drivers/Aporta.Drivers.IonoPi/IonoPiDriver.cs +++ b/src/Drivers/Aporta.Drivers.IonoPi/IonoPiDriver.cs @@ -85,6 +85,7 @@ public async Task PerformAction(string action, string parameters) public event EventHandler AccessCredentialReceived; public event EventHandler StateChanged; public event EventHandler OnlineStatusChanged; + public event EventHandler LocalStatusChanged; protected virtual void OnUpdatedEndpoints() { diff --git a/src/Drivers/Aporta.Drivers.IonoPiMax/IonoPiMaxDriver.cs b/src/Drivers/Aporta.Drivers.IonoPiMax/IonoPiMaxDriver.cs index 162eede..45140b5 100644 --- a/src/Drivers/Aporta.Drivers.IonoPiMax/IonoPiMaxDriver.cs +++ b/src/Drivers/Aporta.Drivers.IonoPiMax/IonoPiMaxDriver.cs @@ -182,9 +182,12 @@ protected virtual void OnStateChanged(IEndpoint endpoint, bool state) /// public event EventHandler? OnlineStatusChanged; - + protected virtual void OnOnlineStatusChanged(OnlineStatusChangedEventArgs eventArgs) { OnlineStatusChanged?.Invoke(this, eventArgs); } + + /// + public event EventHandler? LocalStatusChanged; } \ No newline at end of file diff --git a/src/Drivers/Aporta.Drivers.OSDP.Shared/Bus.cs b/src/Drivers/Aporta.Drivers.OSDP.Shared/Bus.cs index b4b1f02..b6ea222 100644 --- a/src/Drivers/Aporta.Drivers.OSDP.Shared/Bus.cs +++ b/src/Drivers/Aporta.Drivers.OSDP.Shared/Bus.cs @@ -2,21 +2,55 @@ namespace Aporta.Drivers.OSDP.Shared; +/// +/// Specifies the type of connection used for OSDP communication. +/// +public enum ConnectionType +{ + /// + /// Serial port connection (RS-485). + /// + Serial, + + /// + /// TCP/IP network connection (OSDP over IP). + /// + Tcp +} + /// /// Represents a bus used for communication. /// public class Bus { + /// + /// Gets or sets the connection type (Serial or TCP). + /// + public ConnectionType ConnectionType { get; set; } = ConnectionType.Serial; + /// /// Gets or sets the name of the port used for communication. + /// Also used as a unique identifier/key for the bus connection. /// public string PortName { get; set; } /// /// Gets or sets the baud rate used for communication. + /// For TCP connections, this is used for OSDP timing simulation. /// public int BaudRate { get; set; } + /// + /// Gets or sets the TCP host address for TCP connections. + /// + public string TcpHost { get; set; } + + /// + /// Gets or sets the TCP port number for TCP connections. + /// Default is 9843 (standard OSDP over IP port). + /// + public int TcpPort { get; set; } = 9843; + /// /// Gets or sets a value indicating whether tracing is enabled. /// Tracing provides runtime logging information for debugging purposes. diff --git a/src/Drivers/Aporta.Drivers.OSDP/OSDPDriver.cs b/src/Drivers/Aporta.Drivers.OSDP/OSDPDriver.cs index 5efe96b..1b82efa 100644 --- a/src/Drivers/Aporta.Drivers.OSDP/OSDPDriver.cs +++ b/src/Drivers/Aporta.Drivers.OSDP/OSDPDriver.cs @@ -4,6 +4,8 @@ using System.IO.Ports; using System.Linq; using System.Security.Cryptography; +using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -34,7 +36,10 @@ public class OSDPDriver : IHardwareDriver private readonly List _endpoints = new(); private readonly ConcurrentDictionary _pkocDevices = new(); private readonly ConcurrentDictionary _connections = new(); - + private readonly ConcurrentDictionary<(Guid connectionId, byte address), (string pin, CancellationTokenSource cts)> _pendingPins = new(); + private readonly ConcurrentDictionary<(Guid connectionId, byte address), (bool tamper, bool power)> _lastLocalStatus = new(); + private const int PinBufferTimeoutMs = 5000; + private ControlPanel _panel; private PKOCControlPanel _pkocPanel; private IDataEncryption _dataEncryption; @@ -56,10 +61,12 @@ public void Load(string configuration, IDataEncryption dataEncryption, ILoggerFa _panel.ConnectionStatusChanged += PanelOnConnectionStatusChanged; _panel.RawCardDataReplyReceived += PanelOnRawCardDataReplyReceived; + _panel.KeypadReplyReceived += PanelOnKeypadReplyReceived; _panel.InputStatusReportReplyReceived += PanelOnInputStatusReportReplyReceived; _panel.OutputStatusReportReplyReceived += PanelOnOutputStatusReportReplyReceived; _panel.NakReplyReceived += PanelOnNakReplyReceived; - + _panel.LocalStatusReportReplyReceived += PanelOnLocalStatusReportReplyReceived; + _pkocPanel.CardPresented += PkocPanelOnCardPresented; ExtractConfiguration(configuration); @@ -107,9 +114,11 @@ private void PanelOnConnectionStatusChanged(object sender, ControlPanel.Connecti { Task.Run(async () => { - var matchingBus = _configuration.Buses.Single(bus => - bus.PortName == _portMapping.First(keyValue => keyValue.Value == eventArgs.ConnectionId).Key); - var matchingDevice = matchingBus.Devices.First(device => device.Address == eventArgs.Address); + try + { + var matchingBus = _configuration.Buses.Single(bus => + bus.PortName == _portMapping.First(keyValue => keyValue.Value == eventArgs.ConnectionId).Key); + var matchingDevice = matchingBus.Devices.First(device => device.Address == eventArgs.Address); matchingDevice.KeyMismatch = false; @@ -120,11 +129,14 @@ private void PanelOnConnectionStatusChanged(object sender, ControlPanel.Connecti matchingDevice.IsConnected = false; matchingDevice.IdentityNotMatched = false; + // Notify that all endpoints on this device are now offline + NotifyEndpointOnlineStatus(matchingBus.PortName, matchingDevice.Address, false); + OnUpdatedEndpoints(); return; case true: { - _logger.LogInformation("Device \'{MatchingDeviceName}\' is online", matchingDevice.Name); + _logger.LogInformation("Device \'{MatchingDeviceName}\' is online, SecureMode={SecureMode}", matchingDevice.Name, matchingDevice.SecureMode); if (matchingDevice.SecureMode == SecureMode.Install) { @@ -138,21 +150,34 @@ private void PanelOnConnectionStatusChanged(object sender, ControlPanel.Connecti return; } + _logger.LogInformation("Calling ProcessDeviceIdentification..."); if (!await ProcessDeviceIdentification(eventArgs, matchingDevice).ConfigureAwait(false)) { + _logger.LogWarning("ProcessDeviceIdentification returned false"); matchingDevice.IsConnected = false; OnUpdatedEndpoints(); return; } + _logger.LogInformation("ProcessDeviceIdentification succeeded"); + _logger.LogInformation("Calling ProcessDeviceCapabilities..."); if (!await ProcessDeviceCapabilities(eventArgs, matchingDevice).ConfigureAwait(false)) { + _logger.LogWarning("ProcessDeviceCapabilities returned false"); matchingDevice.IsConnected = false; OnUpdatedEndpoints(); return; } + _logger.LogInformation("ProcessDeviceCapabilities succeeded"); + + // Query initial local status (tamper / power cycle) + await QueryInitialLocalStatus(eventArgs.ConnectionId, eventArgs.Address, matchingBus.PortName); matchingDevice.IsConnected = true; + + // Notify that all endpoints on this device are now online + NotifyEndpointOnlineStatus(matchingBus.PortName, matchingDevice.Address, true); + OnUpdatedEndpoints(); if (matchingDevice.PKOCEnabled) @@ -173,15 +198,24 @@ private void PanelOnConnectionStatusChanged(object sender, ControlPanel.Connecti return; } } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in PanelOnConnectionStatusChanged: ConnectionId={ConnectionId} Address={Address}", + eventArgs.ConnectionId, eventArgs.Address); + } }); } private async Task ProcessDeviceCapabilities(ControlPanel.ConnectionStatusEventArgs eventArgs, Device matchingDevice) { + _logger.LogInformation("ProcessDeviceCapabilities starting for '{MatchingDeviceName}'", matchingDevice.Name); DeviceCapabilities capabilities; try { + _logger.LogDebug("Requesting device capabilities from OSDP panel..."); capabilities = await _panel.DeviceCapabilities(eventArgs.ConnectionId, eventArgs.Address).ConfigureAwait(false); + _logger.LogDebug("Device capabilities received: {Count} capabilities", capabilities?.Capabilities?.Count() ?? 0); } catch (Exception exception) { @@ -248,10 +282,14 @@ private async Task ProcessDeviceCapabilities(ControlPanel.ConnectionStatus private async Task ProcessDeviceIdentification(ControlPanel.ConnectionStatusEventArgs eventArgs, Device matchingDevice) { + _logger.LogInformation("ProcessDeviceIdentification starting for '{MatchingDeviceName}'", matchingDevice.Name); DeviceIdentification identification; try { + _logger.LogDebug("Requesting device identification from OSDP panel..."); identification = await _panel.IdReport(eventArgs.ConnectionId, eventArgs.Address).ConfigureAwait(false); + _logger.LogDebug("Device identification received: VendorCode={VendorCode}", + identification?.VendorCode != null ? BitConverter.ToString(identification.VendorCode.ToArray()) : "null"); } catch (Exception exception) { @@ -294,15 +332,35 @@ private void PanelOnRawCardDataReplyReceived(object sender, ControlPanel.RawCard { var accessPoint = _endpoints.Where(endpoint => endpoint is IAccess).Cast() .SingleOrDefault(accessPoint => - eventArgs.ConnectionId == _portMapping[accessPoint.Id.Split(":")[0]] && - accessPoint.Id.Split(":")[1] == eventArgs.Address.ToString()); + { + var (portName, address) = ParseEndpointId(accessPoint.Id); + return _portMapping.TryGetValue(portName, out var connId) && + eventArgs.ConnectionId == connId && + address == eventArgs.Address.ToString(); + }); if (accessPoint != null) { + // Try to consume a buffered PIN for this reader + string pin = null; + var key = (eventArgs.ConnectionId, eventArgs.Address); + if (_pendingPins.TryRemove(key, out var pending)) + { + pending.cts.Cancel(); + pending.cts.Dispose(); + pin = pending.pin; + _logger.LogInformation("Card read received on {AccessPointName} with buffered PIN", accessPoint.Name); + } + else + { + _logger.LogInformation("Card read received on {AccessPointName}", accessPoint.Name); + } + + var handler = new WiegandCredentialHandler(eventArgs.RawCardData.Data, eventArgs.RawCardData.BitCount, + accessPoint.Name, _logger); AccessCredentialReceived?.Invoke(this, - new AccessCredentialReceivedEventArgs( - accessPoint, - new WiegandCredentialHandler(eventArgs.RawCardData.Data, eventArgs.RawCardData.BitCount, - accessPoint.Name, _logger))); + pin != null + ? new AccessCredentialReceivedEventArgs(accessPoint, handler, pin) + : new AccessCredentialReceivedEventArgs(accessPoint, handler)); } else { @@ -312,6 +370,109 @@ private void PanelOnRawCardDataReplyReceived(object sender, ControlPanel.RawCard }); } + private void PanelOnKeypadReplyReceived(object sender, ControlPanel.KeypadReplyEventArgs eventArgs) + { + Task.Run(() => + { + // Decode PIN from KeypadData.Data byte[] → ASCII string (filter to digits only) + var pinBuilder = new StringBuilder(); + foreach (var b in eventArgs.KeypadData.Data) + { + if (b >= 0x30 && b <= 0x39) // ASCII digits '0'-'9' + pinBuilder.Append((char)b); + } + var pin = pinBuilder.ToString(); + if (string.IsNullOrEmpty(pin)) + { + _logger.LogDebug("Keypad reply received but no digits found"); + return; + } + + _logger.LogInformation("Keypad entry received: {DigitCount} digits on address {Address}", + pin.Length, eventArgs.Address); + + var key = (eventArgs.ConnectionId, eventArgs.Address); + + // Cancel any previous pending PIN for this reader + if (_pendingPins.TryRemove(key, out var oldPending)) + { + oldPending.cts.Cancel(); + oldPending.cts.Dispose(); + } + + // Buffer the PIN with a timeout + var cts = new CancellationTokenSource(); + _pendingPins[key] = (pin, cts); + + // Start timeout: if no card read arrives, treat as PIN-only entry + _ = Task.Run(async () => + { + try + { + await Task.Delay(PinBufferTimeoutMs, cts.Token); + + // Timeout expired — no card read arrived, treat as PIN-only + if (!_pendingPins.TryRemove(key, out _)) + return; // Already consumed by card read + + var accessPoint = _endpoints.Where(endpoint => endpoint is IAccess).Cast() + .SingleOrDefault(ap => + { + var (portName, address) = ParseEndpointId(ap.Id); + return _portMapping.TryGetValue(portName, out var connId) && + eventArgs.ConnectionId == connId && + address == eventArgs.Address.ToString(); + }); + + if (accessPoint != null) + { + _logger.LogInformation("PIN-only entry on {AccessPointName} (no card within timeout)", accessPoint.Name); + AccessCredentialReceived?.Invoke(this, + new AccessCredentialReceivedEventArgs(accessPoint, new PinOnlyCredentialHandler(), pin)); + } + else + { + _logger.LogWarning("Unable to find access point at address {Address} for PIN-only entry", + eventArgs.Address); + } + } + catch (TaskCanceledException) + { + // PIN was consumed by a card read — normal flow + } + finally + { + cts.Dispose(); + } + }); + }); + } + + private class PinOnlyCredentialHandler : ICredentialReceivedHandler + { + public bool IsValid() => true; + public string MatchingCardData => null; + } + + /// + /// Parses an endpoint ID to extract port name and address. + /// ID format: "{portName}:{address}:{type}{number}" where portName can contain colons (e.g., "localhost:9843"). + /// + private static (string portName, string address) ParseEndpointId(string endpointId) + { + // Find the last two colons to extract address and type suffix + // ID examples: "COM3:0:R0" or "localhost:9843:0:R0" + var lastColon = endpointId.LastIndexOf(':'); + if (lastColon <= 0) return (endpointId, "0"); + + var secondLastColon = endpointId.LastIndexOf(':', lastColon - 1); + if (secondLastColon <= 0) return (endpointId.Substring(0, lastColon), endpointId.Substring(lastColon + 1)); + + var portName = endpointId.Substring(0, secondLastColon); + var address = endpointId.Substring(secondLastColon + 1, lastColon - secondLastColon - 1); + return (portName, address); + } + private void PkocPanelOnCardPresented(object sender, CardPresentedEventArgs eventArgs) { Task.Run(async () => @@ -319,8 +480,12 @@ private void PkocPanelOnCardPresented(object sender, CardPresentedEventArgs even _logger.LogInformation("A PKOC card has been presented to the reader"); var accessPoint = _endpoints.Where(endpoint => endpoint is IAccess).Cast() .SingleOrDefault(accessPoint => - eventArgs.ConnectionId == _portMapping[accessPoint.Id.Split(":")[0]] && - accessPoint.Id.Split(":")[1] == eventArgs.Address.ToString()); + { + var (portName, address) = ParseEndpointId(accessPoint.Id); + return _portMapping.TryGetValue(portName, out var connId) && + eventArgs.ConnectionId == connId && + address == eventArgs.Address.ToString(); + }); if (accessPoint != null) { var hashLookup = new PKOCDevice(eventArgs.ConnectionId, eventArgs.Address).GetHashCode(); @@ -344,11 +509,15 @@ private void PanelOnInputStatusReportReplyReceived(object sender, ControlPanel.InputStatusReportReplyEventArgs eventArgs) { Task.Run(() => - { + { var monitorPoints = _endpoints.Where(endpoint => endpoint is IInput).Cast() .Where(monitorPoint => - eventArgs.ConnectionId == _portMapping[monitorPoint.Id.Split(":")[0]] && - monitorPoint.Id.Split(":")[1] == eventArgs.Address.ToString()); + { + var (portName, address) = ParseEndpointId(monitorPoint.Id); + return _portMapping.TryGetValue(portName, out var connId) && + eventArgs.ConnectionId == connId && + address == eventArgs.Address.ToString(); + }); foreach (var monitorPoint in monitorPoints) { @@ -366,8 +535,12 @@ private void PanelOnOutputStatusReportReplyReceived(object sender, { var controlPoints = _endpoints.Where(endpoint => endpoint is IOutput).Cast() .Where(controlPoint => - eventArgs.ConnectionId == _portMapping[controlPoint.Id.Split(":")[0]] && - controlPoint.Id.Split(":")[1] == eventArgs.Address.ToString()); + { + var (portName, address) = ParseEndpointId(controlPoint.Id); + return _portMapping.TryGetValue(portName, out var connId) && + eventArgs.ConnectionId == connId && + address == eventArgs.Address.ToString(); + }); foreach (var controlPoint in controlPoints) { @@ -418,10 +591,20 @@ private void StartConnections() { foreach (var bus in _configuration.Buses) { - var connection = new SerialPortOsdpConnection(bus.PortName, bus.BaudRate); - + IOsdpConnection connection; + + if (bus.ConnectionType == ConnectionType.Tcp) + { + _logger.LogInformation("Starting TCP OSDP connection to {Host}:{Port}", bus.TcpHost, bus.TcpPort); + connection = new TcpClientOsdpConnection(bus.TcpHost, bus.TcpPort, bus.BaudRate); + } + else + { + connection = new SerialPortOsdpConnection(bus.PortName, bus.BaudRate); + } + _connections.TryAdd(bus.PortName, connection); - + _portMapping.TryAdd(bus.PortName, _panel.StartConnection(connection, TimeSpan.FromMilliseconds(50), false)); } } @@ -463,10 +646,12 @@ public void Unload() _panel.ConnectionStatusChanged -= PanelOnConnectionStatusChanged; _panel.RawCardDataReplyReceived -= PanelOnRawCardDataReplyReceived; + _panel.KeypadReplyReceived -= PanelOnKeypadReplyReceived; _panel.InputStatusReportReplyReceived -= PanelOnInputStatusReportReplyReceived; _panel.OutputStatusReportReplyReceived -= PanelOnOutputStatusReportReplyReceived; _panel.NakReplyReceived -= PanelOnNakReplyReceived; - + _panel.LocalStatusReportReplyReceived -= PanelOnLocalStatusReportReplyReceived; + _pkocPanel.CardPresented -= PkocPanelOnCardPresented; } @@ -514,6 +699,7 @@ public async Task PerformAction(string action, string parameters) public event EventHandler AccessCredentialReceived; public event EventHandler StateChanged; public event EventHandler OnlineStatusChanged; + public event EventHandler LocalStatusChanged; private string AddBus(string parameters) { @@ -524,9 +710,22 @@ private string AddBus(string parameters) if (!_portMapping.ContainsKey(busAction.Bus.PortName)) { + IOsdpConnection connection; + + if (busAction.Bus.ConnectionType == ConnectionType.Tcp) + { + _logger.LogInformation("Adding TCP OSDP bus connection to {Host}:{Port}", + busAction.Bus.TcpHost, busAction.Bus.TcpPort); + connection = new TcpClientOsdpConnection(busAction.Bus.TcpHost, busAction.Bus.TcpPort, busAction.Bus.BaudRate); + } + else + { + connection = new SerialPortOsdpConnection(busAction.Bus.PortName, busAction.Bus.BaudRate); + } + + _connections.TryAdd(busAction.Bus.PortName, connection); _portMapping.TryAdd(busAction.Bus.PortName, - _panel.StartConnection(new SerialPortOsdpConnection(busAction.Bus.PortName, busAction.Bus.BaudRate), - TimeSpan.FromMilliseconds(50), false)); + _panel.StartConnection(connection, TimeSpan.FromMilliseconds(50), false)); } return string.Empty; @@ -563,11 +762,6 @@ private string AddUpdateDevice(string parameters) var bus = _configuration.Buses.First(bus => bus.PortName == deviceAction.Device.PortName); - if (!_connections[bus.PortName]?.IsOpen ?? true) - { - throw new Exception($"{bus.PortName} is not open"); - } - bus.Devices.Add(deviceAction.Device); AddDeviceToPanel(deviceAction.Device); @@ -746,4 +940,112 @@ protected virtual void OnOnlineStatusChanged(OnlineStatusChangedEventArgs e) { OnlineStatusChanged?.Invoke(this, e); } + + private void NotifyEndpointOnlineStatus(string portName, byte address, bool isOnline) + { + // Find all endpoints that match the given bus port name and device address + // Endpoint ID format: "{portName}:{address}:..." (e.g., "localhost:9843:0:R0") + var endpointPrefix = $"{portName}:{address}:"; + _logger.LogInformation("NotifyEndpointOnlineStatus: portName={PortName}, address={Address}, prefix={Prefix}, total endpoints={Count}", + portName, address, endpointPrefix, _endpoints.Count); + + var matchingEndpoints = _endpoints.Where(e => e.Id.StartsWith(endpointPrefix)).ToList(); + _logger.LogInformation("Found {MatchCount} matching endpoints for prefix '{Prefix}'", + matchingEndpoints.Count, endpointPrefix); + + foreach (var endpoint in matchingEndpoints) + { + _logger.LogInformation("Endpoint {EndpointId} is now {Status}", endpoint.Id, isOnline ? "online" : "offline"); + OnOnlineStatusChanged(new OnlineStatusChangedEventArgs(endpoint, isOnline)); + } + } + + private void PanelOnLocalStatusReportReplyReceived(object sender, + ControlPanel.LocalStatusReportReplyEventArgs eventArgs) + { + Task.Run(() => + { + try + { + var key = (eventArgs.ConnectionId, eventArgs.Address); + var currentTamper = eventArgs.LocalStatus.Tamper; + var currentPower = eventArgs.LocalStatus.PowerFailure; + + var previous = _lastLocalStatus.TryGetValue(key, out var prev) ? prev : (tamper: false, power: false); + + var tamperChanged = previous.tamper != currentTamper; + var powerCycleDetected = !previous.power && currentPower; + + _lastLocalStatus[key] = (currentTamper, currentPower); + + if (!tamperChanged && !powerCycleDetected) return; + + var readerEndpoint = FindReaderEndpoint(eventArgs.ConnectionId, eventArgs.Address); + if (readerEndpoint == null) + { + _logger.LogDebug("No reader endpoint found for LocalStatus at address {Address}", eventArgs.Address); + return; + } + + _logger.LogInformation( + "LocalStatus change on {EndpointName}: tamperChanged={TamperChanged} (tamper={Tamper}), powerCycleDetected={PowerCycleDetected}", + readerEndpoint.Name, tamperChanged, currentTamper, powerCycleDetected); + + LocalStatusChanged?.Invoke(this, new LocalStatusChangedEventArgs( + readerEndpoint, + tamperChanged ? currentTamper : null, + powerCycleDetected)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in PanelOnLocalStatusReportReplyReceived"); + } + }); + } + + private async Task QueryInitialLocalStatus(Guid connectionId, byte address, string portName) + { + try + { + var localStatus = await _panel.LocalStatus(connectionId, address).ConfigureAwait(false); + if (localStatus == null) return; + + var key = (connectionId, address); + _lastLocalStatus[key] = (localStatus.Tamper, localStatus.PowerFailure); + + var tamperActive = localStatus.Tamper; + // Power flag is expected to be true on first connect (reader just started) + var powerCycleDetected = localStatus.PowerFailure; + + if (!tamperActive && !powerCycleDetected) return; + + var readerEndpoint = FindReaderEndpoint(connectionId, address); + if (readerEndpoint == null) return; + + _logger.LogInformation( + "Initial LocalStatus for {EndpointName}: tamper={Tamper}, powerCycle={PowerCycle}", + readerEndpoint.Name, tamperActive, powerCycleDetected); + + LocalStatusChanged?.Invoke(this, new LocalStatusChangedEventArgs( + readerEndpoint, + tamperActive ? true : null, + powerCycleDetected)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to query initial local status for address {Address}", address); + } + } + + private IEndpoint FindReaderEndpoint(Guid connectionId, byte address) + { + return _endpoints.FirstOrDefault(e => + { + var (portName, addr) = ParseEndpointId(e.Id); + return _portMapping.TryGetValue(portName, out var connId) && + connId == connectionId && + addr == address.ToString() && + e.Id.EndsWith($":R0"); + }); + } } \ No newline at end of file diff --git a/src/Drivers/Aporta.Drivers.Virtual.WebClient/Configuration.razor b/src/Drivers/Aporta.Drivers.Virtual.WebClient/Configuration.razor index b7c96ef..f1b661d 100644 --- a/src/Drivers/Aporta.Drivers.Virtual.WebClient/Configuration.razor +++ b/src/Drivers/Aporta.Drivers.Virtual.WebClient/Configuration.razor @@ -4,11 +4,12 @@ @using Aporta.Shared.Calls @using Aporta.Shared.Models +@using Aporta.Shared.Models.Flex @using Aporta.Drivers.Virtual.Shared.Actions @using Aporta.Drivers.Virtual.WebClient.Dialogs @inject IDriverConfigurationCalls ConfigurationCalls; -@inject IDoorCalls DoorCalls; +@inject FlexApiService FlexApi; @if (_configuration == null) { @@ -205,7 +206,7 @@ else private BadgeSwipeDialog _badgeSwipeDialog; - private Endpoint[] _availableEndPoints; + private FlexListResponse _availableEndPoints; private Aporta.Drivers.Virtual.Shared.Configuration _configuration; @@ -227,7 +228,7 @@ else private async Task InitializeAvailableEndpoints() { - _availableEndPoints = await DoorCalls.GetAvailableEndpoints(); + _availableEndPoints = await FlexApi.GetAvailableEndpointsAsync(); StateHasChanged(); } @@ -403,16 +404,16 @@ else private bool IsReaderAvailableForDelete(Device reader) { - return _availableEndPoints.Count(endpoint => endpoint.DriverEndpointId == $"VR{reader.Number}") > 0; + return _availableEndPoints.InstanceList.Count(dev => dev.ExternalId == $"VR{reader.Number}") > 0; } private bool IsInputAvailableForDelete(Device input) { - return _availableEndPoints.Count(endpoint => endpoint.DriverEndpointId == $"VI{input.Number}") > 0; + return _availableEndPoints.InstanceList.Count(dev => dev.ExternalId == $"VI{input.Number}") > 0; } private bool IsOutputAvailableForDelete(Device output) { - return _availableEndPoints.Count(endpoint => endpoint.DriverEndpointId == $"VO{output.Number}") > 0; + return _availableEndPoints.InstanceList.Count(dev => dev.ExternalId == $"VO{output.Number}") > 0; } } \ No newline at end of file diff --git a/src/Drivers/Aporta.Drivers.Virtual/VirtualDriver.cs b/src/Drivers/Aporta.Drivers.Virtual/VirtualDriver.cs index 71ad8c8..f570931 100644 --- a/src/Drivers/Aporta.Drivers.Virtual/VirtualDriver.cs +++ b/src/Drivers/Aporta.Drivers.Virtual/VirtualDriver.cs @@ -340,6 +340,9 @@ protected virtual void OnOnlineStatusChanged(OnlineStatusChangedEventArgs eventA OnlineStatusChanged?.Invoke(this, eventArgs); } + /// + public event EventHandler? LocalStatusChanged; + private class VirtualCredentialReceivedHandler(string cardData) : ICredentialReceivedHandler { public bool IsValid() diff --git a/test/Aporta.Core.Tests/Aporta.Core.Tests.csproj b/test/Aporta.Core.Tests/Aporta.Core.Tests.csproj index 45a74fa..ec899fc 100644 --- a/test/Aporta.Core.Tests/Aporta.Core.Tests.csproj +++ b/test/Aporta.Core.Tests/Aporta.Core.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/test/Aporta.Core.Tests/DataAccess/MigrationTests.cs b/test/Aporta.Core.Tests/DataAccess/MigrationTests.cs index c4ad698..5f04896 100644 --- a/test/Aporta.Core.Tests/DataAccess/MigrationTests.cs +++ b/test/Aporta.Core.Tests/DataAccess/MigrationTests.cs @@ -8,7 +8,7 @@ namespace Aporta.Core.Tests.DataAccess; [TestFixture] public class MigrationTests { - private const int CurrentVersion = 10; + private const int CurrentVersion = 100; private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); private IDbConnection _persistConnection; diff --git a/test/Aporta.Core.Tests/DataAccess/Repositories/CredentialRepositoryTests.cs b/test/Aporta.Core.Tests/DataAccess/Repositories/CredentialRepositoryTests.cs index f076090..aeabea8 100644 --- a/test/Aporta.Core.Tests/DataAccess/Repositories/CredentialRepositoryTests.cs +++ b/test/Aporta.Core.Tests/DataAccess/Repositories/CredentialRepositoryTests.cs @@ -1,13 +1,18 @@ using System; using System.Data; using System.Linq; +using System.Numerics; +using System.Text.Json; using System.Threading.Tasks; using Aporta.Core.DataAccess; using Aporta.Core.DataAccess.Repositories; using Aporta.Core.Models; using Aporta.Shared.Models; +using Aporta.Shared.Models.Flex; using Microsoft.Data.Sqlite; using NUnit.Framework; +using Z9.Protobuf; +using Z9.Spcore.Proto; namespace Aporta.Core.Tests.DataAccess.Repositories; @@ -44,332 +49,360 @@ public void TearDown() _persistConnection?.Close(); _persistConnection?.Dispose(); } - - [Test] - public async Task Get_AssignedCredential() + + private static Cred CreateZ9Cred(int unid, string name, bool enabled, BigInteger? credNum = null) { - // Arrange - var credentials = new[] + var cred = new Cred { - new Credential {Number = "2345342"}, - new Credential {Number = "5345234"} + Unid = unid, + Enabled = enabled, + CredTemplateUnid = 1 }; - - var credentialRepository = new CredentialRepository(_dataAccess); - foreach (var credential in credentials) + if (name != null) { - await credentialRepository.Insert(credential); + cred.Name = name; } - - var people = new[] + if (credNum.HasValue) { - new Person {FirstName = "First1", LastName = "Last1", Enabled = false}, - new Person {FirstName = "First2", LastName = "Last2", Enabled = true}, - }; - - var personRepository = new PersonRepository(_dataAccess); - foreach (var person in people) - { - await personRepository.Insert(person); + cred.CardPin = new CardPin + { + CredNum = SpCoreProtoUtil.ToBigIntegerData(credNum.Value) + }; } - - await credentialRepository.AssignPerson(credentials[1].Id, people[1].Id); - - // Act - var actual = await credentialRepository.Get(credentials[1].Id); - - // Assert - Assert.That(actual.Number, Is.EqualTo("5345234")); - Assert.That(actual.AssignedPersonId, Is.EqualTo(people[1].Id)); - Assert.That(actual.Enabled, Is.True); + SpCoreProtoUtil.InitRequired(cred); + return cred; } - + [Test] - public async Task Get_UnassignedCredential() + public async Task Get_CredentialWithZ9Cred() { // Arrange - var credentials = new[] - { - new Credential {Number = "2345342"}, - new Credential {Number = "5345234"} - }; - var credentialRepository = new CredentialRepository(_dataAccess); - foreach (var credential in credentials) - { - await credentialRepository.Insert(credential); - } + var z9CredRepository = new Z9CredRepository(_dataAccess); + + var cred = CreateZ9Cred(1, "Smith, John", true, new BigInteger(5345234)); + await z9CredRepository.Upsert(cred); - // Act - var actual = await credentialRepository.Get(credentials[1].Id); + // Act + var actual = await credentialRepository.Get(1); // Assert Assert.That(actual.Number, Is.EqualTo("5345234")); - Assert.That(actual.AssignedPersonId, Is.Null); - Assert.That(actual.Enabled, Is.Null); + Assert.That(actual.Enabled, Is.True); } [Test] - public async Task Insert() - { + public async Task Get_CredentialWithoutCredNum() + { // Arrange - var credentials = new[] - { - new Credential {Number = "2345342", LastEvent = 4}, - new Credential {Number = "5345234", LastEvent = 5}, - }; - var credentialRepository = new CredentialRepository(_dataAccess); - foreach (var credential in credentials) - { - await credentialRepository.Insert(credential); - } + var z9CredRepository = new Z9CredRepository(_dataAccess); + + var cred = CreateZ9Cred(1, "Smith, John", true); + await z9CredRepository.Upsert(cred); - // Act - var actualCredential = await credentialRepository.Get(2); + // Act + var actual = await credentialRepository.Get(1); // Assert - Assert.That(credentials[1].Id, Is.EqualTo(2)); - Assert.That(actualCredential.Id, Is.EqualTo(2)); - Assert.That(actualCredential.LastEvent, Is.EqualTo(5)); - Assert.That(actualCredential.Number, Is.EqualTo("5345234")); + Assert.That(actual.Number, Is.Null); + Assert.That(actual.Enabled, Is.True); } - + [Test] - public async Task Insert_DuplicateCardNumber() + public async Task Get_NonExistent_ReturnsNull() { // Arrange - var credential = new Credential {Number = "2345342"}; - var credentialRepository = new CredentialRepository(_dataAccess); - await credentialRepository.Insert(credential); - - // Assert - async Task InsertCredential() - { - await credentialRepository.Insert(credential); - } + // Act + var actual = await credentialRepository.Get(999); // Assert - Assert.ThrowsAsync(InsertCredential); + Assert.That(actual, Is.Null); } [Test] - public async Task Delete() + public async Task DuplicateCredNum_ThrowsSqliteException() { // Arrange - var credentials = new[] - { - new Credential {Number = "2345342"}, - new Credential {Number = "5345234"}, - }; - - var credentialRepository = new CredentialRepository(_dataAccess); - foreach (var credential in credentials) - { - await credentialRepository.Insert(credential); - } + var z9CredRepository = new Z9CredRepository(_dataAccess); - // Act - await credentialRepository.Delete(2); + var cred1 = CreateZ9Cred(1, null, true, new BigInteger(2345342)); + await z9CredRepository.Upsert(cred1); + + var cred2 = CreateZ9Cred(2, null, true, new BigInteger(2345342)); // Assert - var actualCredentials = await credentialRepository.GetAll(); - Assert.That(actualCredentials.Count(), Is.EqualTo(1)); + Assert.ThrowsAsync(() => z9CredRepository.Upsert(cred2)); } - + [Test] - public async Task AssignedCredential_NoAssignedPerson() + public async Task AssignedCredential_NoZ9CredName() { - // Arrange - var credentials = new[] - { - new Credential {Number = "2345342"}, - new Credential {Number = "5345234"} - }; - + // Arrange - z9_cred exists with cred_num but no name + var z9CredRepository = new Z9CredRepository(_dataAccess); var credentialRepository = new CredentialRepository(_dataAccess); - foreach (var credential in credentials) - { - await credentialRepository.Insert(credential); - } - // Act + var cred = CreateZ9Cred(1, null, true, new BigInteger(5345234)); + await z9CredRepository.Upsert(cred); + + // Act var actual = await credentialRepository.AssignedCredential("5345234"); - // Assert - Null person + // Assert - Person should be null (not enrolled) Assert.That(actual.Person, Is.Null); } - + [Test] - public async Task AssignedCredential_ActivePerson() + public async Task AssignedCredential_WithZ9CredName_Enabled() { // Arrange - var credentials = new[] - { - new Credential {Number = "2345342"}, - new Credential {Number = "5345234"} - }; - + var z9CredRepository = new Z9CredRepository(_dataAccess); var credentialRepository = new CredentialRepository(_dataAccess); - foreach (var credential in credentials) - { - await credentialRepository.Insert(credential); - } - - var people = new[] - { - new Person {FirstName = "First1", LastName = "Last1", Enabled = false}, - new Person {FirstName = "First2", LastName = "Last2", Enabled = true}, - }; - var personRepository = new PersonRepository(_dataAccess); - foreach (var person in people) - { - await personRepository.Insert(person); - } + var cred = CreateZ9Cred(1, "Smith, John", true, new BigInteger(5345234)); + await z9CredRepository.Upsert(cred); - await credentialRepository.AssignPerson(credentials[1].Id, people[1].Id); - - // Act + // Act var actual = await credentialRepository.AssignedCredential("5345234"); // Assert Assert.That(actual.Number, Is.EqualTo("5345234")); Assert.That(actual.Enabled, Is.True); - Assert.That(actual.Person.Id, Is.EqualTo(people[1].Id)); + Assert.That(actual.Person, Is.Not.Null); + Assert.That(actual.Person.FirstName, Is.EqualTo("John")); + Assert.That(actual.Person.LastName, Is.EqualTo("Smith")); + Assert.That(actual.Person.Enabled, Is.True); } - + [Test] - public async Task AssignedCredential_InactivePerson() + public async Task AssignedCredential_WithZ9CredName_Disabled() { // Arrange - var credentials = new[] - { - new Credential {Number = "2345342"}, - new Credential {Number = "5345234"} - }; - + var z9CredRepository = new Z9CredRepository(_dataAccess); var credentialRepository = new CredentialRepository(_dataAccess); - foreach (var credential in credentials) - { - await credentialRepository.Insert(credential); - } - - var people = new[] - { - new Person {FirstName = "First1", LastName = "Last1", Enabled = false}, - new Person {FirstName = "First2", LastName = "Last2", Enabled = true}, - }; - - var personRepository = new PersonRepository(_dataAccess); - foreach (var person in people) - { - await personRepository.Insert(person); - } - await credentialRepository.AssignPerson(credentials[1].Id, people[0].Id); + var cred = CreateZ9Cred(1, "Jones, Jane", false, new BigInteger(5345234)); + await z9CredRepository.Upsert(cred); - // Act + // Act var actual = await credentialRepository.AssignedCredential("5345234"); // Assert Assert.That(actual.Number, Is.EqualTo("5345234")); Assert.That(actual.Enabled, Is.False); - Assert.That(actual.Person.Id, Is.EqualTo(people[0].Id)); + Assert.That(actual.Person, Is.Not.Null); + Assert.That(actual.Person.FirstName, Is.EqualTo("Jane")); + Assert.That(actual.Person.LastName, Is.EqualTo("Jones")); + Assert.That(actual.Person.Enabled, Is.False); } - + [Test] - public async Task AssignedCredential_AssignmentNotEnabled() + public async Task Assigned_ReturnsOnlyNamedCredentialsWithNumber() { // Arrange - var credentials = new[] - { - new Credential {Number = "2345342"}, - new Credential {Number = "5345234"} - }; - + var z9CredRepository = new Z9CredRepository(_dataAccess); var credentialRepository = new CredentialRepository(_dataAccess); - foreach (var credential in credentials) - { - await credentialRepository.Insert(credential); - } - - var people = new[] - { - new Person {FirstName = "First1", LastName = "Last1", Enabled = true}, - new Person {FirstName = "First2", LastName = "Last2", Enabled = true}, - }; - var personRepository = new PersonRepository(_dataAccess); - foreach (var person in people) - { - await personRepository.Insert(person); - } + // Enrolled: has name AND cred_num + var enrolled = CreateZ9Cred(1, "Smith, John", true, new BigInteger(1111)); + await z9CredRepository.Upsert(enrolled); - await credentialRepository.AssignPerson(credentials[1].Id, people[0].Id, false); + // Person only: has name but no cred_num + var personOnly = CreateZ9Cred(2, "Doe, Jane", true); + await z9CredRepository.Upsert(personOnly); - // Act - var actual = await credentialRepository.AssignedCredential("5345234"); + // Raw swipe: has cred_num but no name + var swipe = CreateZ9Cred(3, null, true, new BigInteger(2222)); + await z9CredRepository.Upsert(swipe); - // Assert - Assert.That(actual.Number, Is.EqualTo("5345234")); - Assert.That(actual.Enabled, Is.False); - Assert.That(actual.Person.Id, Is.EqualTo(people[0].Id)); + // Act + var assigned = (await credentialRepository.Assigned()).ToList(); + + // Assert - only the enrolled credential + Assert.That(assigned, Has.Exactly(1).Items); + Assert.That(assigned[0].Id, Is.EqualTo(1)); + Assert.That(assigned[0].Number, Is.EqualTo("1111")); } - + [Test] - public async Task RevokeCredential() + public async Task Unassigned_ReturnsCredentialsWithoutZ9CredName() { // Arrange - var credentials = new[] - { - new Credential {Number = "2345342"}, - new Credential {Number = "5345234"} - }; - + var z9CredRepository = new Z9CredRepository(_dataAccess); var credentialRepository = new CredentialRepository(_dataAccess); - foreach (var credential in credentials) - { - await credentialRepository.Insert(credential); - } - - var people = new[] - { - new Person {FirstName = "First1", LastName = "Last1", Enabled = false}, - new Person {FirstName = "First2", LastName = "Last2", Enabled = true}, - }; - var personRepository = new PersonRepository(_dataAccess); - foreach (var person in people) - { - await personRepository.Insert(person); - } + // Named credential (not unassigned) + var named = CreateZ9Cred(1, "Smith, John", true, new BigInteger(1111)); + await z9CredRepository.Upsert(named); + + // Raw swipe (no name → unassigned) + var swipe = CreateZ9Cred(2, null, true, new BigInteger(2222)); + await z9CredRepository.Upsert(swipe); - await credentialRepository.AssignPerson(credentials[1].Id, people[1].Id); + // Act + var unassigned = (await credentialRepository.Unassigned()).ToList(); - // Act - await credentialRepository.RevokePerson(credentials[1].Id, people[1].Id); - // Assert - var unassignedCredentials = await credentialRepository.Unassigned(); - Assert.That(unassignedCredentials, Has.Exactly(2).Items); + Assert.That(unassigned, Has.Exactly(1).Items); + Assert.That(unassigned[0].Id, Is.EqualTo(2)); } - + [Test] - public async Task UpdateLastEvent() + public async Task Named_ReturnsAllPersonDTOs() { // Arrange - var credential = new Credential { Number = "2345342", LastEvent = 4 }; + var z9CredRepository = new Z9CredRepository(_dataAccess); var credentialRepository = new CredentialRepository(_dataAccess); - int credentialId = await credentialRepository.Insert(credential); - // Act - await credentialRepository.UpdateLastEvent(credentialId, 5); + var cred1 = CreateZ9Cred(1, "Smith, John", true); + await z9CredRepository.Upsert(cred1); - // Assert - var actualCredential = await credentialRepository.Get(credentialId); - Assert.That(actualCredential.LastEvent, Is.EqualTo(5)); + var cred2 = CreateZ9Cred(2, "Doe, Jane", false); + await z9CredRepository.Upsert(cred2); + + // Raw swipe (no name) + var swipe = CreateZ9Cred(3, null, true, new BigInteger(9999)); + await z9CredRepository.Upsert(swipe); + + // Act + var people = (await credentialRepository.Named()).ToList(); + + // Assert - only 2 named people + Assert.That(people, Has.Exactly(2).Items); + Assert.That(people.Any(p => p.FirstName == "John" && p.LastName == "Smith" && p.Enabled), Is.True); + Assert.That(people.Any(p => p.FirstName == "Jane" && p.LastName == "Doe" && !p.Enabled), Is.True); + } + + [Test] + public void ParsePersonFromName_WithComma() + { + var person = CredentialRepository.ParsePersonFromName("Smith, John", 42, true); + Assert.That(person.Id, Is.EqualTo(42)); + Assert.That(person.FirstName, Is.EqualTo("John")); + Assert.That(person.LastName, Is.EqualTo("Smith")); + Assert.That(person.Enabled, Is.True); + } + + [Test] + public void ParsePersonFromName_WithoutComma() + { + var person = CredentialRepository.ParsePersonFromName("JohnOnly", 42, false); + Assert.That(person.FirstName, Is.EqualTo("JohnOnly")); + Assert.That(person.LastName, Is.Null); + Assert.That(person.Enabled, Is.False); + } + + [Test] + public async Task CredWithSchedRestriction_SurvivesRoundTrip() + { + // Arrange + var z9CredRepository = new Z9CredRepository(_dataAccess); + + var cred = CreateZ9Cred(1, "Smith, John", true, new BigInteger(12345)); + cred.PrivBindings.Add(new CredPrivBinding + { + DevAsDoorAccessPrivUnid = 42, + SchedRestriction = new SchedRestriction + { + SchedUnid = 7, + Invert = false + } + }); + SpCoreProtoUtil.InitRequired(cred); + + // Act + await z9CredRepository.Upsert(cred); + var retrieved = await z9CredRepository.Get(1); + + // Assert - proto round-trip + Assert.That(retrieved.PrivBindings, Has.Count.EqualTo(1)); + Assert.That(retrieved.PrivBindings[0].DevAsDoorAccessPrivUnid, Is.EqualTo(42)); + Assert.That(retrieved.PrivBindings[0].SchedRestriction, Is.Not.Null, + "SchedRestriction should survive proto JSON round-trip"); + Assert.That(retrieved.PrivBindings[0].SchedRestriction.SchedUnid, Is.EqualTo(7)); + + // Also verify the FlexMapper round-trip + var flex = Aporta.Core.Services.FlexMapper.ToFlex(retrieved); + Assert.That(flex.PrivBindings, Has.Count.EqualTo(1)); + Assert.That(flex.PrivBindings[0].SchedRestriction, Is.Not.Null, + "SchedRestriction should survive FlexMapper.ToFlex"); + Assert.That(flex.PrivBindings[0].SchedRestriction.Sched, Is.Not.Null); + Assert.That(flex.PrivBindings[0].SchedRestriction.Sched.Unid, Is.EqualTo(7)); + + // And the full round-trip back through ToProto + var protoAgain = Aporta.Core.Services.FlexMapper.ToProto(flex); + Assert.That(protoAgain.PrivBindings, Has.Count.EqualTo(1)); + Assert.That(protoAgain.PrivBindings[0].SchedRestriction, Is.Not.Null, + "SchedRestriction should survive FlexMapper.ToProto"); + Assert.That(protoAgain.PrivBindings[0].SchedRestriction.SchedUnid, Is.EqualTo(7)); + } + + [Test] + public async Task CredWithSchedRestriction_SurvivesFullHttpStyleRoundTrip() + { + // This test simulates the full HTTP round-trip: + // Client FlexCred → System.Text.Json serialize → System.Text.Json deserialize (server) → + // FlexMapper.ToProto → DB (proto JSON) → DB read → FlexMapper.ToFlex → + // System.Text.Json serialize (response) → System.Text.Json deserialize (client) + + var z9CredRepository = new Z9CredRepository(_dataAccess); + + // Step 1: Create a FlexCred like the client would (mimics SaveEditAccess) + var clientFlexCred = new FlexCred + { + Unid = 1, + Name = "Smith, John", + Enabled = true, + CredTemplate = new FlexObjRef { Unid = 1 }, + CardPin = new FlexCardPin { CredNum = "12345" }, + PrivBindings = new System.Collections.Generic.List + { + new FlexCredPrivBinding + { + DevAsDoorAccessPriv = new FlexObjRef { Unid = 42, Type = "dev" }, + SchedRestriction = new FlexSchedRestriction + { + Sched = new FlexObjRef { Unid = 7 } + } + } + } + }; + + // Step 2: Simulate HTTP transport (System.Text.Json round-trip) + var clientJson = JsonSerializer.Serialize(clientFlexCred); + TestContext.Progress.WriteLine($"Client JSON: {clientJson}"); + var serverFlexCred = JsonSerializer.Deserialize(clientJson); + + // Verify SchedRestriction survives System.Text.Json round-trip + Assert.That(serverFlexCred.PrivBindings[0].SchedRestriction, Is.Not.Null, + "SchedRestriction should survive System.Text.Json round-trip (simulated HTTP)"); + Assert.That(serverFlexCred.PrivBindings[0].SchedRestriction.Sched.Unid, Is.EqualTo(7)); + + // Step 3: Server-side: ToProto + InitRequired + Upsert (same as FlexCredController.Save) + var proto = Aporta.Core.Services.FlexMapper.ToProto(serverFlexCred); + SpCoreProtoUtil.InitRequired(proto); + await z9CredRepository.Upsert(proto); + + // Step 4: Server-side: GetAll + ToFlex (same as FlexCrudControllerBase.List) + var allProtos = (await z9CredRepository.GetAll()).ToList(); + var responseFlex = Aporta.Core.Services.FlexMapper.ToFlex(allProtos[0]); + + // Step 5: Simulate HTTP response (System.Text.Json serialize + deserialize) + var responseJson = JsonSerializer.Serialize(responseFlex); + TestContext.Progress.WriteLine($"Response JSON: {responseJson}"); + var clientReceivedFlex = JsonSerializer.Deserialize(responseJson); + + // Assert - full round-trip + Assert.That(clientReceivedFlex.PrivBindings, Has.Count.EqualTo(1)); + Assert.That(clientReceivedFlex.PrivBindings[0].DevAsDoorAccessPriv, Is.Not.Null); + Assert.That(clientReceivedFlex.PrivBindings[0].DevAsDoorAccessPriv.Unid, Is.EqualTo(42)); + Assert.That(clientReceivedFlex.PrivBindings[0].SchedRestriction, Is.Not.Null, + "SchedRestriction should survive the full HTTP-style round-trip"); + Assert.That(clientReceivedFlex.PrivBindings[0].SchedRestriction.Sched, Is.Not.Null, + "SchedRestriction.Sched should survive the full round-trip"); + Assert.That(clientReceivedFlex.PrivBindings[0].SchedRestriction.Sched.Unid, Is.EqualTo(7), + "SchedRestriction.Sched.Unid should be preserved"); } -} \ No newline at end of file +} diff --git a/test/Aporta.Core.Tests/DataAccess/Repositories/DoorRepositoryTests.cs b/test/Aporta.Core.Tests/DataAccess/Repositories/DoorRepositoryTests.cs deleted file mode 100644 index 6fc5e10..0000000 --- a/test/Aporta.Core.Tests/DataAccess/Repositories/DoorRepositoryTests.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System; -using System.Data; -using System.Linq; -using System.Threading.Tasks; -using Aporta.Core.DataAccess; -using Aporta.Core.DataAccess.Repositories; -using Aporta.Core.Models; -using Aporta.Shared.Models; -using NUnit.Framework; - -namespace Aporta.Core.Tests.DataAccess.Repositories; - -public class DoorRepositoryTests -{ - private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); - private IDbConnection _persistConnection; - private Guid _extensionId; - private int _inAccessEndpointId; - private int _outAccessEndpointId; - private int _doorContactEndpointId; - private int _requestToExitEndpointId; - private int _doorStrikeEndpointId; - - [SetUp] - public async Task Setup() - { - _persistConnection = _dataAccess.CreateDbConnection(); - _persistConnection.Open(); - - await _dataAccess.UpdateSchema(); - - _extensionId = Guid.NewGuid(); - var extensions = new[] - { - new ExtensionHost {Id = _extensionId, Name = "ExtensionTest", Enabled = false} - }; - - var extensionRepository = new ExtensionRepository(_dataAccess); - foreach (var extension in extensions) - { - await extensionRepository.Insert(extension); - } - - var inAccessEndpoint = new Endpoint - {Name = "InAccessTest", Type = EndpointType.Reader, ExtensionId = _extensionId}; - var outAccessEndpoint = new Endpoint - {Name = "OutAccessTest", Type = EndpointType.Reader, ExtensionId = _extensionId}; - var doorContactEndpoint = new Endpoint - {Name = "DoorContactTest", Type = EndpointType.Input, ExtensionId = _extensionId}; - var requestToExitEndpoint = new Endpoint - {Name = "RequestToExitTest", Type = EndpointType.Input, ExtensionId = _extensionId}; - var doorStrikeEndpoint = new Endpoint - {Name = "DoorStrikeTest", Type = EndpointType.Output, ExtensionId = _extensionId}; - - var endpointRepository = new EndpointRepository(_dataAccess); - - await endpointRepository.Insert(inAccessEndpoint); - _inAccessEndpointId = inAccessEndpoint.Id; - await endpointRepository.Insert(outAccessEndpoint); - _outAccessEndpointId = outAccessEndpoint.Id; - await endpointRepository.Insert(doorContactEndpoint); - _doorContactEndpointId = doorContactEndpoint.Id; - await endpointRepository.Insert(requestToExitEndpoint); - _requestToExitEndpointId = requestToExitEndpoint.Id; - await endpointRepository.Insert(doorStrikeEndpoint); - _doorStrikeEndpointId = doorStrikeEndpoint.Id; - } - - [TearDown] - public void TearDown() - { - _persistConnection?.Close(); - _persistConnection?.Dispose(); - } - - [Test] - public async Task Insert() - { - // Arrange - var doors = new[] - { - new Door {Name = "DoorTest1", InAccessEndpointId = _inAccessEndpointId}, - new Door - { - Name = "DoorTest2", InAccessEndpointId = _inAccessEndpointId, - OutAccessEndpointId = _outAccessEndpointId, DoorContactEndpointId = _doorContactEndpointId, - RequestToExitEndpointId = _requestToExitEndpointId, DoorStrikeEndpointId = _doorStrikeEndpointId - } - }; - - var doorRepository = new DoorRepository(_dataAccess); - foreach (var door in doors) - { - await doorRepository.Insert(door); - } - - // Act - var actualDoor = await doorRepository.Get(2); - - // Assert - Assert.That(doors[1].Id, Is.EqualTo(2)); - Assert.That(actualDoor.Id, Is.EqualTo(2)); - Assert.That(actualDoor.Name, Is.EqualTo("DoorTest2")); - Assert.That(actualDoor.InAccessEndpointId, Is.EqualTo(_inAccessEndpointId)); - Assert.That(actualDoor.OutAccessEndpointId, Is.EqualTo(_outAccessEndpointId)); - Assert.That(actualDoor.DoorContactEndpointId, Is.EqualTo(_doorContactEndpointId)); - Assert.That(actualDoor.RequestToExitEndpointId, Is.EqualTo(_requestToExitEndpointId)); - Assert.That(actualDoor.DoorStrikeEndpointId, Is.EqualTo(_doorStrikeEndpointId)); - } - - [Test] - public async Task Delete() - { - // Arrange - var doors = new[] - { - new Door {Name = "DoorTest1"}, - new Door {Name = "DoorTest2"} - }; - - var doorRepository = new DoorRepository(_dataAccess); - foreach (var door in doors) - { - await doorRepository.Insert(door); - } - - // Act - await doorRepository.Delete(2); - - // Assert - var actualDoors = await doorRepository.GetAll(); - Assert.That(1, Is.EqualTo(actualDoors.Count())); - } -} \ No newline at end of file diff --git a/test/Aporta.Core.Tests/DataAccess/Repositories/EndpointRepositoryTests.cs b/test/Aporta.Core.Tests/DataAccess/Repositories/EndpointRepositoryTests.cs deleted file mode 100644 index 7038fb1..0000000 --- a/test/Aporta.Core.Tests/DataAccess/Repositories/EndpointRepositoryTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System; -using System.Data; -using System.Linq; -using System.Threading.Tasks; -using Aporta.Core.DataAccess; -using Aporta.Core.DataAccess.Repositories; -using Aporta.Core.Models; -using Aporta.Shared.Models; -using NUnit.Framework; - -namespace Aporta.Core.Tests.DataAccess.Repositories; - -[TestFixture] -public class EndpointRepositoryTests -{ - private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); - private IDbConnection _persistConnection; - private Guid _extensionId; - - [SetUp] - public async Task Setup() - { - _persistConnection = _dataAccess.CreateDbConnection(); - _persistConnection.Open(); - - await _dataAccess.UpdateSchema(); - - _extensionId = Guid.NewGuid(); - var extensions = new[] - { - new ExtensionHost {Id = _extensionId, Name = "ExtensionTest", Enabled = false} - }; - - var extensionRepository = new ExtensionRepository(_dataAccess); - foreach (var extension in extensions) - { - await extensionRepository.Insert(extension); - } - } - - [TearDown] - public void TearDown() - { - _persistConnection?.Close(); - _persistConnection?.Dispose(); - } - - [Test] - public async Task Insert() - { - // Arrange - var endpoints = new[] - { - new Endpoint {Name = "Test1", Type = EndpointType.Output, ExtensionId = _extensionId}, - new Endpoint {Name = "Test2", Type = EndpointType.Input, ExtensionId = _extensionId}, - new Endpoint {Name = "Test3", Type = EndpointType.Reader, ExtensionId = _extensionId} - }; - - var endpointRepository = new EndpointRepository(_dataAccess); - foreach (var endpoint in endpoints) - { - await endpointRepository.Insert(endpoint); - } - - // Act - var actualEndpoint = await endpointRepository.Get(3); - - // Assert - Assert.That(endpoints[2].Id, Is.EqualTo(3)); - Assert.That(actualEndpoint.Id, Is.EqualTo(3)); - Assert.That(actualEndpoint.Name, Is.EqualTo("Test3")); - Assert.That(actualEndpoint.Type, Is.EqualTo(EndpointType.Reader)); - Assert.That(actualEndpoint.ExtensionId, Is.EqualTo(_extensionId)); - } - - [Test] - public async Task Delete() - { - // Arrange - var endpoints = new[] - { - new Endpoint {Name = "Test1", Type = EndpointType.Output, ExtensionId = _extensionId}, - new Endpoint {Name = "Test2", Type = EndpointType.Input, ExtensionId = _extensionId}, - new Endpoint {Name = "Test3", Type = EndpointType.Reader, ExtensionId = _extensionId} - }; - - var endpointRepository = new EndpointRepository(_dataAccess); - foreach (var endpoint in endpoints) - { - await endpointRepository.Insert(endpoint); - } - - // Act - await endpointRepository.Delete(3); - - // Assert - var actualEndpoints = await endpointRepository.GetAll(); - Assert.That(2, Is.EqualTo(actualEndpoints.Count())); - } -} \ No newline at end of file diff --git a/test/Aporta.Core.Tests/DataAccess/Repositories/EventRepositoryTests.cs b/test/Aporta.Core.Tests/DataAccess/Repositories/EventRepositoryTests.cs deleted file mode 100644 index c9871b2..0000000 --- a/test/Aporta.Core.Tests/DataAccess/Repositories/EventRepositoryTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using System.Data; -using System.Linq; -using System.Threading.Tasks; -using Aporta.Core.DataAccess; -using Aporta.Core.DataAccess.Repositories; -using Aporta.Core.Models; -using Aporta.Shared.Models; -using NUnit.Framework; - -namespace Aporta.Core.Tests.DataAccess.Repositories; - -public class EventRepositoryTests -{ - private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); - private IDbConnection _persistConnection; - private Guid _extensionId; - private int _accessEndpoint1Id; - private int _accessEndpoint2Id; - - [SetUp] - public async Task Setup() - { - _persistConnection = _dataAccess.CreateDbConnection(); - _persistConnection.Open(); - - await _dataAccess.UpdateSchema(); - - _extensionId = Guid.NewGuid(); - var extensions = new[] - { - new ExtensionHost {Id = _extensionId, Name = "ExtensionTest", Enabled = false} - }; - - var extensionRepository = new ExtensionRepository(_dataAccess); - foreach (var extension in extensions) - { - await extensionRepository.Insert(extension); - } - - var accessEndpoint1 = new Endpoint - {Name = "AccessTest1", Type = EndpointType.Reader, ExtensionId = _extensionId}; - var accessEndpoint2 = new Endpoint - {Name = "AccessTest2", Type = EndpointType.Reader, ExtensionId = _extensionId}; - - var endpointRepository = new EndpointRepository(_dataAccess); - - await endpointRepository.Insert(accessEndpoint1); - _accessEndpoint1Id = accessEndpoint1.Id; - await endpointRepository.Insert(accessEndpoint2); - _accessEndpoint2Id = accessEndpoint2.Id; - } - - [TearDown] - public void TearDown() - { - _persistConnection?.Close(); - _persistConnection?.Dispose(); - } - - [Test] - public async Task Insert() - { - // Arrange - var events = new[] - { - new Event {EndpointId = _accessEndpoint1Id, Timestamp = new DateTime(2022, 1, 1), Type = EventType.AccessDenied}, - new Event {EndpointId = _accessEndpoint2Id, Timestamp = new DateTime(2024, 4, 4), Type = EventType.AccessGranted, Data = "{}"}, - }; - - var eventRepository = new EventRepository(_dataAccess); - foreach (var @event in events) - { - await eventRepository.Insert(@event); - } - - // Act - var actualEvent = await eventRepository.Get(2); - - // Assert - Assert.That(events[1].Id, Is.EqualTo(2)); - Assert.That(actualEvent.Id, Is.EqualTo(2)); - Assert.That(actualEvent.EndpointId, Is.EqualTo(_accessEndpoint2Id)); - Assert.That(actualEvent.Timestamp, Is.EqualTo(new DateTime(2024, 4, 4))); - Assert.That(actualEvent.Type, Is.EqualTo(EventType.AccessGranted)); - Assert.That(actualEvent.Data, Is.EqualTo("{}")); - } - - [Test] - public async Task Delete() - { - // Arrange - var events = new[] - { - new Event - { - EndpointId = _accessEndpoint1Id, Timestamp = new DateTime(2022, 1, 1), Type = EventType.AccessDenied - }, - new Event - { - EndpointId = _accessEndpoint2Id, Timestamp = new DateTime(2024, 4, 4), - Type = EventType.AccessGranted, Data = "{}" - }, - }; - - var eventRepository = new EventRepository(_dataAccess); - foreach (var @event in events) - { - await eventRepository.Insert(@event); - } - - // Act - await eventRepository.Delete(2); - - // Assert - var actualEvents = await eventRepository.GetAll(); - Assert.That(1, Is.EqualTo(actualEvents.Count())); - } -} \ No newline at end of file diff --git a/test/Aporta.Core.Tests/DataAccess/Repositories/InputRepositoryTests.cs b/test/Aporta.Core.Tests/DataAccess/Repositories/InputRepositoryTests.cs deleted file mode 100644 index 3cc8e78..0000000 --- a/test/Aporta.Core.Tests/DataAccess/Repositories/InputRepositoryTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Data; -using System.Linq; -using System.Threading.Tasks; -using Aporta.Core.DataAccess; -using Aporta.Core.DataAccess.Repositories; -using Aporta.Core.Models; -using Aporta.Shared.Models; -using NUnit.Framework; - -namespace Aporta.Core.Tests.DataAccess.Repositories; - -public class InputRepositoryTests -{ - private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); - private IDbConnection _persistConnection; - private Guid _extensionId; - private int _endpointId; - - [SetUp] - public async Task Setup() - { - _persistConnection = _dataAccess.CreateDbConnection(); - _persistConnection.Open(); - - await _dataAccess.UpdateSchema(); - - _extensionId = Guid.NewGuid(); - var extensions = new[] - { - new ExtensionHost {Id = _extensionId, Name = "ExtensionTest", Enabled = false} - }; - - var extensionRepository = new ExtensionRepository(_dataAccess); - foreach (var extension in extensions) - { - await extensionRepository.Insert(extension); - } - - var endpoint = new Endpoint {Name = "Test1", Type = EndpointType.Input, ExtensionId = _extensionId}; - var endpointRepository = new EndpointRepository(_dataAccess); - await endpointRepository.Insert(endpoint); - _endpointId = endpoint.Id; - } - - [TearDown] - public void TearDown() - { - _persistConnection?.Close(); - _persistConnection?.Dispose(); - } - - [Test] - public async Task Insert() - { - // Arrange - var inputs = new[] - { - new Input {Name = "Test1", EndpointId = _endpointId}, - new Input {Name = "Test2", EndpointId = _endpointId}, - new Input {Name = "Test3", EndpointId = _endpointId} - }; - - var inputRepository = new InputRepository(_dataAccess); - foreach (var input in inputs) - { - await inputRepository.Insert(input); - } - - // Act - var actualInput = await inputRepository.Get(3); - - // Assert - Assert.That(inputs[2].Id, Is.EqualTo(3)); - Assert.That(actualInput.Id, Is.EqualTo(3)); - Assert.That(actualInput.Name, Is.EqualTo("Test3")); - Assert.That(actualInput.EndpointId, Is.EqualTo(_endpointId)); - } - - [Test] - public async Task Delete() - { - // Arrange - var inputRepository = new InputRepository(_dataAccess); - foreach (var input in new[] - { - new Input {Name = "Test1", EndpointId = _endpointId}, - new Input {Name = "Test2", EndpointId = _endpointId}, - new Input {Name = "Test3", EndpointId = _endpointId} - }) - { - await inputRepository.Insert(input); - } - - // Act - await inputRepository.Delete(3); - - // Assert - var actualInput = await inputRepository.GetAll(); - Assert.That(2, Is.EqualTo(actualInput.Count())); - } -} \ No newline at end of file diff --git a/test/Aporta.Core.Tests/DataAccess/Repositories/OutputRepositoryTests.cs b/test/Aporta.Core.Tests/DataAccess/Repositories/OutputRepositoryTests.cs deleted file mode 100644 index 978b838..0000000 --- a/test/Aporta.Core.Tests/DataAccess/Repositories/OutputRepositoryTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.Data; -using System.Linq; -using System.Threading.Tasks; -using Aporta.Core.DataAccess; -using Aporta.Core.DataAccess.Repositories; -using Aporta.Core.Models; -using Aporta.Shared.Models; -using NUnit.Framework; - -namespace Aporta.Core.Tests.DataAccess.Repositories; - -public class OutputRepositoryTests -{ - private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); - private IDbConnection _persistConnection; - private Guid _extensionId; - private int _endpointId; - - [SetUp] - public async Task Setup() - { - _persistConnection = _dataAccess.CreateDbConnection(); - _persistConnection.Open(); - - await _dataAccess.UpdateSchema(); - - _extensionId = Guid.NewGuid(); - var extensions = new[] - { - new ExtensionHost {Id = _extensionId, Name = "ExtensionTest", Enabled = false} - }; - - var extensionRepository = new ExtensionRepository(_dataAccess); - foreach (var extension in extensions) - { - await extensionRepository.Insert(extension); - } - - var endpoint = new Endpoint {Name = "Test1", Type = EndpointType.Output, ExtensionId = _extensionId}; - var endpointRepository = new EndpointRepository(_dataAccess); - await endpointRepository.Insert(endpoint); - _endpointId = endpoint.Id; - } - - [TearDown] - public void TearDown() - { - _persistConnection?.Close(); - _persistConnection?.Dispose(); - } - - [Test] - public async Task Insert() - { - // Arrange - var outputs = new[] - { - new Output {Name = "Test1", EndpointId = _endpointId}, - new Output {Name = "Test2", EndpointId = _endpointId}, - new Output {Name = "Test3", EndpointId = _endpointId} - }; - - var outputRepository = new OutputRepository(_dataAccess); - foreach (var output in outputs) - { - await outputRepository.Insert(output); - } - - // Act - var actualOutput = await outputRepository.Get(3); - - // Assert - Assert.That(outputs[2].Id, Is.EqualTo(3)); - Assert.That(actualOutput.Id, Is.EqualTo(3)); - Assert.That(actualOutput.Name, Is.EqualTo("Test3")); - Assert.That(actualOutput.EndpointId, Is.EqualTo(_endpointId)); - } - - [Test] - public async Task Delete() - { - // Arrange - var outputs = new[] - { - new Output {Name = "Test1", EndpointId = _endpointId}, - new Output {Name = "Test2", EndpointId = _endpointId}, - new Output {Name = "Test3", EndpointId = _endpointId} - }; - - var outputRepository = new OutputRepository(_dataAccess); - foreach (var output in outputs) - { - await outputRepository.Insert(output); - } - - // Act - await outputRepository.Delete(3); - - // Assert - var actualOutputs = await outputRepository.GetAll(); - Assert.That(2, Is.EqualTo(actualOutputs.Count())); - } -} \ No newline at end of file diff --git a/test/Aporta.Core.Tests/DataAccess/Repositories/PersonRepositoryTests.cs b/test/Aporta.Core.Tests/DataAccess/Repositories/PersonRepositoryTests.cs deleted file mode 100644 index 4c8c842..0000000 --- a/test/Aporta.Core.Tests/DataAccess/Repositories/PersonRepositoryTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using System.Data; -using System.Linq; -using System.Threading.Tasks; -using Aporta.Core.DataAccess; -using Aporta.Core.DataAccess.Repositories; -using Aporta.Core.Models; -using Aporta.Shared.Models; -using NUnit.Framework; - -namespace Aporta.Core.Tests.DataAccess.Repositories; - -public class PersonRepositoryTests -{ - private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); - private IDbConnection _persistConnection; - private Guid _extensionId; - - [SetUp] - public async Task Setup() - { - _persistConnection = _dataAccess.CreateDbConnection(); - _persistConnection.Open(); - - await _dataAccess.UpdateSchema(); - - _extensionId = Guid.NewGuid(); - var extensions = new[] - { - new ExtensionHost {Id = _extensionId, Name = "ExtensionTest", Enabled = false} - }; - - var extensionRepository = new ExtensionRepository(_dataAccess); - foreach (var extension in extensions) - { - await extensionRepository.Insert(extension); - } - } - - [TearDown] - public void TearDown() - { - _persistConnection?.Close(); - _persistConnection?.Dispose(); - } - - [Test] - public async Task Insert() - { - // Arrange - var people = new[] - { - new Person {FirstName = "First1", LastName = "Last1", Enabled = false}, - new Person {FirstName = "First2", LastName = "Last2", Enabled = true}, - }; - - var personRepository = new PersonRepository(_dataAccess); - foreach (var person in people) - { - await personRepository.Insert(person); - } - - // Act - var actualPerson = await personRepository.Get(2); - - // Assert - Assert.That(people[1].Id, Is.EqualTo(2)); - Assert.That(actualPerson.Id, Is.EqualTo(2)); - Assert.That(actualPerson.FirstName, Is.EqualTo("First2")); - Assert.That(actualPerson.LastName, Is.EqualTo("Last2")); - Assert.That(actualPerson.Enabled, Is.True); - } - - [Test] - public async Task Delete() - { - // Arrange - var people = new[] - { - new Person {FirstName = "First1", LastName = "Last1", Enabled = false}, - new Person {FirstName = "First2", LastName = "Last2", Enabled = true}, - }; - - var personRepository = new PersonRepository(_dataAccess); - foreach (var person in people) - { - await personRepository.Insert(person); - } - - // Act - await personRepository.Delete(2); - - // Assert - var actualPeople = await personRepository.GetAll(); - Assert.That(1, Is.EqualTo(actualPeople.Count())); - } -} \ No newline at end of file diff --git a/test/Aporta.Core.Tests/DataAccess/Repositories/ProtoJsonRepositoryTests.cs b/test/Aporta.Core.Tests/DataAccess/Repositories/ProtoJsonRepositoryTests.cs new file mode 100644 index 0000000..d2f54db --- /dev/null +++ b/test/Aporta.Core.Tests/DataAccess/Repositories/ProtoJsonRepositoryTests.cs @@ -0,0 +1,512 @@ +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using NUnit.Framework; +using Z9.Spcore.Proto; + +namespace Aporta.Core.Tests.DataAccess.Repositories; + +[TestFixture] +public class DataFormatRepositoryTests +{ + private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); + private IDbConnection _persistConnection; + + [SetUp] + public async Task Setup() + { + _persistConnection = _dataAccess.CreateDbConnection(); + _persistConnection.Open(); + await _dataAccess.UpdateSchema(); + } + + [TearDown] + public void TearDown() + { + _persistConnection?.Close(); + _persistConnection?.Dispose(); + } + + [Test] + public async Task Upsert_NewRecord_Stores() + { + var repository = new DataFormatRepository(_dataAccess); + var dataFormat = new DataFormat + { + Unid = 1, + Name = "Standard 26-bit" + }; + + await repository.Upsert(dataFormat); + var retrieved = await repository.Get(1); + + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved.Unid, Is.EqualTo(1)); + Assert.That(retrieved.Name, Is.EqualTo("Standard 26-bit")); + } + + [Test] + public async Task Upsert_ExistingRecord_Updates() + { + var repository = new DataFormatRepository(_dataAccess); + var original = new DataFormat { Unid = 1, Name = "Original" }; + var updated = new DataFormat { Unid = 1, Name = "Updated" }; + + await repository.Upsert(original); + await repository.Upsert(updated); + var retrieved = await repository.Get(1); + + Assert.That(retrieved.Name, Is.EqualTo("Updated")); + } + + [Test] + public async Task Get_NonExistentId_ReturnsNull() + { + var repository = new DataFormatRepository(_dataAccess); + var retrieved = await repository.Get(999); + Assert.That(retrieved, Is.Null); + } + + [Test] + public async Task GetAll_MultipleRecords_ReturnsAll() + { + var repository = new DataFormatRepository(_dataAccess); + await repository.Upsert(new DataFormat { Unid = 1, Name = "Format1" }); + await repository.Upsert(new DataFormat { Unid = 2, Name = "Format2" }); + await repository.Upsert(new DataFormat { Unid = 3, Name = "Format3" }); + + var all = (await repository.GetAll()).ToList(); + + Assert.That(all.Count, Is.EqualTo(3)); + } + + [Test] + public async Task Delete_ExistingRecord_Removes() + { + var repository = new DataFormatRepository(_dataAccess); + await repository.Upsert(new DataFormat { Unid = 1, Name = "ToDelete" }); + + await repository.Delete(1); + var retrieved = await repository.Get(1); + + Assert.That(retrieved, Is.Null); + } + + [Test] + public async Task DeleteAll_ClearsTable() + { + var repository = new DataFormatRepository(_dataAccess); + await repository.Upsert(new DataFormat { Unid = 1, Name = "Format1" }); + await repository.Upsert(new DataFormat { Unid = 2, Name = "Format2" }); + + await repository.DeleteAll(); + var count = await repository.Count(); + + Assert.That(count, Is.EqualTo(0)); + } + + [Test] + public async Task DataFormat_WithBinaryFormat_PreservesExtension() + { + var repository = new DataFormatRepository(_dataAccess); + var dataFormat = new DataFormat + { + Unid = 1, + Name = "Standard 26-bit Wiegand", + DataFormatType = DataFormatType.Binary, + ExtBinaryFormat = new BinaryFormat + { + MinBits = 26, + MaxBits = 26, + SupportReverseRead = false + } + }; + + await repository.Upsert(dataFormat); + var retrieved = await repository.Get(1); + + Assert.That(retrieved.DataFormatType, Is.EqualTo(DataFormatType.Binary)); + Assert.That(retrieved.ExtBinaryFormat, Is.Not.Null); + Assert.That(retrieved.ExtBinaryFormat.MinBits, Is.EqualTo(26)); + Assert.That(retrieved.ExtBinaryFormat.MaxBits, Is.EqualTo(26)); + Assert.That(retrieved.ExtBinaryFormat.SupportReverseRead, Is.False); + } +} + +[TestFixture] +public class DataLayoutRepositoryTests +{ + private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); + private IDbConnection _persistConnection; + + [SetUp] + public async Task Setup() + { + _persistConnection = _dataAccess.CreateDbConnection(); + _persistConnection.Open(); + await _dataAccess.UpdateSchema(); + } + + [TearDown] + public void TearDown() + { + _persistConnection?.Close(); + _persistConnection?.Dispose(); + } + + [Test] + public async Task Upsert_NewRecord_Stores() + { + var repository = new DataLayoutRepository(_dataAccess); + var dataLayout = new DataLayout + { + Unid = 1, + Name = "Standard Layout" + }; + + await repository.Upsert(dataLayout); + var retrieved = await repository.Get(1); + + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved.Unid, Is.EqualTo(1)); + Assert.That(retrieved.Name, Is.EqualTo("Standard Layout")); + } + + [Test] + public async Task DataLayout_WithBasicDataLayout_PreservesExtension() + { + var repository = new DataLayoutRepository(_dataAccess); + var dataLayout = new DataLayout + { + Unid = 1, + Name = "Basic Layout", + LayoutType = DataLayoutType.Basic, + ExtBasicDataLayout = new BasicDataLayout + { + DataFormatUnid = 10 + } + }; + + await repository.Upsert(dataLayout); + var retrieved = await repository.Get(1); + + Assert.That(retrieved.LayoutType, Is.EqualTo(DataLayoutType.Basic)); + Assert.That(retrieved.ExtBasicDataLayout, Is.Not.Null); + Assert.That(retrieved.ExtBasicDataLayout.DataFormatUnid, Is.EqualTo(10)); + } +} + +[TestFixture] +public class CredTemplateRepositoryTests +{ + private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); + private IDbConnection _persistConnection; + + [SetUp] + public async Task Setup() + { + _persistConnection = _dataAccess.CreateDbConnection(); + _persistConnection.Open(); + await _dataAccess.UpdateSchema(); + } + + [TearDown] + public void TearDown() + { + _persistConnection?.Close(); + _persistConnection?.Dispose(); + } + + [Test] + public async Task Upsert_NewRecord_Stores() + { + var repository = new CredTemplateRepository(_dataAccess); + var credTemplate = new CredTemplate + { + Unid = 1, + Name = "Standard Card Template" + }; + + await repository.Upsert(credTemplate); + var retrieved = await repository.Get(1); + + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved.Unid, Is.EqualTo(1)); + Assert.That(retrieved.Name, Is.EqualTo("Standard Card Template")); + } + + [Test] + public async Task CredTemplate_WithCardPinTemplate_PreservesNested() + { + var repository = new CredTemplateRepository(_dataAccess); + var credTemplate = new CredTemplate + { + Unid = 1, + Name = "Card Template", + CardPinTemplate = new CardPinTemplate + { + DataLayoutUnid = 5, + AnyDataLayout = false + } + }; + + await repository.Upsert(credTemplate); + var retrieved = await repository.Get(1); + + Assert.That(retrieved.CardPinTemplate, Is.Not.Null); + Assert.That(retrieved.CardPinTemplate.DataLayoutUnid, Is.EqualTo(5)); + } +} + +[TestFixture] +public class PrivRepositoryTests +{ + private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); + private IDbConnection _persistConnection; + + [SetUp] + public async Task Setup() + { + _persistConnection = _dataAccess.CreateDbConnection(); + _persistConnection.Open(); + await _dataAccess.UpdateSchema(); + } + + [TearDown] + public void TearDown() + { + _persistConnection?.Close(); + _persistConnection?.Dispose(); + } + + [Test] + public async Task Upsert_NewRecord_Stores() + { + var repository = new PrivRepository(_dataAccess); + var priv = new Priv + { + Unid = 1, + Name = "Front Door Access" + }; + + await repository.Upsert(priv); + var retrieved = await repository.Get(1); + + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved.Unid, Is.EqualTo(1)); + Assert.That(retrieved.Name, Is.EqualTo("Front Door Access")); + } + + [Test] + public async Task Priv_WithDoorAccessPriv_PreservesExtension() + { + var repository = new PrivRepository(_dataAccess); + var doorAccessPriv = new DoorAccessPriv(); + doorAccessPriv.Elements.Add(new DoorAccessPrivElement { DoorUnid = 10 }); + var priv = new Priv + { + Unid = 1, + Name = "Door Access", + PrivType = PrivType.Door, + ExtDoorAccessPriv = doorAccessPriv + }; + + await repository.Upsert(priv); + var retrieved = await repository.Get(1); + + Assert.That(retrieved.PrivType, Is.EqualTo(PrivType.Door)); + Assert.That(retrieved.ExtDoorAccessPriv, Is.Not.Null); + Assert.That(retrieved.ExtDoorAccessPriv.Elements[0].DoorUnid, Is.EqualTo(10)); + } +} + +[TestFixture] +public class SchedRepositoryTests +{ + private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); + private IDbConnection _persistConnection; + + [SetUp] + public async Task Setup() + { + _persistConnection = _dataAccess.CreateDbConnection(); + _persistConnection.Open(); + await _dataAccess.UpdateSchema(); + } + + [TearDown] + public void TearDown() + { + _persistConnection?.Close(); + _persistConnection?.Dispose(); + } + + [Test] + public async Task Upsert_NewRecord_Stores() + { + var repository = new SchedRepository(_dataAccess); + var sched = new Sched + { + Unid = 1, + Name = "Business Hours" + }; + + await repository.Upsert(sched); + var retrieved = await repository.Get(1); + + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved.Unid, Is.EqualTo(1)); + Assert.That(retrieved.Name, Is.EqualTo("Business Hours")); + } + + [Test] + public async Task Sched_WithElements_PreservesRepeated() + { + var repository = new SchedRepository(_dataAccess); + var sched = new Sched + { + Unid = 1, + Name = "Work Week" + }; + var element = new SchedElement + { + Start = new SqlTimeData { Hour = 8, Minute = 0, Second = 0 }, + Stop = new SqlTimeData { Hour = 17, Minute = 0, Second = 0 } + }; + element.SchedDays.Add(SchedDay.Mon); + element.SchedDays.Add(SchedDay.Tues); + element.SchedDays.Add(SchedDay.Wed); + element.SchedDays.Add(SchedDay.Thur); + element.SchedDays.Add(SchedDay.Fri); + sched.Elements.Add(element); + + await repository.Upsert(sched); + var retrieved = await repository.Get(1); + + Assert.That(retrieved.Elements.Count, Is.EqualTo(1)); + Assert.That(retrieved.Elements[0].SchedDays, Has.Count.EqualTo(5)); + Assert.That(retrieved.Elements[0].Start.Hour, Is.EqualTo(8)); + } +} + +[TestFixture] +public class HolRepositoryTests +{ + private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); + private IDbConnection _persistConnection; + + [SetUp] + public async Task Setup() + { + _persistConnection = _dataAccess.CreateDbConnection(); + _persistConnection.Open(); + await _dataAccess.UpdateSchema(); + } + + [TearDown] + public void TearDown() + { + _persistConnection?.Close(); + _persistConnection?.Dispose(); + } + + [Test] + public async Task Upsert_NewRecord_Stores() + { + var repository = new HolRepository(_dataAccess); + var hol = new Hol + { + Unid = 1, + Name = "Christmas" + }; + + await repository.Upsert(hol); + var retrieved = await repository.Get(1); + + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved.Unid, Is.EqualTo(1)); + Assert.That(retrieved.Name, Is.EqualTo("Christmas")); + } +} + +[TestFixture] +public class HolCalRepositoryTests +{ + private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); + private IDbConnection _persistConnection; + + [SetUp] + public async Task Setup() + { + _persistConnection = _dataAccess.CreateDbConnection(); + _persistConnection.Open(); + await _dataAccess.UpdateSchema(); + } + + [TearDown] + public void TearDown() + { + _persistConnection?.Close(); + _persistConnection?.Dispose(); + } + + [Test] + public async Task Upsert_NewRecord_Stores() + { + var repository = new HolCalRepository(_dataAccess); + var holCal = new HolCal + { + Unid = 1, + Name = "US Federal Holidays" + }; + + await repository.Upsert(holCal); + var retrieved = await repository.Get(1); + + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved.Unid, Is.EqualTo(1)); + Assert.That(retrieved.Name, Is.EqualTo("US Federal Holidays")); + } +} + +[TestFixture] +public class HolTypeRepositoryTests +{ + private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); + private IDbConnection _persistConnection; + + [SetUp] + public async Task Setup() + { + _persistConnection = _dataAccess.CreateDbConnection(); + _persistConnection.Open(); + await _dataAccess.UpdateSchema(); + } + + [TearDown] + public void TearDown() + { + _persistConnection?.Close(); + _persistConnection?.Dispose(); + } + + [Test] + public async Task Upsert_NewRecord_Stores() + { + var repository = new HolTypeRepository(_dataAccess); + var holType = new HolType + { + Unid = 1, + Name = "Federal Holiday" + }; + + await repository.Upsert(holType); + var retrieved = await repository.Get(1); + + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved.Unid, Is.EqualTo(1)); + Assert.That(retrieved.Name, Is.EqualTo("Federal Holiday")); + } +} diff --git a/test/Aporta.Core.Tests/DataAccess/Repositories/Z9EvtRepositoryTests.cs b/test/Aporta.Core.Tests/DataAccess/Repositories/Z9EvtRepositoryTests.cs new file mode 100644 index 0000000..3fd374b --- /dev/null +++ b/test/Aporta.Core.Tests/DataAccess/Repositories/Z9EvtRepositoryTests.cs @@ -0,0 +1,183 @@ +using System; +using System.Data; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Shared.Models; +using NUnit.Framework; +using Z9.Spcore.Proto; + +namespace Aporta.Core.Tests.DataAccess.Repositories; + +public class Z9EvtRepositoryTests +{ + private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); + private IDbConnection _persistConnection; + + [SetUp] + public async Task Setup() + { + _persistConnection = _dataAccess.CreateDbConnection(); + _persistConnection.Open(); + + await _dataAccess.UpdateSchema(); + } + + [TearDown] + public void TearDown() + { + _persistConnection?.Close(); + _persistConnection?.Dispose(); + } + + [Test] + public async Task Insert() + { + // Arrange + var repository = new Z9EvtRepository(_dataAccess); + + var evt1 = new Evt + { + EvtCode = EvtCode.DoorAccessGranted, + HwTime = new DateTimeData { Millis = 1000 }, + DbTime = new DateTimeData { Millis = 1000 }, + Priority = 0 + }; + var evt2 = new Evt + { + EvtCode = EvtCode.DoorAccessDenied, + HwTime = new DateTimeData { Millis = 2000 }, + DbTime = new DateTimeData { Millis = 2000 }, + Priority = 0 + }; + + // Act + int id1 = await repository.Insert(evt1); + int id2 = await repository.Insert(evt2); + + // Assert — auto-increment IDs + Assert.That(id1, Is.EqualTo(1)); + Assert.That(id2, Is.EqualTo(2)); + Assert.That(evt1.Unid, Is.EqualTo(1)); + Assert.That(evt2.Unid, Is.EqualTo(2)); + + // Verify round-trip via Get + var retrieved = await repository.Get(id1); + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved.EvtCode, Is.EqualTo(EvtCode.DoorAccessGranted)); + Assert.That(retrieved.HwTime.Millis, Is.EqualTo(1000)); + } + + [Test] + public async Task GetAll_Paginated() + { + // Arrange + var repository = new Z9EvtRepository(_dataAccess); + + for (int i = 0; i < 5; i++) + { + await repository.Insert(new Evt + { + EvtCode = EvtCode.DoorAccessGranted, + HwTime = new DateTimeData { Millis = i * 1000 }, + DbTime = new DateTimeData { Millis = i * 1000 }, + Priority = 0 + }); + } + + // Act — page 1, size 2 (should get IDs 5,4 since ORDER BY id DESC) + var page1 = await repository.GetAll(1, 2); + + // Assert + Assert.That(page1.TotalItems, Is.EqualTo(5)); + Assert.That(page1.PageNumber, Is.EqualTo(1)); + Assert.That(page1.PageSize, Is.EqualTo(2)); + var page1Items = page1.Items.ToList(); + Assert.That(page1Items, Has.Count.EqualTo(2)); + Assert.That(page1Items[0].HwTime.Millis, Is.EqualTo(4000)); // most recent first + Assert.That(page1Items[1].HwTime.Millis, Is.EqualTo(3000)); + + // Act — page 3, size 2 (should get 1 item: ID 1) + var page3 = await repository.GetAll(3, 2); + var page3Items = page3.Items.ToList(); + Assert.That(page3Items, Has.Count.EqualTo(1)); + Assert.That(page3Items[0].HwTime.Millis, Is.EqualTo(0)); + } + + [Test] + public async Task Delete() + { + // Arrange + var repository = new Z9EvtRepository(_dataAccess); + + var evt = new Evt + { + EvtCode = EvtCode.CredReaderOnline, + HwTime = new DateTimeData { Millis = 1000 }, + DbTime = new DateTimeData { Millis = 1000 } + }; + int id = await repository.Insert(evt); + + // Act + await repository.Delete(id); + + // Assert + Assert.That(await repository.Count(), Is.EqualTo(0)); + } + + [Test] + public async Task Count() + { + // Arrange + var repository = new Z9EvtRepository(_dataAccess); + Assert.That(await repository.Count(), Is.EqualTo(0)); + + // Act + await repository.Insert(new Evt { EvtCode = EvtCode.DoorAccessGranted }); + await repository.Insert(new Evt { EvtCode = EvtCode.DoorAccessDenied }); + await repository.Insert(new Evt { EvtCode = EvtCode.DoorUnlocked }); + + // Assert + Assert.That(await repository.Count(), Is.EqualTo(3)); + } + + [Test] + public async Task Insert_WithEventDataJson_RoundTrips() + { + // Arrange + var repository = new Z9EvtRepository(_dataAccess); + + var eventData = new EventData + { + EventReason = EventReason.CredentialExpired, + Endpoint = new Endpoint { Id = 1, Name = "Reader 1", DriverEndpointId = "ep1", ExtensionId = Guid.Empty }, + CardNumber = "12345" + }; + var eventDataJson = JsonSerializer.Serialize(eventData); + + var evt = new Evt + { + EvtCode = EvtCode.DoorAccessDenied, + EvtSubCode = EvtSubCode.AccessDeniedExpired, + HwTime = new DateTimeData { Millis = 1000 }, + DbTime = new DateTimeData { Millis = 1000 }, + Data = eventDataJson + }; + + // Act + int id = await repository.Insert(evt); + var retrieved = await repository.Get(id); + + // Assert + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved.Data, Is.EqualTo(eventDataJson)); + Assert.That(retrieved.EvtCode, Is.EqualTo(EvtCode.DoorAccessDenied)); + Assert.That(retrieved.EvtSubCode, Is.EqualTo(EvtSubCode.AccessDeniedExpired)); + + var roundTrippedData = JsonSerializer.Deserialize(retrieved.Data); + Assert.That(roundTrippedData.EventReason, Is.EqualTo(EventReason.CredentialExpired)); + Assert.That(roundTrippedData.CardNumber, Is.EqualTo("12345")); + } +} diff --git a/test/Aporta.Core.Tests/Services/EventReasonMappingTests.cs b/test/Aporta.Core.Tests/Services/EventReasonMappingTests.cs new file mode 100644 index 0000000..a4e992b --- /dev/null +++ b/test/Aporta.Core.Tests/Services/EventReasonMappingTests.cs @@ -0,0 +1,82 @@ +using System; +using Aporta.Core.Services; +using Aporta.Shared.Models; +using NUnit.Framework; +using Z9.Spcore.Proto; + +namespace Aporta.Core.Tests.Services; + +public class EventReasonMappingTests +{ + [Test] + public void AccessGranted_MapsToEvtCode() + { + var (evtCode, evtSubCode) = + EventReasonMapping.ToEvtCodes(EventType.AccessGranted, EventReason.None); + + Assert.That(evtCode, Is.EqualTo(EvtCode.DoorAccessGranted)); + Assert.That(evtSubCode, Is.Null); + } + + [TestCase(EventReason.CredentialNotEnrolled, EvtSubCode.AccessDeniedUnknownCredNum)] + [TestCase(EventReason.NoCredentialTemplate, EvtSubCode.AccessDeniedUnknownCredNumFormat)] + [TestCase(EventReason.CredentialDisabled, EvtSubCode.AccessDeniedInactive)] + [TestCase(EventReason.CredentialNotYetEffective, EvtSubCode.AccessDeniedNotEffective)] + [TestCase(EventReason.CredentialExpired, EvtSubCode.AccessDeniedExpired)] + [TestCase(EventReason.NoPrivilege, EvtSubCode.AccessDeniedNoPriv)] + [TestCase(EventReason.AccessNotAssigned, EvtSubCode.AccessDeniedNoPriv)] + [TestCase(EventReason.OutsideSchedule, EvtSubCode.AccessDeniedOutsideSched)] + public void AccessDenied_MapsToCorrectSubCode(EventReason reason, EvtSubCode expectedSubCode) + { + var (evtCode, evtSubCode) = + EventReasonMapping.ToEvtCodes(EventType.AccessDenied, reason); + + Assert.That(evtCode, Is.EqualTo(EvtCode.DoorAccessDenied)); + Assert.That(evtSubCode, Is.EqualTo(expectedSubCode)); + } + + [Test] + public void DoorLocked_MapsToStaticLocked_NotNoPriv() + { + var (evtCode, evtSubCode) = + EventReasonMapping.ToEvtCodes(EventType.AccessDenied, EventReason.DoorLocked); + + Assert.That(evtCode, Is.EqualTo(EvtCode.DoorAccessDenied)); + Assert.That(evtSubCode, Is.EqualTo(EvtSubCode.AccessDeniedDoorModeStaticLocked)); + } + + [TestCase(EventReason.DoorUsed)] + [TestCase(EventReason.DoorNotUsed)] + public void UnmappedReason_Throws(EventReason reason) + { + Assert.Throws(() => + EventReasonMapping.ToEvtCodes(EventType.AccessDenied, reason)); + } + + [Test] + public void ReverseMapping_AccessGranted() + { + var (eventType, reason) = + EventReasonMapping.ToEventTypeAndReason(EvtCode.DoorAccessGranted, 0); + + Assert.That(eventType, Is.EqualTo(EventType.AccessGranted)); + Assert.That(reason, Is.EqualTo(EventReason.None)); + } + + [TestCase(EvtSubCode.AccessDeniedUnknownCredNum, EventReason.CredentialNotEnrolled)] + [TestCase(EvtSubCode.AccessDeniedUnknownCredNumFormat, EventReason.NoCredentialTemplate)] + [TestCase(EvtSubCode.AccessDeniedInactive, EventReason.CredentialDisabled)] + [TestCase(EvtSubCode.AccessDeniedNotEffective, EventReason.CredentialNotYetEffective)] + [TestCase(EvtSubCode.AccessDeniedExpired, EventReason.CredentialExpired)] + [TestCase(EvtSubCode.AccessDeniedNoPriv, EventReason.NoPrivilege)] + [TestCase(EvtSubCode.AccessDeniedOutsideSched, EventReason.OutsideSchedule)] + [TestCase(EvtSubCode.AccessDeniedDoorModeStaticLocked, EventReason.DoorLocked)] + public void ReverseMapping_AccessDenied(EvtSubCode subCode, EventReason expectedReason) + { + var (eventType, reason) = + EventReasonMapping.ToEventTypeAndReason(EvtCode.DoorAccessDenied, subCode); + + Assert.That(eventType, Is.EqualTo(EventType.AccessDenied)); + Assert.That(reason, Is.EqualTo(expectedReason)); + } +} diff --git a/test/Aporta.Core.Tests/Services/InputServiceTests.cs b/test/Aporta.Core.Tests/Services/InputServiceTests.cs index a45b4df..ad8918e 100644 --- a/test/Aporta.Core.Tests/Services/InputServiceTests.cs +++ b/test/Aporta.Core.Tests/Services/InputServiceTests.cs @@ -19,7 +19,7 @@ using Aporta.Core.Services; using Aporta.Extensions; using Aporta.Shared.Messaging; -using Aporta.Shared.Models; +using Z9.Spcore.Proto; namespace Aporta.Core.Tests.Services; @@ -47,17 +47,17 @@ public async Task Setup() await _extensionService.Startup(); await _extensionService.EnableExtension(_extensionId, true); - // Wait for endpoints to be inserted - var endpointRepository = new EndpointRepository(_dataAccess); + // Wait for pool z9_devs to be synced from driver (1 controller + 5 endpoints) + var z9DevRepository = new Z9DevRepository(_dataAccess); using CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - while ((await endpointRepository.GetAll()).Count() != 5 && !cancellationTokenSource.Token.IsCancellationRequested) + while ((await z9DevRepository.GetAll()).Count() < 6 && !cancellationTokenSource.Token.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(1), cancellationTokenSource.Token); } - - if(cancellationTokenSource.Token.IsCancellationRequested) + + if(cancellationTokenSource.Token.IsCancellationRequested) { - Assert.Fail("Timeout waiting for endpoints to be inserted"); + Assert.Fail("Timeout waiting for pool z9_devs to be synced"); } } @@ -65,7 +65,7 @@ public async Task Setup() public void TearDown() { _extensionService.Shutdown(); - + _persistConnection?.Close(); _persistConnection?.Dispose(); } @@ -74,28 +74,34 @@ public void TearDown() public async Task GetState() { // Arrange - var inputService = new InputService(_dataAccess, + var z9DevRepository = new Z9DevRepository(_dataAccess); + // InputService is still instantiated to subscribe to StateChanged events + _ = new InputService(_dataAccess, new UnitTestingSupportForIHubContext().IHubContextMock.Object, - _extensionService); - var inputs = new[] - { - new Input {Name = "TestInput1", EndpointId = 4}, - new Input {Name = "TestInput2", EndpointId = 5} - }; + _extensionService, new DevStateService()); + + var available = (await z9DevRepository.GetAvailableByDevType(DevType.Sensor)).ToArray(); + Assert.That(available.Length, Is.EqualTo(2), "Expected 2 available sensor endpoints"); - var inputRepository = new InputRepository(_dataAccess); - foreach (var input in inputs) + // Assign sensors (set name + enabled) + foreach (var dev in available) { - await inputRepository.Insert(input); + dev.Name = $"TestInput_{dev.Unid}"; + dev.Enabled = true; + await z9DevRepository.Upsert(dev); } - // Act - await SendInputState("I2", true); + // Act — send state via named pipe using the driver endpoint ID of the second input + var secondDev = available[1]; + var extensionId = await z9DevRepository.GetExtensionId(secondDev); + await SendInputState(secondDev.ExternalId, true); // Assert - Assert.That(async () => await inputService.GetState(inputs[0].Id), + Assert.That(async () => + await _extensionService.GetMonitorPoint(extensionId.Value, available[0].ExternalId).GetState(), Is.False.After(1000, 100)); - Assert.That(async () => await inputService.GetState(inputs[1].Id), + Assert.That(async () => + await _extensionService.GetMonitorPoint(extensionId.Value, secondDev.ExternalId).GetState(), Is.True.After(1000, 100)); } @@ -104,28 +110,32 @@ public async Task ReceiveStateChange() { // Arrange var hubContext = new UnitTestingSupportForIHubContext(); - var inputService = new InputService(_dataAccess, hubContext.IHubContextMock.Object, _extensionService); - var inputs = new[] - { - new Input {Name = "TestInput1", EndpointId = 4}, - new Input {Name = "TestInput2", EndpointId = 5} - }; + var z9DevRepository = new Z9DevRepository(_dataAccess); + _ = new InputService(_dataAccess, hubContext.IHubContextMock.Object, _extensionService, new DevStateService()); + + var available = (await z9DevRepository.GetAvailableByDevType(DevType.Sensor)).ToArray(); + Assert.That(available.Length, Is.EqualTo(2), "Expected 2 available sensor endpoints"); - var inputRepository = new InputRepository(_dataAccess); - foreach (var input in inputs) + // Assign sensors (set name + enabled) + foreach (var dev in available) { - await inputRepository.Insert(input); + dev.Name = $"TestInput_{dev.Unid}"; + dev.Enabled = true; + await z9DevRepository.Upsert(dev); } - // Act - await SendInputState("I2", true); + // Act — send state via named pipe using the driver endpoint ID of the second input + var secondDev = available[1]; + var extensionId = await z9DevRepository.GetExtensionId(secondDev); + await SendInputState(secondDev.ExternalId, true); // Assert // Wait for state to be updated on service before verifying - Assert.That(async () => await inputService.GetState(inputs[1].Id), + Assert.That(async () => + await _extensionService.GetMonitorPoint(extensionId.Value, secondDev.ExternalId).GetState(), Is.True.After(1000, 100)); hubContext.ClientsAllMock.Verify(clientProxy => - clientProxy.SendCoreAsync(Methods.InputStateChanged, new object[] {2, true}, + clientProxy.SendCoreAsync(Methods.InputStateChanged, new object[] {secondDev.Unid, true}, It.IsAny())); } @@ -133,10 +143,10 @@ private static async Task SendInputState(string id, bool state) { await using var pipeClient = new NamedPipeClientStream(".", "Aporta.TestDriverMonitorPoint", PipeDirection.Out, PipeOptions.Asynchronous); - + await pipeClient.ConnectAsync(); await using var writer = new StreamWriter(pipeClient); writer.AutoFlush = true; await writer.WriteLineAsync($"{id}|{state}"); } -} \ No newline at end of file +} diff --git a/test/Aporta.Core.Tests/Services/OutputServiceTests.cs b/test/Aporta.Core.Tests/Services/OutputServiceTests.cs index 4d6ffba..0f70b6d 100644 --- a/test/Aporta.Core.Tests/Services/OutputServiceTests.cs +++ b/test/Aporta.Core.Tests/Services/OutputServiceTests.cs @@ -8,12 +8,12 @@ using Aporta.Core.Hubs; using Aporta.Core.Services; using Aporta.Extensions; -using Aporta.Shared.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using NUnit.Framework; using SignalR_UnitTestingSupportCommon.IHubContextSupport; +using Z9.Spcore.Proto; namespace Aporta.Core.Tests.Services; @@ -41,17 +41,17 @@ public async Task Setup() await _extensionService.Startup(); await _extensionService.EnableExtension(_extensionId, true); - // Wait for endpoints to be inserted - var endpointRepository = new EndpointRepository(_dataAccess); + // Wait for pool z9_devs to be synced from driver (1 controller + 5 endpoints) + var z9DevRepository = new Z9DevRepository(_dataAccess); using CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - while ((await endpointRepository.GetAll()).Count() != 5 && !cancellationTokenSource.Token.IsCancellationRequested) + while ((await z9DevRepository.GetAll()).Count() < 6 && !cancellationTokenSource.Token.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(1), cancellationTokenSource.Token); } - - if(cancellationTokenSource.Token.IsCancellationRequested) + + if(cancellationTokenSource.Token.IsCancellationRequested) { - Assert.Fail("Timeout waiting for endpoints to be inserted"); + Assert.Fail("Timeout waiting for pool z9_devs to be synced"); } } @@ -59,7 +59,7 @@ public async Task Setup() public void TearDown() { _extensionService.Shutdown(); - + _persistConnection?.Close(); _persistConnection?.Dispose(); } @@ -68,27 +68,32 @@ public void TearDown() public async Task SetState() { // Arrange - var outputService = new OutputService(_dataAccess, + var z9DevRepository = new Z9DevRepository(_dataAccess); + // OutputService is still instantiated to subscribe to StateChanged events + _ = new OutputService(_dataAccess, new UnitTestingSupportForIHubContext().IHubContextMock.Object, - _extensionService); - - var outputs = new[] - { - new Output {Name = "TestOutput1", EndpointId = 2}, - new Output {Name = "TestOutput2", EndpointId = 3} - }; - var outputRepository = new OutputRepository(_dataAccess); - foreach (var output in outputs) + _extensionService, new DevStateService()); + + var available = (await z9DevRepository.GetAvailableByDevType(DevType.Actuator)).ToArray(); + Assert.That(available.Length, Is.EqualTo(2), "Expected 2 available actuator endpoints"); + + // Assign actuators (set name + enabled) + foreach (var dev in available) { - await outputRepository.Insert(output); + dev.Name = $"TestOutput_{dev.Unid}"; + dev.Enabled = true; + await z9DevRepository.Upsert(dev); } + // Get extension ID for state calls + var extensionId = await z9DevRepository.GetExtensionId(available[0]); + // Act - await outputService.SetState(outputs[0].Id, true); - await outputService.SetState(outputs[1].Id, false); + await _extensionService.GetControlPoint(extensionId.Value, available[0].ExternalId).SetState(true); + await _extensionService.GetControlPoint(extensionId.Value, available[1].ExternalId).SetState(false); // Assert - Assert.That(await outputService.GetState(outputs[0].Id), Is.True); - Assert.That(await outputService.GetState(outputs[1].Id), Is.False); + Assert.That(await _extensionService.GetControlPoint(extensionId.Value, available[0].ExternalId).GetState(), Is.True); + Assert.That(await _extensionService.GetControlPoint(extensionId.Value, available[1].ExternalId).GetState(), Is.False); } -} \ No newline at end of file +} diff --git a/test/Aporta.Core.Tests/Services/Z9OpenCommunityProtocolServiceTests.cs b/test/Aporta.Core.Tests/Services/Z9OpenCommunityProtocolServiceTests.cs new file mode 100644 index 0000000..23092d2 --- /dev/null +++ b/test/Aporta.Core.Tests/Services/Z9OpenCommunityProtocolServiceTests.cs @@ -0,0 +1,368 @@ +using System.Data; +using System.Net; +using System.Net.Sockets; +using System.Numerics; +using System.Threading; +using System.Threading.Tasks; +using Aporta.Core.DataAccess; +using Aporta.Core.DataAccess.Repositories; +using Aporta.Core.Services; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Z9.Protobuf; +using Z9.Spcore.Proto; + +namespace Aporta.Core.Tests.Services; + +[TestFixture] +public class Z9OpenCommunityProtocolServiceTests +{ + private readonly IDataAccess _dataAccess = new SqLiteDataAccess(true); + private IDbConnection _persistConnection; + private Z9OpenCommunityProtocolService _z9OpenCommunityProtocolService; + private TcpListener _hostListener; + + [SetUp] + public async Task Setup() + { + _persistConnection = _dataAccess.CreateDbConnection(); + _persistConnection.Open(); + await _dataAccess.UpdateSchema(); + + _hostListener = new TcpListener(IPAddress.Loopback, 0); + _hostListener.Start(); + var hostPort = ((IPEndPoint)_hostListener.LocalEndpoint).Port; + + _z9OpenCommunityProtocolService = new Z9OpenCommunityProtocolService( + NullLogger.Instance, + _dataAccess, + new DevStateService()); + _z9OpenCommunityProtocolService.Start("127.0.0.1", hostPort); + } + + [TearDown] + public void TearDown() + { + _z9OpenCommunityProtocolService?.Dispose(); + _hostListener?.Stop(); + _persistConnection?.Close(); + _persistConnection?.Dispose(); + } + + private (TcpClient client, SpCoreMessageInputStream mis, SpCoreMessageOutputStream mos) AcceptAsHost() + { + var client = _hostListener.AcceptTcpClient(); + var stream = client.GetStream(); + var mis = new SpCoreMessageInputStream(stream); + var mos = new SpCoreMessageOutputStream(stream); + return (client, mis, mos); + } + + private static void SendIdentification(SpCoreMessageOutputStream mos) + { + var message = new SpCoreMessage + { + Type = SpCoreMessage.Types.Type.Identification, + Identification = new Identification + { + Id = "test-host", + SoftwareVersion = "1.0.0-test", + ProtocolVersion = "0.1", + MaxBodyLength = SpCoreMessageHeader.MAX_LENGTH, + SpCoreDevMod = DevMod.IoControllerZ9Spcore, + ProtocolCapabilities = new ProtocolCapabilities + { + SupportsIdentificationPassword = false, + SupportsIdentificationPasswordUpstream = false + } + } + }; + mos.Write(message); + } + + /// + /// Completes the identification handshake and consumes the CONTROLLER_STARTUP event. + /// + private static SpCoreMessage CompleteIdentification(SpCoreMessageInputStream mis, SpCoreMessageOutputStream mos) + { + SendIdentification(mos); + var identResponse = mis.Read(); + Assert.That(identResponse.Type, Is.EqualTo(SpCoreMessage.Types.Type.Identification)); + + // Consume CONTROLLER_STARTUP event sent after identification + var startupEvt = mis.Read(); + Assert.That(startupEvt.Type, Is.EqualTo(SpCoreMessage.Types.Type.Evt)); + Assert.That(startupEvt.Evt.Count, Is.EqualTo(1)); + Assert.That(startupEvt.Evt[0].EvtCode, Is.EqualTo(EvtCode.ControllerStartup)); + + return identResponse; + } + + [Test] + public void Identification_PanelConnects_BothSidesIdentify() + { + var (client, mis, mos) = AcceptAsHost(); + + try + { + var response = CompleteIdentification(mis, mos); + Assert.That(response, Is.Not.Null); + Assert.That(response.Identification.Id, Is.EqualTo("aporta-panel")); + + Thread.Sleep(100); + Assert.That(_z9OpenCommunityProtocolService.IsConnected, Is.True); + Assert.That(_z9OpenCommunityProtocolService.IsIdentified, Is.True); + } + finally + { + client.Close(); + } + + Assert.That(_z9OpenCommunityProtocolService.LastException, Is.Null); + } + + [Test] + public void DbChange_HostSendsSchedule_PanelRespondsSuccess() + { + var (client, mis, mos) = AcceptAsHost(); + + try + { + CompleteIdentification(mis, mos); + + var dbChange = new DbChange(); + dbChange.Sched.Add(new Sched { Unid = 1, Name = "Test Schedule" }); + var dbChangeMsg = new SpCoreMessage + { + Type = SpCoreMessage.Types.Type.DbChange, + DbChange = dbChange + }; + dbChangeMsg.DbChange.RequestId = 42; + mos.Write(dbChangeMsg); + + var response = mis.Read(); + Assert.That(response, Is.Not.Null); + Assert.That(response.Type, Is.EqualTo(SpCoreMessage.Types.Type.DbChangeResp)); + Assert.That(response.DbChangeResp.RequestId, Is.EqualTo(42)); + Assert.That(response.DbChangeResp.ExceptionCase, Is.EqualTo(DbChangeResp.ExceptionOneofCase.None)); + } + finally + { + client.Close(); + } + + Assert.That(_z9OpenCommunityProtocolService.LastException, Is.Null); + } + + [Test] + public void DbChange_HostSendsCredential_CredentialAndPersonCreatedInDb() + { + var (client, mis, mos) = AcceptAsHost(); + + try + { + CompleteIdentification(mis, mos); + + var dbChange = new DbChange(); + dbChange.Cred.Add(new Cred + { + Unid = 100, + Name = "John Doe", + Enabled = true, + CardPin = new CardPin + { + CredNum = SpCoreProtoUtil.ToBigIntegerData(new BigInteger(123456)) + } + }); + var dbChangeMsg = new SpCoreMessage + { + Type = SpCoreMessage.Types.Type.DbChange, + DbChange = dbChange + }; + dbChangeMsg.DbChange.RequestId = 50; + mos.Write(dbChangeMsg); + + var response = mis.Read(); + Assert.That(response, Is.Not.Null); + Assert.That(response.Type, Is.EqualTo(SpCoreMessage.Types.Type.DbChangeResp)); + Assert.That(response.DbChangeResp.RequestId, Is.EqualTo(50)); + } + finally + { + client.Close(); + } + + Assert.That(_z9OpenCommunityProtocolService.LastException, Is.Null); + + // Verify Z9 Cred was stored with cred_num + var z9CredRepository = new Z9CredRepository(_dataAccess); + var z9Cred = z9CredRepository.Get(100).GetAwaiter().GetResult(); + Assert.That(z9Cred, Is.Not.Null); + Assert.That(z9Cred.Name, Is.EqualTo("John Doe")); + Assert.That(z9Cred.Enabled, Is.True); + Assert.That(Z9CredRepository.ExtractCredNum(z9Cred), Is.EqualTo("123456")); + + // Verify credential can be looked up via CredentialRepository + var credentialRepository = new CredentialRepository(_dataAccess); + var credential = credentialRepository.Get(100).GetAwaiter().GetResult(); + Assert.That(credential, Is.Not.Null); + Assert.That(credential.Number, Is.EqualTo("123456")); + Assert.That(credential.Enabled, Is.True); + } + + [Test] + public void DbChange_HostSendsCredentialWithNoCredNum_CredentialNotCreatedInDb() + { + var (client, mis, mos) = AcceptAsHost(); + + try + { + CompleteIdentification(mis, mos); + + var dbChange = new DbChange(); + dbChange.Cred.Add(new Cred + { + Unid = 200, + Name = "No Card", + Enabled = true + }); + var dbChangeMsg = new SpCoreMessage + { + Type = SpCoreMessage.Types.Type.DbChange, + DbChange = dbChange + }; + dbChangeMsg.DbChange.RequestId = 60; + mos.Write(dbChangeMsg); + + var response = mis.Read(); + Assert.That(response, Is.Not.Null); + Assert.That(response.Type, Is.EqualTo(SpCoreMessage.Types.Type.DbChangeResp)); + Assert.That(response.DbChangeResp.RequestId, Is.EqualTo(60)); + } + finally + { + client.Close(); + } + + Assert.That(_z9OpenCommunityProtocolService.LastException, Is.Null); + + // Verify Z9 Cred was NOT created (no card number = deleted) + var z9CredRepository = new Z9CredRepository(_dataAccess); + var z9Cred = z9CredRepository.Get(200).GetAwaiter().GetResult(); + Assert.That(z9Cred, Is.Null); + + // Verify credential is not visible via CredentialRepository either + var credentialRepository = new CredentialRepository(_dataAccess); + var credential = credentialRepository.Get(200).GetAwaiter().GetResult(); + Assert.That(credential, Is.Null); + } + + [Test] + public void DbChange_HostSendsCredentialWithBlankName_UsesCardNumberAsName() + { + var (client, mis, mos) = AcceptAsHost(); + + try + { + CompleteIdentification(mis, mos); + + var dbChange = new DbChange(); + dbChange.Cred.Add(new Cred + { + Unid = 300, + Enabled = true, + CardPin = new CardPin + { + CredNum = SpCoreProtoUtil.ToBigIntegerData(new BigInteger(789012)) + } + }); + var dbChangeMsg = new SpCoreMessage + { + Type = SpCoreMessage.Types.Type.DbChange, + DbChange = dbChange + }; + dbChangeMsg.DbChange.RequestId = 70; + mos.Write(dbChangeMsg); + + var response = mis.Read(); + Assert.That(response, Is.Not.Null); + Assert.That(response.Type, Is.EqualTo(SpCoreMessage.Types.Type.DbChangeResp)); + Assert.That(response.DbChangeResp.RequestId, Is.EqualTo(70)); + } + finally + { + client.Close(); + } + + Assert.That(_z9OpenCommunityProtocolService.LastException, Is.Null); + + // Verify Z9 Cred was stored with cred_num (no name since cred had blank name) + var z9CredRepository = new Z9CredRepository(_dataAccess); + var z9Cred = z9CredRepository.Get(300).GetAwaiter().GetResult(); + Assert.That(z9Cred, Is.Not.Null); + Assert.That(Z9CredRepository.ExtractCredNum(z9Cred), Is.EqualTo("789012")); + + // Verify credential is visible via CredentialRepository + var credentialRepository = new CredentialRepository(_dataAccess); + var credential = credentialRepository.Get(300).GetAwaiter().GetResult(); + Assert.That(credential, Is.Not.Null); + Assert.That(credential.Number, Is.EqualTo("789012")); + } + + [Test] + public void DevActionReq_HostSendsDoorUnlock_PanelRespondsSuccess() + { + var (client, mis, mos) = AcceptAsHost(); + + try + { + CompleteIdentification(mis, mos); + + var devActionMsg = new SpCoreMessage + { + Type = SpCoreMessage.Types.Type.DevActionReq, + DevActionReq = new DevActionReq + { + DevUnid = 1, + DevActionType = DevActionType.DoorMomentaryUnlock, + RequestId = 99 + } + }; + mos.Write(devActionMsg); + + var response = mis.Read(); + Assert.That(response, Is.Not.Null); + Assert.That(response.Type, Is.EqualTo(SpCoreMessage.Types.Type.DevActionResp)); + Assert.That(response.DevActionResp.RequestId, Is.EqualTo(99)); + Assert.That(response.DevActionResp.ExceptionCase, Is.EqualTo(DevActionResp.ExceptionOneofCase.None)); + } + finally + { + client.Close(); + } + + Assert.That(_z9OpenCommunityProtocolService.LastException, Is.Null); + } + + [Test] + public void ConnectAndDisconnect_CleanLifecycle() + { + var (client, mis, mos) = AcceptAsHost(); + + try + { + CompleteIdentification(mis, mos); + + Thread.Sleep(100); + Assert.That(_z9OpenCommunityProtocolService.IsConnected, Is.True); + } + finally + { + client.Close(); + } + + Thread.Sleep(500); + Assert.That(_z9OpenCommunityProtocolService.IsConnected, Is.False); + Assert.That(_z9OpenCommunityProtocolService.LastException, Is.Null); + } +} diff --git a/test/Aporta.FlexClient.Integration.Tests/Aporta.FlexClient.Integration.Tests.csproj b/test/Aporta.FlexClient.Integration.Tests/Aporta.FlexClient.Integration.Tests.csproj new file mode 100644 index 0000000..0d701da --- /dev/null +++ b/test/Aporta.FlexClient.Integration.Tests/Aporta.FlexClient.Integration.Tests.csproj @@ -0,0 +1,15 @@ + + + net8.0 + enable + enable + + + + + + + + + + diff --git a/test/Aporta.FlexClient.Integration.Tests/FlexClientIntegrationTest.cs b/test/Aporta.FlexClient.Integration.Tests/FlexClientIntegrationTest.cs new file mode 100644 index 0000000..734b7a1 --- /dev/null +++ b/test/Aporta.FlexClient.Integration.Tests/FlexClientIntegrationTest.cs @@ -0,0 +1,406 @@ +using System.Diagnostics; +using System.Net; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary; +using NUnit.Framework; +using Z9Flex; +using Z9Flex.Client.Models; +using FlexApiClient = Z9Flex.Client.FlexClient; + +namespace Aporta.FlexClient.Integration.Tests; + +[TestFixture] +public class FlexClientIntegrationTest +{ + private const string BaseUrl = "https://localhost:5001"; + private const int AportaStartupTimeoutSeconds = 60; + + private static readonly string AportaPath = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "git", "Aporta"); + + private Process? _aportaProcess; + + private static string FindDotnet() + { + var candidates = new[] + { + "/opt/homebrew/opt/dotnet@8/bin/dotnet", + "/usr/local/share/dotnet/dotnet", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dotnet", "dotnet"), + "dotnet" + }; + return candidates.FirstOrDefault(File.Exists) ?? "dotnet"; + } + + private static HttpClientHandler CreateHandler() + { + return new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + } + + private static FlexApiClient CreateUnauthenticatedClient(HttpMessageHandler handler) + { + var adapter = new HttpClientRequestAdapter( + new AnonymousAuthenticationProvider(), + httpClient: new HttpClient(handler)) + { + BaseUrl = BaseUrl + }; + return new FlexApiClient(adapter); + } + + private static FlexApiClient CreateAuthenticatedClient(Z9AuthenticationProvider authProvider) + { + var adapter = new HttpClientRequestAdapter(authProvider, + httpClient: new HttpClient(CreateHandler())) + { + BaseUrl = BaseUrl + }; + return new FlexApiClient(adapter); + } + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + var dotnetPath = FindDotnet(); + var aportaProjectPath = Path.Combine(AportaPath, "src", "Aporta"); + + Assert.That(Directory.Exists(aportaProjectPath), Is.True, + $"Aporta project not found at {aportaProjectPath}"); + + var psi = new ProcessStartInfo(dotnetPath, $"run --project \"{aportaProjectPath}\" -- --cleanDatabase true") + { + WorkingDirectory = AportaPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + _aportaProcess = Process.Start(psi); + Assert.That(_aportaProcess, Is.Not.Null, "Failed to start Aporta process"); + + // Read output on background threads so the process doesn't block + _aportaProcess!.BeginOutputReadLine(); + _aportaProcess.BeginErrorReadLine(); + _aportaProcess.OutputDataReceived += (_, e) => + { + if (e.Data != null) TestContext.Progress.WriteLine($"[APORTA] {e.Data}"); + }; + _aportaProcess.ErrorDataReceived += (_, e) => + { + if (e.Data != null) TestContext.Progress.WriteLine($"[APORTA-ERR] {e.Data}"); + }; + + // Poll until Aporta responds + using var pollHandler = CreateHandler(); + using var pollClient = new HttpClient(pollHandler) { Timeout = TimeSpan.FromSeconds(5) }; + var deadline = DateTime.UtcNow.AddSeconds(AportaStartupTimeoutSeconds); + var ready = false; + while (DateTime.UtcNow < deadline) + { + try + { + var response = await pollClient.GetAsync($"{BaseUrl}/door/list"); + // 401 means Aporta is up (just not authenticated) + if (response.StatusCode == HttpStatusCode.Unauthorized || + response.StatusCode == HttpStatusCode.OK) + { + ready = true; + break; + } + } + catch + { + // Not ready yet + } + + await Task.Delay(1000); + } + + Assert.That(ready, Is.True, "Aporta did not become ready within the timeout"); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + if (_aportaProcess is { HasExited: false }) + { + _aportaProcess.Kill(entireProcessTree: true); + _aportaProcess.WaitForExit(5000); + } + + _aportaProcess?.Dispose(); + } + + [Test] + public async Task FlexClientIntegration() + { + // ================================================================ + // Test 1: 401 without token + // ================================================================ + TestContext.Progress.WriteLine("Test 1: Verify 401 without session token..."); + using var unauthHandler = CreateHandler(); + var unauthClient = CreateUnauthenticatedClient(unauthHandler); + var ex401 = Assert.ThrowsAsync(async () => + await unauthClient.Dev.List.GetAsync()); + Assert.That(ex401!.ResponseStatusCode, Is.EqualTo(401)); + TestContext.Progress.WriteLine("Got 401 as expected"); + + // ================================================================ + // Test 2: Authenticate with admin/pass + // ================================================================ + TestContext.Progress.WriteLine("Test 2: Authenticating with admin/pass..."); + var authProvider = Z9AuthenticationProvider.CreateInstance( + BaseUrl, () => ("admin", "pass"), CreateHandler()); + await authProvider.RefreshTokenAsync(); + Assert.That(authProvider.CurrentAuthenticationResult.Authenticated, Is.True); + Assert.That(authProvider.CurrentAuthenticationResult.SessionToken, Is.Not.Null.And.Not.Empty); + TestContext.Progress.WriteLine( + $"Authenticated, token={authProvider.CurrentAuthenticationResult.SessionToken![..8]}..."); + + // ================================================================ + // Test 3: Bad credentials + // ================================================================ + TestContext.Progress.WriteLine("Test 3: Authenticating with bad credentials..."); + var badAuthProvider = Z9AuthenticationProvider.CreateInstance( + BaseUrl, () => ("admin", "wrong"), CreateHandler()); + await badAuthProvider.RefreshTokenAsync(); + Assert.That(badAuthProvider.CurrentAuthenticationResult.Authenticated, Is.False); + TestContext.Progress.WriteLine("Bad credentials correctly rejected"); + + // ================================================================ + // Test 4: List endpoints — empty database, verify deserialization + // ================================================================ + TestContext.Progress.WriteLine("Test 4: Testing list endpoints..."); + var client = CreateAuthenticatedClient(authProvider); + + // Door list + var doorList = await client.Door.List.GetAsync(); + Assert.That(doorList, Is.Not.Null); + Assert.That(doorList!.InstanceList, Is.Not.Null); + Assert.That(doorList.Count, Is.GreaterThanOrEqualTo(0)); + TestContext.Progress.WriteLine($" door/list: count={doorList.Count}"); + + // Dev list + var devList = await client.Dev.List.GetAsync(); + Assert.That(devList, Is.Not.Null); + Assert.That(devList!.InstanceList, Is.Not.Null); + Assert.That(devList.Count, Is.GreaterThanOrEqualTo(0)); + TestContext.Progress.WriteLine($" dev/list: count={devList.Count}"); + + // Sensor list + var sensorList = await client.Sensor.List.GetAsync(); + Assert.That(sensorList, Is.Not.Null); + Assert.That(sensorList!.InstanceList, Is.Not.Null); + Assert.That(sensorList.Count, Is.GreaterThanOrEqualTo(0)); + TestContext.Progress.WriteLine($" sensor/list: count={sensorList.Count}"); + + // Actuator list + var actuatorList = await client.Actuator.List.GetAsync(); + Assert.That(actuatorList, Is.Not.Null); + Assert.That(actuatorList!.InstanceList, Is.Not.Null); + Assert.That(actuatorList.Count, Is.GreaterThanOrEqualTo(0)); + TestContext.Progress.WriteLine($" actuator/list: count={actuatorList.Count}"); + + // CredReader list + var credReaderList = await client.CredReader.List.GetAsync(); + Assert.That(credReaderList, Is.Not.Null); + Assert.That(credReaderList!.InstanceList, Is.Not.Null); + Assert.That(credReaderList.Count, Is.GreaterThanOrEqualTo(0)); + TestContext.Progress.WriteLine($" credReader/list: count={credReaderList.Count}"); + + // Cred list + var credList = await client.Cred.List.GetAsync(); + Assert.That(credList, Is.Not.Null); + Assert.That(credList!.InstanceList, Is.Not.Null); + Assert.That(credList.Count, Is.GreaterThanOrEqualTo(0)); + TestContext.Progress.WriteLine($" cred/list: count={credList.Count}"); + + // CredTemplate list + var credTemplateList = await client.CredTemplate.List.GetAsync(); + Assert.That(credTemplateList, Is.Not.Null); + Assert.That(credTemplateList!.InstanceList, Is.Not.Null); + Assert.That(credTemplateList.Count, Is.GreaterThanOrEqualTo(0)); + TestContext.Progress.WriteLine($" credTemplate/list: count={credTemplateList.Count}"); + + // Sched list + var schedList = await client.Sched.List.GetAsync(); + Assert.That(schedList, Is.Not.Null); + Assert.That(schedList!.InstanceList, Is.Not.Null); + Assert.That(schedList.Count, Is.GreaterThanOrEqualTo(0)); + var initialSchedCount = schedList.Count; + TestContext.Progress.WriteLine($" sched/list: count={schedList.Count}"); + + // DoorAccessPriv list + var privList = await client.DoorAccessPriv.List.GetAsync(); + Assert.That(privList, Is.Not.Null); + Assert.That(privList!.InstanceList, Is.Not.Null); + Assert.That(privList.Count, Is.GreaterThanOrEqualTo(0)); + TestContext.Progress.WriteLine($" doorAccessPriv/list: count={privList.Count}"); + + // Evt list + var evtList = await client.Evt.List.GetAsync(); + Assert.That(evtList, Is.Not.Null); + Assert.That(evtList!.InstanceList, Is.Not.Null); + Assert.That(evtList.Count, Is.GreaterThanOrEqualTo(0)); + TestContext.Progress.WriteLine($" evt/list: count={evtList.Count}"); + + // Hol list + var holList = await client.Hol.List.GetAsync(); + Assert.That(holList, Is.Not.Null); + Assert.That(holList!.InstanceList, Is.Not.Null); + Assert.That(holList.Count, Is.GreaterThanOrEqualTo(0)); + TestContext.Progress.WriteLine($" hol/list: count={holList.Count}"); + + // HolCal list + var holCalList = await client.HolCal.List.GetAsync(); + Assert.That(holCalList, Is.Not.Null); + Assert.That(holCalList!.InstanceList, Is.Not.Null); + Assert.That(holCalList.Count, Is.GreaterThanOrEqualTo(0)); + TestContext.Progress.WriteLine($" holCal/list: count={holCalList.Count}"); + + // HolType list + var holTypeList = await client.HolType.List.GetAsync(); + Assert.That(holTypeList, Is.Not.Null); + Assert.That(holTypeList!.InstanceList, Is.Not.Null); + Assert.That(holTypeList.Count, Is.GreaterThanOrEqualTo(0)); + var initialHolTypeCount = holTypeList.Count; + TestContext.Progress.WriteLine($" holType/list: count={holTypeList.Count}"); + + // ================================================================ + // Test 5: CRUD — schedule + // ================================================================ + TestContext.Progress.WriteLine("Test 5: CRUD schedule..."); + + // Save + var newSched = new Z9Flex.Client.Models.Sched + { + Unid = 999, + Name = "Flex Test Schedule", + Tag = "flex-test", + Elements = new List + { + new() + { + SchedDays = new List { 1, 2, 3, 4, 5 }, + Start = new Time(8, 0, 0), + Stop = new Time(17, 0, 0) + } + } + }; + var saveResult = await client.Sched.Save.PostAsSavePostResponseAsync(newSched); + Assert.That(saveResult, Is.Not.Null); + Assert.That(saveResult!.Instance, Is.Not.Null); + Assert.That(saveResult.Instance!.Name, Is.EqualTo("Flex Test Schedule")); + TestContext.Progress.WriteLine($" Saved schedule: unid={saveResult.Instance.Unid}"); + + // Verify count increased + var schedListAfterSave = await client.Sched.List.GetAsync(); + Assert.That(schedListAfterSave!.Count, Is.GreaterThan(initialSchedCount!.Value)); + + // Update + var updatedSched = new Z9Flex.Client.Models.Sched + { + Unid = 999, + Name = "Updated Flex Schedule", + Tag = "flex-test-updated", + Elements = new List + { + new() + { + SchedDays = new List { 1, 2, 3, 4, 5, 6 }, + Start = new Time(7, 0, 0), + Stop = new Time(19, 0, 0) + } + } + }; + var updateResult = await client.Sched.Update["999"].PostAsUpdatePostResponseAsync(updatedSched); + Assert.That(updateResult, Is.Not.Null); + Assert.That(updateResult!.Instance, Is.Not.Null); + Assert.That(updateResult.Instance!.Name, Is.EqualTo("Updated Flex Schedule")); + TestContext.Progress.WriteLine(" Updated schedule name"); + + // Delete + await client.Sched.DeletePath["999"].PostAsync(); + TestContext.Progress.WriteLine(" Deleted schedule"); + + // Verify count restored + var schedListAfterDelete = await client.Sched.List.GetAsync(); + Assert.That(schedListAfterDelete!.Count, Is.EqualTo(initialSchedCount)); + TestContext.Progress.WriteLine(" Schedule count restored after delete"); + + // ================================================================ + // Test 6: CRUD — holiday type + // ================================================================ + TestContext.Progress.WriteLine("Test 6: CRUD holiday type..."); + + // Save + var newHolType = new Z9Flex.Client.Models.HolType + { + Unid = 500, + Name = "Flex Test Holiday", + Tag = "flex-hol" + }; + var htSaveResult = await client.HolType.Save.PostAsSavePostResponseAsync(newHolType); + Assert.That(htSaveResult, Is.Not.Null); + Assert.That(htSaveResult!.Instance, Is.Not.Null); + Assert.That(htSaveResult.Instance!.Name, Is.EqualTo("Flex Test Holiday")); + TestContext.Progress.WriteLine(" Saved holiday type"); + + // Update + var updatedHolType = new Z9Flex.Client.Models.HolType + { + Unid = 500, + Name = "Updated Holiday", + Tag = "flex-hol-updated" + }; + var htUpdateResult = await client.HolType.Update["500"].PostAsUpdatePostResponseAsync(updatedHolType); + Assert.That(htUpdateResult, Is.Not.Null); + Assert.That(htUpdateResult!.Instance, Is.Not.Null); + Assert.That(htUpdateResult.Instance!.Name, Is.EqualTo("Updated Holiday")); + TestContext.Progress.WriteLine(" Updated holiday type"); + + // Delete + await client.HolType.DeletePath["500"].PostAsync(); + TestContext.Progress.WriteLine(" Deleted holiday type"); + + // Verify count restored + var holTypeListAfterDelete = await client.HolType.List.GetAsync(); + Assert.That(holTypeListAfterDelete!.Count, Is.EqualTo(initialHolTypeCount)); + TestContext.Progress.WriteLine(" Holiday type count restored after delete"); + + // ================================================================ + // Test 7: Pagination + // ================================================================ + TestContext.Progress.WriteLine("Test 7: Pagination..."); + var paginatedCreds = await client.Cred.List.GetAsync(config => + { + config.QueryParameters.Max = 1; + config.QueryParameters.Offset = 0; + }); + Assert.That(paginatedCreds, Is.Not.Null); + Assert.That(paginatedCreds!.Max, Is.EqualTo(1)); + Assert.That(paginatedCreds.Offset, Is.EqualTo(0)); + Assert.That(paginatedCreds.InstanceList!.Count, Is.LessThanOrEqualTo(1)); + TestContext.Progress.WriteLine( + $" Pagination: max={paginatedCreds.Max}, offset={paginatedCreds.Offset}, returned={paginatedCreds.InstanceList.Count}"); + + // ================================================================ + // Test 8: Terminate session + // ================================================================ + TestContext.Progress.WriteLine("Test 8: Terminating session..."); + await client.Terminate.GetAsync(); + TestContext.Progress.WriteLine(" Session terminated"); + + // Verify next call returns 401 + var exAfterTerminate = Assert.ThrowsAsync(async () => + await client.Cred.List.GetAsync()); + Assert.That(exAfterTerminate!.ResponseStatusCode, Is.EqualTo(401)); + TestContext.Progress.WriteLine(" Confirmed 401 after termination"); + + TestContext.Progress.WriteLine("All FlexClient integration tests passed!"); + } +} diff --git a/test/Aporta.Migration.Tests/Aporta.Migration.Tests.csproj b/test/Aporta.Migration.Tests/Aporta.Migration.Tests.csproj new file mode 100644 index 0000000..68edcd5 --- /dev/null +++ b/test/Aporta.Migration.Tests/Aporta.Migration.Tests.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + diff --git a/test/Aporta.Migration.Tests/MigrationTests.cs b/test/Aporta.Migration.Tests/MigrationTests.cs new file mode 100644 index 0000000..79e6ff1 --- /dev/null +++ b/test/Aporta.Migration.Tests/MigrationTests.cs @@ -0,0 +1,328 @@ +using System.Data; +using System.Text.Json; +using Dapper; +using Google.Protobuf; +using Microsoft.Data.Sqlite; +using NUnit.Framework; +using Z9.Spcore.Proto; + +namespace Aporta.Migration.Tests; + +[TestFixture] +public class MigrationTests +{ + private SqliteConnection _connection = null!; + + [SetUp] + public void Setup() + { + // Create in-memory database + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + } + + [TearDown] + public void TearDown() + { + _connection?.Close(); + _connection?.Dispose(); + } + + [Test] + public async Task Migrate_FromVersion10_TransformsAllTables() + { + // Arrange - Create old schema with test data + await CreateOldSchemaVersion10(); + await InsertTestData(); + + // Act - Run migration + var migrator = new Migrator(_connection, dryRun: false); + await migrator.MigrateAsync(); + + // Assert - Verify each table was transformed correctly + await VerifyExtensionTable(); + await VerifyEndpointTable(); + await VerifyOutputTable(); + await VerifyInputTable(); + await VerifyDoorTable(); + await VerifyGlobalSettingTable(); + await VerifyPersonTable(); + await VerifyCredentialTable(); + await VerifyCredentialAssignmentTable(); + await VerifyZ9EvtTable(); + await VerifyEventTableDropped(); + await VerifySchemaInfo(); + } + + [Test] + public async Task Migrate_FromVersion5_AppliesRemainingMigrationsFirst() + { + // Arrange - Create partial old schema (version 5) + await CreateOldSchemaVersion5(); + + // Act - Run migration + var migrator = new Migrator(_connection, dryRun: false); + await migrator.MigrateAsync(); + + // Assert - All tables should exist with JSON schema + var tables = await _connection.QueryAsync( + "SELECT name FROM sqlite_master WHERE type='table' AND name != 'sqlite_sequence'"); + + Assert.That(tables, Does.Contain("person")); + Assert.That(tables, Does.Contain("credential")); + Assert.That(tables, Does.Contain("credential_assignment")); + Assert.That(tables, Does.Contain("z9_evt")); + Assert.That(tables, Does.Not.Contain("event")); + } + + [Test] + public async Task Migrate_DryRun_MakesNoChanges() + { + // Arrange + await CreateOldSchemaVersion10(); + await InsertTestData(); + + // Act - Run migration in dry-run mode + var migrator = new Migrator(_connection, dryRun: true); + await migrator.MigrateAsync(); + + // Assert - Old schema should still exist (person has first_name column, not data) + var columns = await _connection.QueryAsync( + "SELECT name FROM pragma_table_info('person')"); + Assert.That(columns, Does.Contain("first_name")); + Assert.That(columns, Does.Not.Contain("data")); + } + + [Test] + public async Task Migrate_AlreadyJsonSchema_DoesNothing() + { + // Arrange - Create new JSON schema with test data + await CreateNewJsonSchema(); + var testData = "{\"firstName\":\"Test\",\"lastName\":\"User\",\"enabled\":true}"; + await _connection.ExecuteAsync( + "INSERT INTO person (id, data) VALUES (1, @data)", new { data = testData }); + + // Act + var migrator = new Migrator(_connection, dryRun: false); + await migrator.MigrateAsync(); + + // Assert - Data should be unchanged + var result = await _connection.QueryFirstAsync<(int Id, string Data)>( + "SELECT id, data FROM person WHERE id = 1"); + Assert.That(result.Data, Is.EqualTo(testData)); + + // Schema version should still be 0 + var version = await _connection.QueryFirstAsync("SELECT MAX(id) FROM schema_info"); + Assert.That(version, Is.EqualTo(100)); + } + + private async Task CreateOldSchemaVersion10() + { + await _connection.ExecuteAsync(@" + CREATE TABLE schema_info (id INTEGER PRIMARY KEY, name TEXT NOT NULL, timestamp DATETIME NOT NULL); + INSERT INTO schema_info VALUES (0, 'Initial', '2024-01-01'); + INSERT INTO schema_info VALUES (1, 'Extension', '2024-01-01'); + INSERT INTO schema_info VALUES (2, 'Endpoint', '2024-01-01'); + INSERT INTO schema_info VALUES (3, 'Output', '2024-01-01'); + INSERT INTO schema_info VALUES (4, 'Input', '2024-01-01'); + INSERT INTO schema_info VALUES (5, 'Door', '2024-01-01'); + INSERT INTO schema_info VALUES (6, 'GlobalSetting', '2024-01-01'); + INSERT INTO schema_info VALUES (7, 'Credential', '2024-01-01'); + INSERT INTO schema_info VALUES (8, 'Person', '2024-01-01'); + INSERT INTO schema_info VALUES (9, 'Event', '2024-01-01'); + INSERT INTO schema_info VALUES (10, 'LastEvent', '2024-01-01'); + + CREATE TABLE extension (id TEXT PRIMARY KEY, name TEXT NOT NULL, enabled INTEGER NOT NULL, configuration TEXT NOT NULL); + CREATE TABLE endpoint (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, driver_id TEXT NOT NULL, endpoint_type INTEGER NOT NULL, extension_id TEXT NOT NULL); + CREATE TABLE output (id INTEGER PRIMARY KEY AUTOINCREMENT, endpoint_id INTEGER NOT NULL, name TEXT NOT NULL); + CREATE TABLE input (id INTEGER PRIMARY KEY AUTOINCREMENT, endpoint_id INTEGER NOT NULL, name TEXT NOT NULL); + CREATE TABLE door (id INTEGER PRIMARY KEY AUTOINCREMENT, in_access_endpoint_id INTEGER, out_access_endpoint_id INTEGER, door_contact_endpoint_id INTEGER, request_to_exit_endpoint_id INTEGER, door_strike_endpoint_id INTEGER, name TEXT NOT NULL); + CREATE TABLE global_setting (name TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE person (id INTEGER PRIMARY KEY AUTOINCREMENT, first_name TEXT, last_name TEXT, enabled INTEGER NOT NULL); + CREATE TABLE credential (id INTEGER PRIMARY KEY AUTOINCREMENT, number TEXT NOT NULL, last_event INTEGER); + CREATE TABLE credential_assignment (person_id INTEGER NOT NULL, credential_id INTEGER PRIMARY KEY, enabled INTEGER NOT NULL); + CREATE TABLE event (id INTEGER PRIMARY KEY AUTOINCREMENT, endpoint_id INTEGER NOT NULL, timestamp DATETIME NOT NULL, event_type INTEGER NOT NULL, data TEXT NOT NULL); + "); + } + + private async Task CreateOldSchemaVersion5() + { + await _connection.ExecuteAsync(@" + CREATE TABLE schema_info (id INTEGER PRIMARY KEY, name TEXT NOT NULL, timestamp DATETIME NOT NULL); + INSERT INTO schema_info VALUES (0, 'Initial', '2024-01-01'); + INSERT INTO schema_info VALUES (1, 'Extension', '2024-01-01'); + INSERT INTO schema_info VALUES (2, 'Endpoint', '2024-01-01'); + INSERT INTO schema_info VALUES (3, 'Output', '2024-01-01'); + INSERT INTO schema_info VALUES (4, 'Input', '2024-01-01'); + INSERT INTO schema_info VALUES (5, 'Door', '2024-01-01'); + + CREATE TABLE extension (id TEXT PRIMARY KEY, name TEXT NOT NULL, enabled INTEGER NOT NULL, configuration TEXT NOT NULL); + CREATE TABLE endpoint (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, driver_id TEXT NOT NULL, endpoint_type INTEGER NOT NULL, extension_id TEXT NOT NULL); + CREATE TABLE output (id INTEGER PRIMARY KEY AUTOINCREMENT, endpoint_id INTEGER NOT NULL, name TEXT NOT NULL); + CREATE TABLE input (id INTEGER PRIMARY KEY AUTOINCREMENT, endpoint_id INTEGER NOT NULL, name TEXT NOT NULL); + CREATE TABLE door (id INTEGER PRIMARY KEY AUTOINCREMENT, in_access_endpoint_id INTEGER, out_access_endpoint_id INTEGER, door_contact_endpoint_id INTEGER, request_to_exit_endpoint_id INTEGER, door_strike_endpoint_id INTEGER, name TEXT NOT NULL); + "); + } + + private async Task CreateNewJsonSchema() + { + await _connection.ExecuteAsync(@" + CREATE TABLE schema_info (id INTEGER PRIMARY KEY, name TEXT NOT NULL, timestamp DATETIME NOT NULL); + INSERT INTO schema_info VALUES (100, 'Initial create with JSON document storage', '2024-01-01'); + + CREATE TABLE extension (id TEXT PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE endpoint (id INTEGER PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE output (id INTEGER PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE input (id INTEGER PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE door (id INTEGER PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE global_setting (name TEXT PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE person (id INTEGER PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE credential (id INTEGER PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE credential_assignment (id INTEGER PRIMARY KEY, data TEXT NOT NULL); + "); + } + + private async Task InsertTestData() + { + await _connection.ExecuteAsync(@" + INSERT INTO extension VALUES ('550e8400-e29b-41d4-a716-446655440000', 'Virtual Driver', 1, '{""readers"":[]}'); + INSERT INTO endpoint VALUES (1, 'Reader 1', 'R1', 0, '550e8400-e29b-41d4-a716-446655440000'); + INSERT INTO endpoint VALUES (2, 'Output 1', 'O1', 1, '550e8400-e29b-41d4-a716-446655440000'); + INSERT INTO output VALUES (1, 2, 'Door Strike'); + INSERT INTO input VALUES (1, 1, 'Door Contact'); + INSERT INTO door VALUES (1, 1, NULL, NULL, NULL, 2, 'Front Door'); + INSERT INTO global_setting VALUES ('timezone', 'America/New_York'); + INSERT INTO person VALUES (1, 'John', 'Doe', 1); + INSERT INTO person VALUES (2, 'Jane', 'Smith', 0); + INSERT INTO credential VALUES (1, '12345678', NULL); + INSERT INTO credential VALUES (2, '87654321', 5); + INSERT INTO credential_assignment VALUES (1, 1, 1); + INSERT INTO credential_assignment VALUES (2, 2, 0); + INSERT INTO event VALUES (1, 1, '2024-01-15 10:30:00', 1, '{""card"":""12345678""}'); + "); + } + + private async Task VerifyExtensionTable() + { + var result = await _connection.QueryFirstAsync<(string Id, string Data)>( + "SELECT id, data FROM extension WHERE id = @id", + new { id = "550e8400-e29b-41d4-a716-446655440000" }); + + var json = JsonDocument.Parse(result.Data); + Assert.That(json.RootElement.GetProperty("name").GetString(), Is.EqualTo("Virtual Driver")); + Assert.That(json.RootElement.GetProperty("enabled").GetBoolean(), Is.True); + } + + private async Task VerifyEndpointTable() + { + var result = await _connection.QueryFirstAsync<(int Id, string Data)>( + "SELECT id, data FROM endpoint WHERE id = 1"); + + var json = JsonDocument.Parse(result.Data); + Assert.That(json.RootElement.GetProperty("name").GetString(), Is.EqualTo("Reader 1")); + Assert.That(json.RootElement.GetProperty("driverEndpointId").GetString(), Is.EqualTo("R1")); + Assert.That(json.RootElement.GetProperty("type").GetInt32(), Is.EqualTo(0)); + } + + private async Task VerifyOutputTable() + { + var result = await _connection.QueryFirstAsync<(int Id, string Data)>( + "SELECT id, data FROM output WHERE id = 1"); + + var json = JsonDocument.Parse(result.Data); + Assert.That(json.RootElement.GetProperty("name").GetString(), Is.EqualTo("Door Strike")); + Assert.That(json.RootElement.GetProperty("endpointId").GetInt32(), Is.EqualTo(2)); + } + + private async Task VerifyInputTable() + { + var result = await _connection.QueryFirstAsync<(int Id, string Data)>( + "SELECT id, data FROM input WHERE id = 1"); + + var json = JsonDocument.Parse(result.Data); + Assert.That(json.RootElement.GetProperty("name").GetString(), Is.EqualTo("Door Contact")); + Assert.That(json.RootElement.GetProperty("endpointId").GetInt32(), Is.EqualTo(1)); + } + + private async Task VerifyDoorTable() + { + var result = await _connection.QueryFirstAsync<(int Id, string Data)>( + "SELECT id, data FROM door WHERE id = 1"); + + var json = JsonDocument.Parse(result.Data); + Assert.That(json.RootElement.GetProperty("name").GetString(), Is.EqualTo("Front Door")); + Assert.That(json.RootElement.GetProperty("inAccessEndpointId").GetInt32(), Is.EqualTo(1)); + Assert.That(json.RootElement.GetProperty("doorStrikeEndpointId").GetInt32(), Is.EqualTo(2)); + } + + private async Task VerifyGlobalSettingTable() + { + var result = await _connection.QueryFirstAsync<(string Name, string Data)>( + "SELECT name, data FROM global_setting WHERE name = 'timezone'"); + + var json = JsonDocument.Parse(result.Data); + Assert.That(json.RootElement.GetProperty("value").GetString(), Is.EqualTo("America/New_York")); + } + + private async Task VerifyPersonTable() + { + var result = await _connection.QueryFirstAsync<(int Id, string Data)>( + "SELECT id, data FROM person WHERE id = 1"); + + var json = JsonDocument.Parse(result.Data); + Assert.That(json.RootElement.GetProperty("firstName").GetString(), Is.EqualTo("John")); + Assert.That(json.RootElement.GetProperty("lastName").GetString(), Is.EqualTo("Doe")); + Assert.That(json.RootElement.GetProperty("enabled").GetBoolean(), Is.True); + } + + private async Task VerifyCredentialTable() + { + var result = await _connection.QueryFirstAsync<(int Id, string Data)>( + "SELECT id, data FROM credential WHERE id = 2"); + + var json = JsonDocument.Parse(result.Data); + Assert.That(json.RootElement.GetProperty("number").GetString(), Is.EqualTo("87654321")); + Assert.That(json.RootElement.GetProperty("lastEvent").GetInt32(), Is.EqualTo(5)); + } + + private async Task VerifyCredentialAssignmentTable() + { + // Old schema: credential_id was PK, now we have our own id + var results = await _connection.QueryAsync<(int Id, string Data)>( + "SELECT id, data FROM credential_assignment"); + + Assert.That(results.Count(), Is.EqualTo(2)); + + var first = results.First(); + var json = JsonDocument.Parse(first.Data); + Assert.That(json.RootElement.GetProperty("credentialId").GetInt32(), Is.EqualTo(1)); + Assert.That(json.RootElement.GetProperty("personId").GetInt32(), Is.EqualTo(1)); + Assert.That(json.RootElement.GetProperty("enabled").GetInt32(), Is.EqualTo(1)); + } + + private static readonly JsonParser ProtoParser = new(JsonParser.Settings.Default); + + private async Task VerifyZ9EvtTable() + { + var result = await _connection.QueryFirstAsync<(int Id, string Data)>( + "SELECT id, data FROM z9_evt WHERE id = 1"); + + var evt = ProtoParser.Parse(result.Data); + Assert.That(evt.EvtCode, Is.EqualTo(EvtCode.DoorAccessDenied)); // event_type=1 is AccessDenied + Assert.That(evt.Data, Is.EqualTo("{\"card\":\"12345678\"}")); + Assert.That(evt.DbTime.Millis, Is.GreaterThan(0)); + } + + private async Task VerifyEventTableDropped() + { + var tables = await _connection.QueryAsync( + "SELECT name FROM sqlite_master WHERE type='table' AND name='event'"); + Assert.That(tables, Is.Empty); + } + + private async Task VerifySchemaInfo() + { + var version = await _connection.QueryFirstAsync("SELECT MAX(id) FROM schema_info"); + Assert.That(version, Is.EqualTo(100)); // New JSON schema is version 0 + } +} diff --git a/test/Aporta.Selenium.Tests/AccessGrantedTest.cs b/test/Aporta.Selenium.Tests/AccessGrantedTest.cs new file mode 100644 index 0000000..4cce819 --- /dev/null +++ b/test/Aporta.Selenium.Tests/AccessGrantedTest.cs @@ -0,0 +1,1450 @@ +using System.Diagnostics; +using System.Net; +using NUnit.Framework; +using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; +using OpenQA.Selenium.Support.UI; + +namespace Aporta.Selenium.Tests; + +[TestFixture] +public class AccessGrantedTest +{ + private const string BaseUrl = "https://localhost:5001"; + private const int AportaStartupTimeoutSeconds = 60; + private const string VirtualDriverGuid = "6667E442-53B2-4240-A10D-25F5E4400D83"; + + private static readonly string AportaPath = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "git", "Aporta"); + + private static readonly string ScreenshotDir = + Path.Combine(AportaPath, "test", "Aporta.Selenium.Tests", "screenshots"); + + private Process? _aportaProcess; + private ChromeDriver? _driver; + private readonly List<(string FilePath, string Description)> _screenshotEntries = new(); + + private static string FindDotnet() + { + var candidates = new[] + { + "/opt/homebrew/opt/dotnet@8/bin/dotnet", + "/usr/local/share/dotnet/dotnet", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dotnet", "dotnet"), + "dotnet" + }; + return candidates.FirstOrDefault(File.Exists) ?? "dotnet"; + } + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + var dotnetPath = FindDotnet(); + var aportaProjectPath = Path.Combine(AportaPath, "src", "Aporta"); + + Assert.That(Directory.Exists(aportaProjectPath), Is.True, + $"Aporta project not found at {aportaProjectPath}"); + + var psi = new ProcessStartInfo(dotnetPath, + $"run --project \"{aportaProjectPath}\" -- --cleanDatabase true") + { + WorkingDirectory = AportaPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + _aportaProcess = Process.Start(psi); + Assert.That(_aportaProcess, Is.Not.Null, "Failed to start Aporta process"); + + _aportaProcess!.BeginOutputReadLine(); + _aportaProcess.BeginErrorReadLine(); + _aportaProcess.OutputDataReceived += (_, e) => + { + if (e.Data != null) TestContext.Progress.WriteLine($"[APORTA] {e.Data}"); + }; + _aportaProcess.ErrorDataReceived += (_, e) => + { + if (e.Data != null) TestContext.Progress.WriteLine($"[APORTA-ERR] {e.Data}"); + }; + + using var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + using var pollClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(5) }; + var deadline = DateTime.UtcNow.AddSeconds(AportaStartupTimeoutSeconds); + var ready = false; + while (DateTime.UtcNow < deadline) + { + try + { + var response = await pollClient.GetAsync($"{BaseUrl}/door/list"); + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.OK) + { + ready = true; + break; + } + } + catch + { + // Not ready yet + } + + await Task.Delay(1000); + } + + Assert.That(ready, Is.True, "Aporta did not become ready within the timeout"); + + var options = new ChromeOptions(); + options.AddArgument("--headless=new"); + options.AddArgument("--window-size=1920,1080"); + options.AddArgument("--no-sandbox"); + options.AddArgument("--disable-dev-shm-usage"); + options.AcceptInsecureCertificates = true; + + _driver = new ChromeDriver(options); + _driver.Manage().Timeouts().PageLoad = TimeSpan.FromSeconds(30); + + // Warm up Blazor WASM — first page load downloads and compiles the runtime + _driver.Navigate().GoToUrl(BaseUrl); + var warmupWait = new WebDriverWait(_driver, TimeSpan.FromSeconds(60)); + warmupWait.Until(d => + { + try + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Aporta Login"); + } + catch (NoSuchElementException) + { + return false; + } + }); + + TakeScreenshot("00_login_screen", "Login screen presented before authentication"); + + // Log in through the UI + var inputs = _driver.FindElements(By.CssSelector("input")); + inputs[0].SendKeys("admin"); + inputs[1].SendKeys("pass"); + ClickButtonContaining("Login"); + + warmupWait.Until(d => + { + try + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Welcome to Aporta"); + } + catch (NoSuchElementException) + { + return false; + } + }); + + TakeScreenshot("00_logged_in", "Home page loads after successful authentication"); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + WriteScreenshotMarkdown(); + + _driver?.Quit(); + _driver?.Dispose(); + + if (_aportaProcess is { HasExited: false }) + { + _aportaProcess.Kill(entireProcessTree: true); + _aportaProcess.WaitForExit(5000); + } + + _aportaProcess?.Dispose(); + } + + [Test, Order(1)] + public void EnableVirtualDriver() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/drivers"); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(30)); + + // Wait for the drivers page to render with the Virtual driver visible + WaitForBodyText("Virtual"); + + // Find the Virtual driver row by finding the with "Virtual" text + // then navigating to its parent + var virtualRow = FindTableRowContaining("Virtual"); + var actionToggle = virtualRow.FindElement(By.CssSelector("button.dropdown-toggle")); + actionToggle.Click(); + + // Wait for dropdown and click Enable — Blazorise renders DropdownItem as tags + WaitForVisibleDropdown(); + ClickDropdownItem("Enable"); + + // Wait for the driver to be loaded — indicated by green color in the status column + var longWait = new WebDriverWait(_driver, TimeSpan.FromSeconds(60)); + longWait.Until(d => + { + try + { + var row = FindTableRowContaining("Virtual"); + var greenElements = row.FindElements(By.CssSelector("[style*='green']")); + return greenElements.Count > 0; + } + catch + { + return false; + } + }); + + TakeScreenshot("01_virtual_driver_enabled", "Enable the Virtual driver — green check confirms it is loaded and running"); + TestContext.Progress.WriteLine("Virtual driver enabled successfully"); + } + + [Test, Order(2)] + public void AddVirtualReaderAndOutput() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/driver/{VirtualDriverGuid}"); + + // Wait for Virtual Driver Configuration page to load + WaitForBodyText("Add Virtual Reader"); + + // --- Add Virtual Reader --- + ClickButtonContaining("Add Virtual Reader"); + WaitForModal(); + + var nameInput = _driver.FindElement(By.Id("NameTextEdit")); + nameInput.Clear(); + nameInput.SendKeys("Test Reader"); + nameInput.SendKeys(Keys.Tab); + + ClickModalButton("Add"); + WaitForModalClose(); + WaitForBodyText("Test Reader"); + + // --- Add Virtual Output --- + ClickButtonContaining("Add Virtual Output"); + WaitForModal(); + + nameInput = _driver.FindElement(By.Id("NameTextEdit")); + nameInput.Clear(); + nameInput.SendKeys("Test Output"); + nameInput.SendKeys(Keys.Tab); + + ClickModalButton("Add"); + WaitForModalClose(); + WaitForBodyText("Test Output"); + + TakeScreenshot("02_reader_and_output_added", "Add a Virtual Reader and Virtual Output to the driver configuration"); + TestContext.Progress.WriteLine("Virtual reader and output added successfully"); + } + + [Test, Order(3)] + public void AddDoor() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/doors"); + + // Wait for the Add Door button to appear + WaitForBodyText("Add Door"); + + ClickButtonContaining("Add Door"); + WaitForModal(); + + var modal = _driver.FindElement(By.CssSelector("div.modal.show")); + var nameInput = modal.FindElement(By.CssSelector("input")); + nameInput.Clear(); + nameInput.SendKeys("Test Door"); + nameInput.SendKeys(Keys.Tab); + + // Select "Test Reader" for Access Reader In and "Test Actuator" for Door Strike + var selects = modal.FindElements(By.CssSelector("select")); + Assert.That(selects.Count, Is.GreaterThanOrEqualTo(3), "Expected at least 3 select elements in Add Door modal"); + + var accessReaderInSelect = new SelectElement(selects[0]); + SelectOptionContainingText(accessReaderInSelect, "Test Reader"); + + var doorStrikeSelect = new SelectElement(selects[2]); + SelectOptionContainingText(doorStrikeSelect, "Test Output"); + + ClickModalButton("Add"); + WaitForModalClose(); + WaitForBodyText("Test Door"); + + TakeScreenshot("04_door_added", "Create a door with the Test Reader as access reader and Test Output as door strike relay"); + TestContext.Progress.WriteLine("Door added successfully"); + } + + [Test, Order(4)] + public void AddPerson() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/credentials"); + + WaitForBodyText("Add Credential"); + + ClickButtonContaining("Add Credential"); + WaitForModal(); + + var modal = _driver.FindElement(By.CssSelector("div.modal.show")); + var inputs = modal.FindElements(By.CssSelector("input[type='text'],input:not([type])")); + Assert.That(inputs.Count, Is.GreaterThanOrEqualTo(2), "Expected at least 2 text inputs in Add Credential modal"); + + inputs[0].Clear(); + inputs[0].SendKeys("Test"); + inputs[0].SendKeys(Keys.Tab); + + inputs[1].Clear(); + inputs[1].SendKeys("Person"); + inputs[1].SendKeys(Keys.Tab); + + ClickModalButton("Add"); + WaitForModalClose(); + + // Wait for the person to appear + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(30)); + wait.Until(d => + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Test") && body.Text.Contains("Person"); + }); + + TakeScreenshot("05_person_added", "Add a person (Test Person) on the credentials page"); + TestContext.Progress.WriteLine("Person added successfully"); + } + + [Test, Order(5)] + public void SwipeBadgeFirstTime() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/driver/{VirtualDriverGuid}"); + + // Wait for the reader row to appear (uses ElementId set in Configuration.razor) + WaitForBodyText("Test Reader"); + var readerRow = _driver.FindElement(By.Id("Reader:Test Reader")); + + var actionToggle = readerRow.FindElement(By.CssSelector("button.dropdown-toggle")); + actionToggle.Click(); + + WaitForVisibleDropdown(); + ClickDropdownItem("Swipe Badge"); + + WaitForModal(); + + var badgeInput = _driver.FindElement(By.Id("SwipeBadgeTextEdit")); + badgeInput.Clear(); + badgeInput.SendKeys("12345"); + badgeInput.SendKeys(Keys.Tab); + + ClickModalButton("Swipe the badge"); + WaitForModalClose(); + + // Pause for server to process the badge swipe and create the raw read event + Thread.Sleep(2000); + + TakeScreenshot("06_badge_swiped", "Swipe badge 12345 on the Test Reader — creates a raw read event for enrollment"); + TestContext.Progress.WriteLine("Badge swiped successfully (first time - creates raw read event)"); + } + + [Test, Order(6)] + public void EnrollPersonWithBadge() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/credentials"); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(30)); + + // Wait for the person row to appear + wait.Until(d => + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Test") && body.Text.Contains("Person"); + }); + + // Find the person row and click Action dropdown + var personRow = FindTableRowContaining("Person"); + var actionToggle = personRow.FindElement(By.CssSelector("button.dropdown-toggle")); + actionToggle.Click(); + + WaitForVisibleDropdown(); + ClickDropdownItem("Enroll"); + + WaitForModal(); + + // Verify the badge is available in the enrollment dropdown (raw reads show "Badge 12345 (Test Reader)") + var modal = _driver.FindElement(By.CssSelector("div.modal.show")); + var selectElement = modal.FindElement(By.CssSelector("select")); + Assert.That(selectElement.Text, Does.Contain("12345"), "Badge 12345 should be available in enrollment dropdown"); + Assert.That(selectElement.Text, Does.Contain("Test Reader"), "Reader name should appear in enrollment dropdown"); + + ClickModalButton("Enroll"); + WaitForModalClose(); + + // Wait for the "Successfully enrolled person" snackbar — this confirms the enrollment + // API call completed (the modal closes before the API call, so modal close alone + // is not sufficient). + wait.Until(d => + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Successfully enrolled credential"); + }); + + // Reload to verify enrollment persisted (person row should show badge number) + _driver.Navigate().GoToUrl($"{BaseUrl}/configuration/credentials"); + wait.Until(d => + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Test") && body.Text.Contains("Person"); + }); + + // Verify the person row specifically has the badge number + var enrolledRow = FindTableRowContaining("Person"); + Assert.That(enrolledRow.Text, Does.Contain("12345"), + "Person row should contain badge number 12345 after enrollment"); + + // Verify Door Access column shows the door with Always schedule + Assert.That(enrolledRow.Text, Does.Contain("Test Door (Always)"), + "Person row should show 'Test Door (Always)' in the Door Access column after enrollment"); + + TakeScreenshot("07_person_enrolled", "Enroll Test Person with badge 12345 — badge number and door access now appear in their row"); + TestContext.Progress.WriteLine("Person enrolled with badge 12345 successfully"); + } + + [Test, Order(7)] + public void SwipeBadgeSecondTime() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/driver/{VirtualDriverGuid}"); + + WaitForBodyText("Test Reader"); + var readerRow = _driver.FindElement(By.Id("Reader:Test Reader")); + + var actionToggle = readerRow.FindElement(By.CssSelector("button.dropdown-toggle")); + actionToggle.Click(); + + WaitForVisibleDropdown(); + ClickDropdownItem("Swipe Badge"); + + WaitForModal(); + + var badgeInput = _driver.FindElement(By.Id("SwipeBadgeTextEdit")); + badgeInput.Clear(); + badgeInput.SendKeys("12345"); + badgeInput.SendKeys(Keys.Tab); + + ClickModalButton("Swipe the badge"); + WaitForModalClose(); + + // Wait for server to process + Thread.Sleep(2000); + + TakeScreenshot("08_badge_swiped_again", "Swipe badge 12345 again — this time the credential is enrolled, triggering access decision"); + TestContext.Progress.WriteLine("Badge swiped second time (should trigger Access Granted)"); + } + + [Test, Order(8)] + public void VerifyAccessGrantedOnMonitoringPage() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/monitoring"); + + // Wait for the events table and Access Granted event to appear + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(30)); + wait.Until(d => + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Access Granted"); + }); + + var body = _driver.FindElement(By.TagName("body")); + Assert.That(body.Text, Does.Contain("Access Granted"), + "Expected 'Access Granted' event on the monitoring page"); + + // Verify the Access Granted row shows Device and Credential info + var accessGrantedRow = FindTableRowContaining("Access Granted"); + Assert.That(accessGrantedRow.Text, Does.Contain("Test Reader"), + "Access Granted event should show device name 'Test Reader'"); + Assert.That(accessGrantedRow.Text, Does.Contain("Person, Test"), + "Access Granted event should show credential name 'Person, Test'"); + + TakeScreenshot("09_access_granted_verified", "Monitoring page shows Access Granted event with device and credential info — end-to-end flow complete"); + TestContext.Progress.WriteLine("Access Granted event verified on monitoring page!"); + } + + [Test, Order(9)] + public void AddSchedule() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/schedules"); + + WaitForBodyText("Add Schedule"); + + TakeScreenshot("10_schedules_page_empty", "Schedules page before any schedules have been created"); + + ClickButtonContaining("Add Schedule"); + WaitForModal(); + + // Enter schedule name + var modal = _driver.FindElement(By.CssSelector("div.modal.show")); + var nameInput = modal.FindElement(By.CssSelector("input")); + nameInput.Clear(); + nameInput.SendKeys("Business Hours"); + nameInput.SendKeys(Keys.Tab); + + // Add a time interval + var addIntervalButton = modal.FindElements(By.TagName("button")) + .First(b => b.Displayed && b.Text.Contains("Add Time Interval")); + addIntervalButton.Click(); + + // Wait for the interval card to appear + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(d => + { + var m = d.FindElement(By.CssSelector("div.modal.show")); + return m.FindElements(By.CssSelector(".card .card-body")).Count > 0; + }); + + // Find the interval card within the modal + var intervalCard = modal.FindElement(By.CssSelector(".card .card-body")); + + // Check Mon through Fri — Blazorise renders Check as hidden input + visible label, + // so click the labels (custom-control-label) instead of the inputs + var checkboxLabels = intervalCard.FindElements(By.CssSelector("label.custom-control-label")); + Assert.That(checkboxLabels.Count, Is.GreaterThanOrEqualTo(7), "Expected at least 7 day-of-week checkbox labels"); + for (var i = 0; i < 5; i++) + { + checkboxLabels[i].Click(); + } + + // Enter start and stop times + var timeInputs = intervalCard.FindElements(By.CssSelector("input[type='text']")); + Assert.That(timeInputs.Count, Is.GreaterThanOrEqualTo(2), "Expected at least 2 time inputs"); + timeInputs[0].Clear(); + timeInputs[0].SendKeys("08:00"); + timeInputs[1].Clear(); + timeInputs[1].SendKeys("17:00"); + + TakeScreenshot("11_schedule_modal_filled", "Add Schedule modal with Business Hours — Mon through Fri, 08:00 to 17:00"); + + ClickModalButton("Save"); + WaitForModalClose(); + + // Wait for the schedule to appear in the table + WaitForBodyText("Business Hours"); + + // Verify the summary shows the expected time interval + var scheduleRow = FindTableRowContaining("Business Hours"); + Assert.That(scheduleRow.Text, Does.Contain("Mon"), + "Schedule row should show day abbreviations"); + Assert.That(scheduleRow.Text, Does.Contain("08:00"), + "Schedule row should show start time"); + Assert.That(scheduleRow.Text, Does.Contain("17:00"), + "Schedule row should show stop time"); + + TakeScreenshot("12_schedule_created", "Business Hours schedule created — Mon-Fri 08:00-17:00 visible in the schedules table"); + TestContext.Progress.WriteLine("Schedule 'Business Hours' added successfully"); + } + + [Test, Order(10)] + public void CreateNotTodaySchedule() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/schedules"); + + WaitForBodyText("Add Schedule"); + + ClickButtonContaining("Add Schedule"); + WaitForModal(); + + var modal = _driver.FindElement(By.CssSelector("div.modal.show")); + var nameInput = modal.FindElement(By.CssSelector("input")); + nameInput.Clear(); + nameInput.SendKeys("Not Today"); + nameInput.SendKeys(Keys.Tab); + + // Add a time interval + var addIntervalButton = modal.FindElements(By.TagName("button")) + .First(b => b.Displayed && b.Text.Contains("Add Time Interval")); + addIntervalButton.Click(); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(d => + { + var m = d.FindElement(By.CssSelector("div.modal.show")); + return m.FindElements(By.CssSelector(".card .card-body")).Count > 0; + }); + + var intervalCard = modal.FindElement(By.CssSelector(".card .card-body")); + + // Check only a day that is NOT today + // Checkbox indices: Mon=0, Tue=1, Wed=2, Thu=3, Fri=4, Sat=5, Sun=6 + var today = DateTime.Now.DayOfWeek; + var todayIndex = today == DayOfWeek.Sunday ? 6 : (int)today - 1; + var notTodayIndex = (todayIndex + 1) % 7; + + var checkboxLabels = intervalCard.FindElements(By.CssSelector("label.custom-control-label")); + Assert.That(checkboxLabels.Count, Is.GreaterThanOrEqualTo(7), "Expected at least 7 day-of-week checkbox labels"); + checkboxLabels[notTodayIndex].Click(); + + // Enter all-day time range + var timeInputs = intervalCard.FindElements(By.CssSelector("input[type='text']")); + Assert.That(timeInputs.Count, Is.GreaterThanOrEqualTo(2), "Expected at least 2 time inputs"); + timeInputs[0].Clear(); + timeInputs[0].SendKeys("00:00"); + timeInputs[1].Clear(); + timeInputs[1].SendKeys("23:59"); + + TakeScreenshot("13_not_today_schedule_modal", "Add 'Not Today' schedule — active only on a day that is not today"); + + ClickModalButton("Save"); + WaitForModalClose(); + + WaitForBodyText("Not Today"); + + TakeScreenshot("14_not_today_schedule_created", "'Not Today' schedule created — will cause Access Denied when assigned"); + TestContext.Progress.WriteLine("Schedule 'Not Today' added successfully"); + } + + [Test, Order(11)] + public void EditCredentialAccessToNotToday() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/credentials"); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(30)); + wait.Until(d => + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Test") && body.Text.Contains("Person"); + }); + + // Find the person row and click Action dropdown + var personRow = FindTableRowContaining("Person"); + var actionToggle = personRow.FindElement(By.CssSelector("button.dropdown-toggle")); + actionToggle.Click(); + + WaitForVisibleDropdown(); + ClickDropdownItem("Edit Access"); + + WaitForModal(); + + // The modal should show the existing "Test Door (Always)" binding + var modal = _driver.FindElement(By.CssSelector("div.modal.show")); + var selects = modal.FindElements(By.CssSelector("select")); + Assert.That(selects.Count, Is.GreaterThanOrEqualTo(2), + "Expected at least 2 selects (door + schedule) in Edit Access modal"); + + // Change the schedule dropdown (second select) from "Always" to "Not Today" + var schedSelect = new SelectElement(selects[1]); + SelectOptionContainingText(schedSelect, "Not Today"); + + TakeScreenshot("15_edit_access_not_today", "Edit Access modal — changing schedule from Always to Not Today"); + + ClickModalButton("Save"); + WaitForModalClose(); + + // Wait for the snackbar confirmation + wait.Until(d => + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Successfully updated door access"); + }); + + // Reload and verify the Door Access column shows "Not Today" schedule + _driver.Navigate().GoToUrl($"{BaseUrl}/configuration/credentials"); + wait.Until(d => + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Test") && body.Text.Contains("Person"); + }); + + // Wait for door/schedule lookups to resolve and render in the Door Access column + wait.Until(d => + { + var row = FindTableRowContaining("Person"); + return row.Text.Contains("Test Door"); + }); + + var updatedRow = FindTableRowContaining("Person"); + Assert.That(updatedRow.Text, Does.Contain("Test Door (Not Today)"), + "Person row should show 'Test Door (Not Today)' after editing access"); + + TakeScreenshot("16_credential_access_updated", "Credentials page — door access now shows 'Test Door (Not Today)'"); + TestContext.Progress.WriteLine("Credential access updated to 'Not Today' schedule"); + } + + [Test, Order(12)] + public void SwipeBadgeThirdTime() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/driver/{VirtualDriverGuid}"); + + WaitForBodyText("Test Reader"); + var readerRow = _driver.FindElement(By.Id("Reader:Test Reader")); + + var actionToggle = readerRow.FindElement(By.CssSelector("button.dropdown-toggle")); + actionToggle.Click(); + + WaitForVisibleDropdown(); + ClickDropdownItem("Swipe Badge"); + + WaitForModal(); + + var badgeInput = _driver.FindElement(By.Id("SwipeBadgeTextEdit")); + badgeInput.Clear(); + badgeInput.SendKeys("12345"); + badgeInput.SendKeys(Keys.Tab); + + ClickModalButton("Swipe the badge"); + WaitForModalClose(); + + // Wait for server to process + Thread.Sleep(2000); + + TakeScreenshot("17_badge_swiped_third", "Swipe badge 12345 again — schedule is not active today, should trigger Access Denied"); + TestContext.Progress.WriteLine("Badge swiped third time (should trigger Access Denied)"); + } + + [Test, Order(13)] + public void VerifyAccessDeniedOnMonitoringPage() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/monitoring"); + + // Wait for the Access Denied event from the third swipe (schedule restriction). + // The monitoring page shows newest events first. The first Access Denied row should + // be from swipe 3 — it will include the credential name "Person, Test" because the + // credential was recognized. (The earlier Access Denied from swipe 1 would NOT have + // a credential name since it was an unknown badge.) + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(30)); + wait.Until(d => + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Access Denied"); + }); + + var accessDeniedRow = FindTableRowContaining("Access Denied"); + Assert.That(accessDeniedRow.Text, Does.Contain("Test Reader"), + "Access Denied event should show device name 'Test Reader'"); + Assert.That(accessDeniedRow.Text, Does.Contain("Person, Test"), + "Access Denied event should show credential name 'Person, Test' (proves it's from the schedule-restricted swipe, not the unknown-badge swipe)"); + + TakeScreenshot("18_access_denied_verified", "Monitoring page shows Access Denied — credential's schedule is not active today"); + TestContext.Progress.WriteLine("Access Denied event verified on monitoring page!"); + } + + [Test, Order(14)] + public void AddCardFormat() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/cardformats"); + + WaitForBodyText("Add Card Format"); + + TakeScreenshot("20_card_formats_page_empty", "Card Formats page before any card formats have been created"); + + ClickButtonContaining("Add Card Format"); + WaitForModal(); + + var modal = _driver.FindElement(By.CssSelector("div.modal.show")); + + // Enter card format name + var nameInput = modal.FindElement(By.CssSelector("input")); + nameInput.Clear(); + nameInput.SendKeys("Standard 26-Bit"); + nameInput.SendKeys(Keys.Tab); + + // Set Min Bits and Max Bits (numeric inputs) + var numericInputs = modal.FindElements(By.CssSelector("input[type='number']")); + Assert.That(numericInputs.Count, Is.GreaterThanOrEqualTo(2), "Expected at least 2 numeric inputs for Min/Max bits"); + + // Min Bits + numericInputs[0].Clear(); + numericInputs[0].SendKeys("26"); + numericInputs[0].SendKeys(Keys.Tab); + + // Max Bits + numericInputs[1].Clear(); + numericInputs[1].SendKeys("26"); + numericInputs[1].SendKeys(Keys.Tab); + + // Add Element 1: Even Parity (type=1) + ClickModalBodyButton("Add Element"); + WaitForElementCards(1); + + var elementCards = GetElementCards(); + SetElementType(elementCards[0], "Parity"); + SetElementNumericFields(elementCards[0], start: 0, length: 1); + // Even parity: leave Odd unchecked, set src start/length + SetParityFields(elementCards[0], srcStart: 1, srcLength: 12); + + // Add Element 2: Facility Code (type=2) + ClickModalBodyButton("Add Element"); + WaitForElementCards(2); + + elementCards = GetElementCards(); + SetElementType(elementCards[1], "Field"); + SetElementNumericFields(elementCards[1], start: 1, length: 8); + SetFieldType(elementCards[1], "Facility Code"); + + // Add Element 3: Card Number (type=2) + ClickModalBodyButton("Add Element"); + WaitForElementCards(3); + + elementCards = GetElementCards(); + SetElementType(elementCards[2], "Field"); + SetElementNumericFields(elementCards[2], start: 9, length: 16); + // Card Number is default (value 0), no change needed + + // Add Element 4: Odd Parity (type=1) + ClickModalBodyButton("Add Element"); + WaitForElementCards(4); + + elementCards = GetElementCards(); + SetElementType(elementCards[3], "Parity"); + SetElementNumericFields(elementCards[3], start: 25, length: 1); + SetParityOdd(elementCards[3], true); + SetParityFields(elementCards[3], srcStart: 13, srcLength: 12); + + TakeScreenshot("21_card_format_modal_filled", "Add Card Format modal with Standard 26-Bit — 4 elements: Even Parity, Facility Code, Card Number, Odd Parity"); + + ClickModalButton("Save"); + WaitForModalClose(); + + // Wait for the card format to appear in the table + WaitForBodyText("Standard 26-Bit"); + + var formatRow = FindTableRowContaining("Standard 26-Bit"); + Assert.That(formatRow.Text, Does.Contain("26"), + "Card format row should show bit count"); + Assert.That(formatRow.Text, Does.Contain("4"), + "Card format row should show 4 elements"); + + TakeScreenshot("22_card_format_created", "Standard 26-Bit card format created — 26 bits, 4 elements visible in the table"); + TestContext.Progress.WriteLine("Card format 'Standard 26-Bit' added successfully"); + } + + [Test, Order(15)] + public void AddHoliday() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/holidays"); + + WaitForBodyText("Add Holiday"); + + TakeScreenshot("30_holidays_page_empty", "Holidays page before any holidays have been created"); + + ClickButtonContaining("Add Holiday"); + WaitForModal(); + + var modal = _driver.FindElement(By.CssSelector("div.modal.show")); + + // Enter holiday name + var nameInput = modal.FindElement(By.CssSelector("input[type='text']")); + nameInput.Clear(); + nameInput.SendKeys("Christmas"); + nameInput.SendKeys(Keys.Tab); + + // Enter date — DateEdit renders as input[type='date'] + // Use JavaScript to set the value directly; SendKeys on date inputs is unreliable across browsers + var dateInput = modal.FindElement(By.CssSelector("input[type='date']")); + var christmasDate = $"{DateTime.Now.Year}-12-25"; + ((IJavaScriptExecutor)_driver).ExecuteScript( + "var el = arguments[0]; var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; nativeSetter.call(el, arguments[1]); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));", + dateInput, christmasDate); + dateInput.SendKeys(Keys.Tab); + + // Check "Repeats Annually" — first checkbox label in the modal + var checkboxLabels = modal.FindElements(By.CssSelector("label.custom-control-label")); + Assert.That(checkboxLabels.Count, Is.GreaterThanOrEqualTo(1), "Expected at least 1 checkbox label"); + checkboxLabels[0].Click(); + + TakeScreenshot("31_holiday_modal_filled", "Add Holiday modal with Christmas — Dec 25, Repeats Annually"); + + ClickModalButton("Save"); + WaitForModalClose(); + + // Wait for the holiday to appear in the table + WaitForBodyText("Christmas"); + + var holidayRow = FindTableRowContaining("Christmas"); + Assert.That(holidayRow.Text, Does.Contain("Dec 25"), + "Holiday row should show formatted date"); + Assert.That(holidayRow.Text, Does.Contain("Annually"), + "Holiday row should show 'Annually' for repeating holiday"); + + TakeScreenshot("32_holiday_created", "Christmas holiday created — Dec 25, Annually visible in the holidays table"); + TestContext.Progress.WriteLine("Holiday 'Christmas' added successfully"); + } + + [Test, Order(16)] + public void CreateTodayScheduleAndReassign() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + // --- Create "Today All Day" schedule --- + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/schedules"); + WaitForBodyText("Add Schedule"); + + ClickButtonContaining("Add Schedule"); + WaitForModal(); + + var modal = _driver.FindElement(By.CssSelector("div.modal.show")); + var nameInput = modal.FindElement(By.CssSelector("input")); + nameInput.Clear(); + nameInput.SendKeys("Today All Day"); + nameInput.SendKeys(Keys.Tab); + + // Add a time interval + var addIntervalButton = modal.FindElements(By.TagName("button")) + .First(b => b.Displayed && b.Text.Contains("Add Time Interval")); + addIntervalButton.Click(); + + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(d => + { + var m = d.FindElement(By.CssSelector("div.modal.show")); + return m.FindElements(By.CssSelector(".card .card-body")).Count > 0; + }); + + var intervalCard = modal.FindElement(By.CssSelector(".card .card-body")); + + // Check only today's day-of-week + var today = DateTime.Now.DayOfWeek; + var todayIndex = today == DayOfWeek.Sunday ? 6 : (int)today - 1; + + var checkboxLabels = intervalCard.FindElements(By.CssSelector("label.custom-control-label")); + Assert.That(checkboxLabels.Count, Is.GreaterThanOrEqualTo(7), "Expected at least 7 checkbox labels (days + holidays)"); + checkboxLabels[todayIndex].Click(); + + // Enter all-day time range + var timeInputs = intervalCard.FindElements(By.CssSelector("input[type='text']")); + Assert.That(timeInputs.Count, Is.GreaterThanOrEqualTo(2), "Expected at least 2 time inputs"); + timeInputs[0].Clear(); + timeInputs[0].SendKeys("00:00"); + timeInputs[1].Clear(); + timeInputs[1].SendKeys("23:59"); + + // Holidays checkbox is NOT checked (default) + + TakeScreenshot("40_today_schedule_modal", "Add 'Today All Day' schedule — active today, holidays unchecked"); + + ClickModalButton("Save"); + WaitForModalClose(); + + WaitForBodyText("Today All Day"); + + TakeScreenshot("41_today_schedule_created", "'Today All Day' schedule created"); + + // --- Reassign credential from "Not Today" to "Today All Day" --- + _driver.Navigate().GoToUrl($"{BaseUrl}/configuration/credentials"); + + wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(30)); + wait.Until(d => + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Test") && body.Text.Contains("Person"); + }); + + var personRow = FindTableRowContaining("Person"); + var actionToggle = personRow.FindElement(By.CssSelector("button.dropdown-toggle")); + actionToggle.Click(); + + WaitForVisibleDropdown(); + ClickDropdownItem("Edit Access"); + + WaitForModal(); + + modal = _driver.FindElement(By.CssSelector("div.modal.show")); + var selects = modal.FindElements(By.CssSelector("select")); + Assert.That(selects.Count, Is.GreaterThanOrEqualTo(2), + "Expected at least 2 selects (door + schedule) in Edit Access modal"); + + var schedSelect = new SelectElement(selects[1]); + SelectOptionContainingText(schedSelect, "Today All Day"); + + TakeScreenshot("42_edit_access_today", "Edit Access modal — changing schedule to 'Today All Day'"); + + ClickModalButton("Save"); + WaitForModalClose(); + + wait.Until(d => + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Successfully updated door access"); + }); + + // --- Swipe badge and verify Access Granted --- + _driver.Navigate().GoToUrl($"{BaseUrl}/configuration/driver/{VirtualDriverGuid}"); + + WaitForBodyText("Test Reader"); + var readerRow = _driver.FindElement(By.Id("Reader:Test Reader")); + + actionToggle = readerRow.FindElement(By.CssSelector("button.dropdown-toggle")); + actionToggle.Click(); + + WaitForVisibleDropdown(); + ClickDropdownItem("Swipe Badge"); + + WaitForModal(); + + var badgeInput = _driver.FindElement(By.Id("SwipeBadgeTextEdit")); + badgeInput.Clear(); + badgeInput.SendKeys("12345"); + badgeInput.SendKeys(Keys.Tab); + + ClickModalButton("Swipe the badge"); + WaitForModalClose(); + + Thread.Sleep(2000); + + // Verify Access Granted on monitoring page + _driver.Navigate().GoToUrl($"{BaseUrl}/monitoring"); + WaitForBodyText("Access Granted"); + + // Check the first row is Access Granted + var firstRow = _driver.FindElement(By.CssSelector("tbody tr")); + Assert.That(firstRow.Text, Does.Contain("Access Granted"), + "First monitoring row should show 'Access Granted' after swipe with 'Today All Day' schedule"); + + TakeScreenshot("43_access_granted_today_schedule", "Monitoring shows Access Granted — 'Today All Day' schedule is active"); + TestContext.Progress.WriteLine("'Today All Day' schedule created, credential reassigned, Access Granted verified"); + } + + [Test, Order(17)] + public void CreateTodayHolidayAndVerifyDenied() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + // --- Add a holiday for today --- + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/holidays"); + + WaitForBodyText("Add Holiday"); + + ClickButtonContaining("Add Holiday"); + WaitForModal(); + + var modal = _driver.FindElement(By.CssSelector("div.modal.show")); + + var nameInput = modal.FindElement(By.CssSelector("input[type='text']")); + nameInput.Clear(); + nameInput.SendKeys("Today Holiday"); + nameInput.SendKeys(Keys.Tab); + + // Set date to today + var dateInput = modal.FindElement(By.CssSelector("input[type='date']")); + var todayDate = DateTime.Now.ToString("yyyy-MM-dd"); + ((IJavaScriptExecutor)_driver).ExecuteScript( + "var el = arguments[0]; var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; nativeSetter.call(el, arguments[1]); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));", + dateInput, todayDate); + dateInput.SendKeys(Keys.Tab); + + // Do NOT check "Repeats Annually" — leave it as a one-time holiday + + TakeScreenshot("44_today_holiday_modal", "Add 'Today Holiday' — date is today, no repeat"); + + ClickModalButton("Save"); + WaitForModalClose(); + + WaitForBodyText("Today Holiday"); + + TakeScreenshot("45_today_holiday_created", "'Today Holiday' created — today is now a holiday"); + + // --- Swipe badge and verify Access Denied --- + _driver.Navigate().GoToUrl($"{BaseUrl}/configuration/driver/{VirtualDriverGuid}"); + + WaitForBodyText("Test Reader"); + var readerRow = _driver.FindElement(By.Id("Reader:Test Reader")); + + var actionToggle = readerRow.FindElement(By.CssSelector("button.dropdown-toggle")); + actionToggle.Click(); + + WaitForVisibleDropdown(); + ClickDropdownItem("Swipe Badge"); + + WaitForModal(); + + var badgeInput = _driver.FindElement(By.Id("SwipeBadgeTextEdit")); + badgeInput.Clear(); + badgeInput.SendKeys("12345"); + badgeInput.SendKeys(Keys.Tab); + + ClickModalButton("Swipe the badge"); + WaitForModalClose(); + + Thread.Sleep(2000); + + // Verify Access Denied on monitoring page + _driver.Navigate().GoToUrl($"{BaseUrl}/monitoring"); + WaitForBodyText("Access Denied"); + + // Check the first row is Access Denied + var firstRow = _driver.FindElement(By.CssSelector("tbody tr")); + Assert.That(firstRow.Text, Does.Contain("Access Denied"), + "First monitoring row should show 'Access Denied' — today is a holiday and schedule doesn't include holidays"); + + TakeScreenshot("46_access_denied_holiday", "Monitoring shows Access Denied — today is a holiday, schedule has holidays=false"); + TestContext.Progress.WriteLine("Today Holiday created, Access Denied verified (schedule doesn't include holidays)"); + } + + [Test, Order(18)] + public void EnableHolidaysOnScheduleAndVerifyGranted() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + // --- Enable Holidays on "Today All Day" schedule --- + _driver!.Navigate().GoToUrl($"{BaseUrl}/configuration/schedules"); + + WaitForBodyText("Today All Day"); + + // Find the "Today All Day" row and click Edit + var scheduleRow = FindTableRowContaining("Today All Day"); + var editButton = scheduleRow.FindElement(By.CssSelector("button")); + editButton.Click(); + + WaitForModal(); + + var modal = _driver.FindElement(By.CssSelector("div.modal.show")); + + // Find the interval card and check the Holidays checkbox + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10)); + wait.Until(d => + { + var m = d.FindElement(By.CssSelector("div.modal.show")); + return m.FindElements(By.CssSelector(".card .card-body")).Count > 0; + }); + + var intervalCard = modal.FindElement(By.CssSelector(".card .card-body")); + + // The Holidays checkbox is the last label.custom-control-label in the card (after the 7 day checkboxes) + var checkboxLabels = intervalCard.FindElements(By.CssSelector("label.custom-control-label")); + Assert.That(checkboxLabels.Count, Is.GreaterThanOrEqualTo(8), + "Expected at least 8 checkbox labels (7 days + Holidays)"); + var holidaysLabel = checkboxLabels.First(l => l.Text.Contains("Holiday")); + ((IJavaScriptExecutor)_driver).ExecuteScript("arguments[0].click();", holidaysLabel); + Thread.Sleep(500); // Let Blazor re-render + + TakeScreenshot("47_enable_holidays_on_schedule", "Edit 'Today All Day' schedule — checking Holidays checkbox"); + + ClickModalButton("Save"); + WaitForModalClose(); + + // Wait for the save API call to complete (snackbar confirms it) + WaitForBodyText("Successfully updated schedule"); + + TakeScreenshot("48_holidays_enabled_saved", "'Today All Day' schedule saved with Holidays enabled"); + + // --- Swipe badge and verify Access Granted --- + _driver.Navigate().GoToUrl($"{BaseUrl}/configuration/driver/{VirtualDriverGuid}"); + + WaitForBodyText("Test Reader"); + var readerRow = _driver.FindElement(By.Id("Reader:Test Reader")); + + var actionToggle = readerRow.FindElement(By.CssSelector("button.dropdown-toggle")); + actionToggle.Click(); + + WaitForVisibleDropdown(); + ClickDropdownItem("Swipe Badge"); + + WaitForModal(); + + var badgeInput = _driver.FindElement(By.Id("SwipeBadgeTextEdit")); + badgeInput.Clear(); + badgeInput.SendKeys("12345"); + badgeInput.SendKeys(Keys.Tab); + + ClickModalButton("Swipe the badge"); + WaitForModalClose(); + + Thread.Sleep(2000); + + // Verify Access Granted on monitoring page + _driver.Navigate().GoToUrl($"{BaseUrl}/monitoring"); + WaitForBodyText("Access Granted"); + + // Check the first row is Access Granted + var firstRow = _driver.FindElement(By.CssSelector("tbody tr")); + Assert.That(firstRow.Text, Does.Contain("Access Granted"), + "First monitoring row should show 'Access Granted' — schedule now includes holidays"); + + TakeScreenshot("49_access_granted_holidays_enabled", "Monitoring shows Access Granted — schedule includes holidays, access is restored"); + TestContext.Progress.WriteLine("Holidays enabled on schedule, Access Granted verified"); + } + + // --- Card Format Test Helpers --- + + private void ClickModalBodyButton(string text) + { + var modal = _driver!.FindElement(By.CssSelector("div.modal.show")); + var body = modal.FindElement(By.CssSelector(".modal-body")); + var buttons = body.FindElements(By.TagName("button")); + foreach (var button in buttons) + { + if (button.Displayed && button.Text.Contains(text)) + { + button.Click(); + return; + } + } + Assert.Fail($"Could not find modal body button containing '{text}'"); + } + + private void WaitForElementCards(int expectedCount) + { + var wait = new WebDriverWait(_driver!, TimeSpan.FromSeconds(10)); + wait.Until(d => + { + var modal = d.FindElement(By.CssSelector("div.modal.show")); + var body = modal.FindElement(By.CssSelector(".modal-body")); + return body.FindElements(By.CssSelector(".card .card-body")).Count >= expectedCount; + }); + } + + private IReadOnlyList GetElementCards() + { + var modal = _driver!.FindElement(By.CssSelector("div.modal.show")); + var body = modal.FindElement(By.CssSelector(".modal-body")); + return body.FindElements(By.CssSelector(".card .card-body")); + } + + private void SetElementType(IWebElement card, string typeName) + { + var select = new SelectElement(card.FindElement(By.CssSelector("select"))); + select.SelectByText(typeName); + // Allow Blazor to re-render after type change + Thread.Sleep(500); + } + + private void SetElementNumericFields(IWebElement card, int start, int length) + { + var numInputs = card.FindElements(By.CssSelector("input[type='number']")); + Assert.That(numInputs.Count, Is.GreaterThanOrEqualTo(2), "Expected at least 2 numeric inputs in element card"); + numInputs[0].Clear(); + numInputs[0].SendKeys(start.ToString()); + numInputs[0].SendKeys(Keys.Tab); + numInputs[1].Clear(); + numInputs[1].SendKeys(length.ToString()); + numInputs[1].SendKeys(Keys.Tab); + } + + private void SetFieldType(IWebElement card, string fieldName) + { + // After type change, a second select appears for Field type + var selects = card.FindElements(By.CssSelector("select")); + Assert.That(selects.Count, Is.GreaterThanOrEqualTo(2), "Expected at least 2 selects for Field element"); + var fieldSelect = new SelectElement(selects[1]); + fieldSelect.SelectByText(fieldName); + } + + private void SetParityOdd(IWebElement card, bool odd) + { + if (!odd) return; + var checkboxLabels = card.FindElements(By.CssSelector("label.custom-control-label")); + if (checkboxLabels.Count > 0) + { + checkboxLabels[0].Click(); + } + } + + private void SetParityFields(IWebElement card, int srcStart, int srcLength) + { + // After type=Parity, additional numeric inputs appear for Src Start and Src Length + var numInputs = card.FindElements(By.CssSelector("input[type='number']")); + // First 2 are Start Bit and Length, next 2 are Src Start and Src Length + Assert.That(numInputs.Count, Is.GreaterThanOrEqualTo(4), "Expected at least 4 numeric inputs for Parity element"); + numInputs[2].Clear(); + numInputs[2].SendKeys(srcStart.ToString()); + numInputs[2].SendKeys(Keys.Tab); + numInputs[3].Clear(); + numInputs[3].SendKeys(srcLength.ToString()); + numInputs[3].SendKeys(Keys.Tab); + } + + // --- Helper Methods --- + + /// + /// Wait for the body text to contain the specified string. + /// This is the most reliable wait strategy for Blazor WASM pages. + /// + private void WaitForBodyText(string text, int timeoutSeconds = 30) + { + var wait = new WebDriverWait(_driver!, TimeSpan.FromSeconds(timeoutSeconds)); + wait.Until(d => + { + try + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains(text); + } + catch + { + return false; + } + }); + } + + /// + /// Find a table row containing the specified text by iterating elements. + /// Avoids XPath contains() which has issues with implicit waits. + /// + private IWebElement FindTableRowContaining(string text) + { + var rows = _driver!.FindElements(By.CssSelector("tbody tr")); + foreach (var row in rows) + { + if (row.Text.Contains(text)) + return row; + } + + Assert.Fail($"Could not find table row containing '{text}'"); + return null!; // unreachable + } + + /// + /// Click a button whose visible text contains the given string. + /// Uses JavaScript querySelectorAll to find all buttons, then filters by text. + /// + private void ClickButtonContaining(string text) + { + var buttons = _driver!.FindElements(By.TagName("button")); + foreach (var button in buttons) + { + if (button.Displayed && button.Text.Contains(text)) + { + button.Click(); + return; + } + } + + Assert.Fail($"Could not find visible button containing '{text}'"); + } + + private void WaitForModal() + { + var wait = new WebDriverWait(_driver!, TimeSpan.FromSeconds(10)); + wait.Until(d => d.FindElements(By.CssSelector("div.modal.show")).Count > 0); + } + + private void WaitForModalClose() + { + var wait = new WebDriverWait(_driver!, TimeSpan.FromSeconds(10)); + wait.Until(d => d.FindElements(By.CssSelector("div.modal.show")).Count == 0); + } + + private void WaitForVisibleDropdown() + { + var wait = new WebDriverWait(_driver!, TimeSpan.FromSeconds(10)); + wait.Until(d => d.FindElements(By.CssSelector("div.dropdown-menu.show")).Count > 0); + } + + /// + /// Click a dropdown item (rendered as by Blazorise) within the open dropdown menu. + /// + private void ClickDropdownItem(string text) + { + var menu = _driver!.FindElement(By.CssSelector("div.dropdown-menu.show")); + var items = menu.FindElements(By.CssSelector("a.dropdown-item")); + foreach (var item in items) + { + if (item.Text.Contains(text)) + { + item.Click(); + return; + } + } + + Assert.Fail($"Could not find dropdown item containing '{text}'"); + } + + private void ClickModalButton(string buttonText) + { + var wait = new WebDriverWait(_driver!, TimeSpan.FromSeconds(10)); + wait.Until(d => + { + var modal = d.FindElement(By.CssSelector("div.modal.show")); + var footer = modal.FindElement(By.CssSelector(".modal-footer")); + var buttons = footer.FindElements(By.TagName("button")); + foreach (var button in buttons) + { + if (button.Text.Contains(buttonText)) + { + button.Click(); + return true; + } + } + + return false; + }); + } + + private static void SelectOptionContainingText(SelectElement select, string text) + { + foreach (var option in select.Options) + { + if (option.Text.Contains(text)) + { + select.SelectByText(option.Text); + return; + } + } + + Assert.Fail($"Could not find option containing '{text}' in select"); + } + + private void TakeScreenshot(string name, string description = "") + { + if (_driver == null) return; + + Directory.CreateDirectory(ScreenshotDir); + var fileName = $"{name}.png"; + var filePath = Path.Combine(ScreenshotDir, fileName); + var screenshot = _driver.GetScreenshot(); + screenshot.SaveAsFile(filePath); + TestContext.Progress.WriteLine($"Screenshot saved: {filePath}"); + + _screenshotEntries.Add((fileName, description)); + } + + private void WriteScreenshotMarkdown() + { + if (_screenshotEntries.Count == 0) return; + + var mdPath = Path.Combine(ScreenshotDir, "AccessGrantedTest.md"); + using var writer = new StreamWriter(mdPath); + writer.WriteLine("# Aporta Access Granted End-to-End UI Test"); + writer.WriteLine(); + writer.WriteLine($"*Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}*"); + writer.WriteLine(); + + foreach (var (filePath, description) in _screenshotEntries) + { + if (!string.IsNullOrEmpty(description)) + { + writer.WriteLine($"## {description}"); + writer.WriteLine(); + } + + writer.WriteLine($"![{description}]({filePath})"); + writer.WriteLine(); + } + } +} diff --git a/test/Aporta.Selenium.Tests/Aporta.Selenium.Tests.csproj b/test/Aporta.Selenium.Tests/Aporta.Selenium.Tests.csproj new file mode 100644 index 0000000..c7b114a --- /dev/null +++ b/test/Aporta.Selenium.Tests/Aporta.Selenium.Tests.csproj @@ -0,0 +1,17 @@ + + + + false + enable + enable + + + + + + + + + + + diff --git a/test/Aporta.Selenium.Tests/HomePageSmokeTest.cs b/test/Aporta.Selenium.Tests/HomePageSmokeTest.cs new file mode 100644 index 0000000..62e5d67 --- /dev/null +++ b/test/Aporta.Selenium.Tests/HomePageSmokeTest.cs @@ -0,0 +1,196 @@ +using System.Diagnostics; +using System.Net; +using NUnit.Framework; +using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; +using OpenQA.Selenium.Support.UI; +namespace Aporta.Selenium.Tests; + +[TestFixture] +public class HomePageSmokeTest +{ + private const string BaseUrl = "https://localhost:5001"; + private const int AportaStartupTimeoutSeconds = 60; + + private static readonly string AportaPath = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "git", "Aporta"); + + private static readonly string ScreenshotDir = + Path.Combine(AportaPath, "test", "Aporta.Selenium.Tests", "screenshots"); + + private Process? _aportaProcess; + private ChromeDriver? _driver; + + private static string FindDotnet() + { + var candidates = new[] + { + "/opt/homebrew/opt/dotnet@8/bin/dotnet", + "/usr/local/share/dotnet/dotnet", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dotnet", "dotnet"), + "dotnet" + }; + return candidates.FirstOrDefault(File.Exists) ?? "dotnet"; + } + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + // Start Aporta + var dotnetPath = FindDotnet(); + var aportaProjectPath = Path.Combine(AportaPath, "src", "Aporta"); + + Assert.That(Directory.Exists(aportaProjectPath), Is.True, + $"Aporta project not found at {aportaProjectPath}"); + + var psi = new ProcessStartInfo(dotnetPath, + $"run --project \"{aportaProjectPath}\" -- --cleanDatabase true") + { + WorkingDirectory = AportaPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + _aportaProcess = Process.Start(psi); + Assert.That(_aportaProcess, Is.Not.Null, "Failed to start Aporta process"); + + _aportaProcess!.BeginOutputReadLine(); + _aportaProcess.BeginErrorReadLine(); + _aportaProcess.OutputDataReceived += (_, e) => + { + if (e.Data != null) TestContext.Progress.WriteLine($"[APORTA] {e.Data}"); + }; + _aportaProcess.ErrorDataReceived += (_, e) => + { + if (e.Data != null) TestContext.Progress.WriteLine($"[APORTA-ERR] {e.Data}"); + }; + + // Poll until Aporta responds + using var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + using var pollClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(5) }; + var deadline = DateTime.UtcNow.AddSeconds(AportaStartupTimeoutSeconds); + var ready = false; + while (DateTime.UtcNow < deadline) + { + try + { + var response = await pollClient.GetAsync($"{BaseUrl}/door/list"); + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.OK) + { + ready = true; + break; + } + } + catch + { + // Not ready yet + } + + await Task.Delay(1000); + } + + Assert.That(ready, Is.True, "Aporta did not become ready within the timeout"); + + // Set up ChromeDriver (Selenium Manager auto-downloads matching driver) + var options = new ChromeOptions(); + options.AddArgument("--headless=new"); + options.AddArgument("--window-size=1920,1080"); + options.AddArgument("--no-sandbox"); + options.AddArgument("--disable-dev-shm-usage"); + options.AcceptInsecureCertificates = true; + + _driver = new ChromeDriver(options); + _driver.Manage().Timeouts().PageLoad = TimeSpan.FromSeconds(30); + _driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(10); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + _driver?.Quit(); + _driver?.Dispose(); + + if (_aportaProcess is { HasExited: false }) + { + _aportaProcess.Kill(entireProcessTree: true); + _aportaProcess.WaitForExit(5000); + } + + _aportaProcess?.Dispose(); + } + + [Test] + public void HomePageLoads() + { + Assert.That(_driver, Is.Not.Null, "ChromeDriver was not initialized"); + + _driver!.Navigate().GoToUrl(BaseUrl); + + // Wait for Blazor WASM to fully load and render the login screen + var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(60)); + wait.Until(d => + { + try + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Aporta Login"); + } + catch (NoSuchElementException) + { + return false; + } + }); + + // Log in through the UI + var inputs = _driver.FindElements(By.CssSelector("input")); + inputs[0].SendKeys("admin"); + inputs[1].SendKeys("pass"); + + var buttons = _driver.FindElements(By.TagName("button")); + foreach (var button in buttons) + { + if (button.Displayed && button.Text.Contains("Login")) + { + button.Click(); + break; + } + } + + // Wait for home page to load after login + wait.Until(d => + { + try + { + var body = d.FindElement(By.TagName("body")); + return body.Text.Contains("Welcome to Aporta"); + } + catch (NoSuchElementException) + { + return false; + } + }); + + Assert.That(_driver.Title, Is.EqualTo("Aporta")); + + var body = _driver.FindElement(By.TagName("body")); + Assert.That(body.Text, Does.Contain("Welcome to Aporta")); + + TakeScreenshot("home_page"); + TestContext.Progress.WriteLine("Home page loaded successfully with Blazor WASM content"); + } + + private void TakeScreenshot(string name) + { + if (_driver == null) return; + + Directory.CreateDirectory(ScreenshotDir); + var filePath = Path.Combine(ScreenshotDir, $"{name}.png"); + var screenshot = _driver.GetScreenshot(); + screenshot.SaveAsFile(filePath); + TestContext.Progress.WriteLine($"Screenshot saved: {filePath}"); + } +} diff --git a/test/Drivers/TestDriver/TestDriver.cs b/test/Drivers/TestDriver/TestDriver.cs index 43c7451..352d540 100644 --- a/test/Drivers/TestDriver/TestDriver.cs +++ b/test/Drivers/TestDriver/TestDriver.cs @@ -119,6 +119,8 @@ public Task PerformAction(string action, string parameters) public event EventHandler OnlineStatusChanged; + public event EventHandler LocalStatusChanged; + protected virtual void OnUpdatedEndpoints() { UpdatedEndpoints?.Invoke(this, EventArgs.Empty); diff --git a/test/Drivers/Virtual/Aporta.Drivers.Virtual.WebClient.Tests/ConfigurationTest.cs b/test/Drivers/Virtual/Aporta.Drivers.Virtual.WebClient.Tests/ConfigurationTest.cs index 3e8faf2..b7ea836 100644 --- a/test/Drivers/Virtual/Aporta.Drivers.Virtual.WebClient.Tests/ConfigurationTest.cs +++ b/test/Drivers/Virtual/Aporta.Drivers.Virtual.WebClient.Tests/ConfigurationTest.cs @@ -1,6 +1,9 @@ +using System.Collections.Generic; +using System.Net.Http; using Aporta.Drivers.Virtual.Shared; using Aporta.Drivers.Virtual.Shared.Actions; using Aporta.Shared.Calls; +using Aporta.Shared.Models.Flex; using Blazorise; using Bunit; using Microsoft.Extensions.DependencyInjection; @@ -17,17 +20,19 @@ public class ConfigurationTest : AportaTestContext private const string EmptyConfiguration = "{\"Readers\":[],\"Outputs\":[],\"Inputs\":[]}"; private readonly Mock _mockConfigurationCalls = new(); - private readonly Mock _mockDoorCalls = new(); + private readonly Mock _mockFlexApi; private readonly Guid _extensionId = Guid.NewGuid(); private IRenderedComponent? _cut; - + public ConfigurationTest() { BlazoriseConfig.JSInterop.AddTextEdit(JSInterop); BlazoriseConfig.JSInterop.AddButton(JSInterop); + _mockFlexApi = new Mock(new HttpClient()) { CallBase = false }; + Services.AddScoped(_ => _mockConfigurationCalls.Object); } @@ -42,16 +47,20 @@ private Shared.Configuration SetUpDeviceConfiguration() return config; } - private void SetUpDoorMock() + private void SetUpFlexApiMock() { - Services.AddScoped(_ => _mockDoorCalls.Object); + _mockFlexApi.Setup(api => api.GetAvailableEndpointsAsync()) + .ReturnsAsync(new FlexListResponse { InstanceList = new List() }); + + Services.AddScoped(_ => _mockFlexApi.Object); } - private void SetUpDoorMock(Endpoint[] endpoints) - { - _mockDoorCalls.Setup(calls => calls.GetAvailableEndpoints()).ReturnsAsync(endpoints); + private void SetUpFlexApiMock(FlexDev[] endpoints) + { + _mockFlexApi.Setup(api => api.GetAvailableEndpointsAsync()) + .ReturnsAsync(new FlexListResponse { InstanceList = new List(endpoints) }); - Services.AddScoped(_ => _mockDoorCalls.Object); + Services.AddScoped(_ => _mockFlexApi.Object); } [Test] @@ -59,7 +68,7 @@ public async Task SwipeBadge() { // Arrange - SetUpDoorMock(); + SetUpFlexApiMock(); byte readerNumber = 1; var cardData = "2468"; @@ -96,7 +105,7 @@ public async Task SwipeBadge() public void ConfigurationComponentRendersCorrectly() { // Act - Render with an empty configuration - SetUpDoorMock(); + SetUpFlexApiMock(); _cut = RenderComponent(parameters => parameters .Add(p => p.RawConfiguration, EmptyConfiguration) @@ -113,7 +122,7 @@ public void ConfigurationComponentRendersCorrectly() public async Task AddReader() { // Arrange - SetUpDoorMock(); + SetUpFlexApiMock(); var emptyConfig = new Shared.Configuration(); @@ -152,21 +161,19 @@ public async Task EditReader() new() { Name = "Virtual Reader 3", Number = 3 } }; - Endpoint[] availableEndPoints = + FlexDev[] availableEndPoints = { new() { - ExtensionId = _extensionId, Type = EndpointType.Reader, - DriverEndpointId = $"VR{readersOnRazorPage[1].Number}", Id = readersOnRazorPage[1].Number + ExternalId = $"VR{readersOnRazorPage[1].Number}", Unid = readersOnRazorPage[1].Number }, new() { - ExtensionId = _extensionId, Type = EndpointType.Reader, - DriverEndpointId = $"VR{readersOnRazorPage[2].Number}", Id = readersOnRazorPage[2].Number + ExternalId = $"VR{readersOnRazorPage[2].Number}", Unid = readersOnRazorPage[2].Number } }; - SetUpDoorMock(availableEndPoints); + SetUpFlexApiMock(availableEndPoints); var config = new Shared.Configuration(); @@ -181,14 +188,14 @@ public async Task EditReader() !button.Instance.Disabled && button.Nodes[0].TextContent.Trim() == "Edit"); await _cut.InvokeAsync(async () => await editButton.Instance.Clicked.InvokeAsync()); - + var textEdit = _cut.Find("#NameTextEdit"); textEdit.Input("Edit Reader Name"); var modalEditButton = _cut.FindComponents