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 @@ [](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 @(_liveEventMonitoring ? "Disable" : "Enable") Live Monitoring - + @@ -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 -
Sorry, there's nothing at this address.