diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3db8c2d..d56b24e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: [ main, develop ] pull_request: - branches: [ main ] + branches: [ main, develop ] concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -100,8 +100,10 @@ jobs: run: dotnet build --configuration Release - name: Start server & probe health + env: + ASPNETCORE_URLS: "http://localhost:8150" run: | - dotnet run --project src/CodeMaster.Web/CodeMaster.Web.csproj --configuration Release & + dotnet run --project src/CodeMaster.Web/CodeMaster.Web.csproj --configuration Release --no-build & APP_PID=$! echo "Server started with PID $APP_PID" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 60d006c..d090047 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -4,7 +4,7 @@ on: push: branches: [ main, develop ] pull_request: - branches: [ main ] + branches: [ main, develop ] schedule: - cron: '0 6 * * 1' diff --git a/Directory.Build.props b/Directory.Build.props index cdb5dae..29b1d9e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,9 +5,9 @@ enable true latest - 1.0.0 - 1.0.0.0 - 1.0.0.0 + 1.5.0 + 1.5.0.0 + 1.5.0.0 diff --git a/addon/CHANGELOG.md b/addon/CHANGELOG.md index 138c897..d082d13 100644 --- a/addon/CHANGELOG.md +++ b/addon/CHANGELOG.md @@ -2,6 +2,55 @@ All notable changes to the CodeMaster Home Assistant Add-on will be documented in this file. +## [1.5.0] - 2026-09-06 + +### Added +- Pluggable Hardware Entity Transports: Introduced `ITransport`, `ILockTransport`, `IKeypadTransport`, and `ISensorTransport` interfaces with central `ITransportRegistry`. +- Direct Z-Wave JS Server WebSocket Transport (`ZWaveWebSocketTransport`): Connects directly to `zwave-js-server` via JSON-RPC, eliminating the requirement to enable the MQTT gateway in Z-Wave JS UI. +- Native Hardware Detection: Validated against physical mesh hardware: Node 18 (August Smart Lock Pro), Node 39 (Schlage BE469ZP Touchscreen Deadbolt), and Node 40 (Ring Keypad v2). +- Dynamic System Settings Persistence: SQLite `system_settings` table backing `SettingsController` (`GET/PUT /api/settings`, `POST /api/settings/test-connection`) with container environment variable fallbacks and live hot-reconnection. +- Interactive Settings View & Zustand Store: Real-time segmented transport selector, live WebSocket connection latency & node diagnostic probe, and MQTT credential management. +- Smart Door Setup Wizard: Auto-populates discovered Z-Wave locks and keypads directly from the active WebSocket session. + +## [1.4.0] - 2026-09-06 + +### Added +- In-modal PIN validation error alert banner (`role="alert"`) and visual border highlighting in `UserManagement` UI with auto-dismiss on keystroke. +- Strict 4–8 numeric digit PIN validation in REST API (`UsersController.SetPin`) and MCP tool (`codemaster__create_guest_pin`). +- Closed-loop test harness assertions replacing trivial checks: live SSE event delivery stream tests, hardware slot error handling, and non-numeric credential rejection tests. + +### Fixed +- Fixed SSE event serialization formatting (`s_sseJsonOptions`) using camelCase naming policy and string enum serialization for real-time frontend streaming. + +## [1.3.0] - 2026-09-06 + +### Added +- Real-time live activity audit streaming via Server-Sent Events (`EventSource`) at `/api/logs/stream` integrated with Zustand `useAuditStore`. +- Home Assistant dynamic Ingress reverse-proxy integration with automated `` and `` runtime HTML injection. +- Home Assistant Add-on `/data/options.json` configuration ingestion mapping `mqtt_host`, `mqtt_port`, `mqtt_username`, and `mqtt_password` directly to engine options. +- Manual lock/unlock audit event dispatch to `INotificationDispatcher` (Apprise) with typed client constructor resolution. + +## [1.2.0] - 2026-09-06 + +### Added +- Authenticated AES-256-GCM `ICredentialEncryptionService` protecting sensitive PIN credentials at rest with dynamic key derivation and transparent legacy plaintext fallback. +- Cryptographically salted PIN hashing with constant-time equality verification (`PinSecurityHelper.VerifyPinHash`) preventing timing attacks. +- Physical Lock Code Revocation on user deletion and MCP revocation (`ClearUserHardwareSlotsAsync`) clearing active lock deadbolt slot codes. +- Timezone-aware access policy evaluation supporting arbitrary `TimeZoneId` mappings (e.g. `America/Chicago`) for localized recurring schedules. + +## [1.1.0] - 2026-09-06 + +### Added +- Hosted `MqttInboundConsumerService` for reliable continuous consumption and processing of the bounded MQTT channel. +- Automatic Door Policy Assignments in `UsersController.Create` and `SavePolicy` with multi-door targeting. +- Automated Home Assistant MQTT Discovery entity registration and unregistration on door lifecycle events. +- Live real-time lock state and contact sensor telemetry reconciliation via `UpdateDoorStates`. + +### Fixed +- Fixed auto-lock countdown calculation to expose live remaining seconds via dynamic expiration timestamp. +- Added numeric PIN validation (`All(char.IsAsciiDigit)`) in `HardwareSlotSyncWorker` preventing raw hashes being sent to physical locks. +- Fixed `HardwareSlotRepository.ClearSlotAsync` marking slots as Synced instead of PendingSync. + ## [1.0.0] - 2026-09-06 ### Added diff --git a/addon/config.yaml b/addon/config.yaml index 7dc6be0..0da292e 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,6 +1,6 @@ name: "CodeMaster" description: "Universal Access Control & Lock/Keypad Synchronization Engine" -version: "1.0.0" +version: "1.5.0" slug: "codemaster" arch: - amd64 diff --git a/docs/screenshots/01-dashboard-doors.png b/docs/screenshots/01-dashboard-doors.png index 2ad4849..f3dc0f7 100644 Binary files a/docs/screenshots/01-dashboard-doors.png and b/docs/screenshots/01-dashboard-doors.png differ diff --git a/docs/screenshots/02-users-and-schedules.png b/docs/screenshots/02-users-and-schedules.png index a46f9b4..ea9c039 100644 Binary files a/docs/screenshots/02-users-and-schedules.png and b/docs/screenshots/02-users-and-schedules.png differ diff --git a/docs/screenshots/03-activity-audit-log.png b/docs/screenshots/03-activity-audit-log.png index 3b53a20..8f78365 100644 Binary files a/docs/screenshots/03-activity-audit-log.png and b/docs/screenshots/03-activity-audit-log.png differ diff --git a/docs/screenshots/04-door-setup-wizard.png b/docs/screenshots/04-door-setup-wizard.png index 9fa322d..e235408 100644 Binary files a/docs/screenshots/04-door-setup-wizard.png and b/docs/screenshots/04-door-setup-wizard.png differ diff --git a/docs/screenshots/05-mobile-responsive.png b/docs/screenshots/05-mobile-responsive.png index 4357f56..c24ba74 100644 Binary files a/docs/screenshots/05-mobile-responsive.png and b/docs/screenshots/05-mobile-responsive.png differ diff --git a/docs/superpowers/plans/2026-09-06-codemaster-remediation.md b/docs/superpowers/plans/2026-09-06-codemaster-remediation.md new file mode 100644 index 0000000..792d446 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-codemaster-remediation.md @@ -0,0 +1,89 @@ +# CodeMaster Remediation & Production Hardening Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Transform CodeMaster from a vibe-coded prototype into an enterprise-grade access control engine by resolving all critical pipeline disconnects, data orphan bugs, security/encryption gaps, hardware sync safety issues, and frontend streaming limitations across 3 minor releases (v1.1.0, v1.2.0, v1.3.0). + +**Architecture:** +1. Build `MqttInboundConsumerService` consuming `MqttInboundChannel` to process keypad events, lock states, and sensor changes in real time. +2. Fix policy assignment links (`AccessAssignments`) and schedule evaluation. +3. Introduce AES-256-GCM reversible encryption for hardware slot codes and salted PIN hashing. +4. Enforce physical lock clearance on user deletion/revocation. +5. Provide genuine SSE streaming in the React UI, dynamic Ingress basePath resolution, and HA `/data/options.json` support. + +**Tech Stack:** .NET 10, C# 13, Dapper, SQLite (WAL), System.Threading.Channels, MQTTnet, React 19, TypeScript, Vitest, Playwright. + +--- + +## Global Constraints +- Target .NET 10 (`net10.0`), C# 13, `enable`. +- Container immutability: all changes build into Docker images cleanly. +- Verify release version synchronization with `python3 verify_release.py --ci --skip-tests`. +- Maintain >= 80% test coverage with zero ESLint warnings (`npm run lint`). +- Only mock when absolutely necessary and ensure mock data matches realistic hardware payloads. + +--- + +## Release Set 1: Core Pipeline & Policy Engine (v1.1.0) +Branch: `feature/v1.1.0-pipeline-and-policy-engine` + +### Task 1.1: Build `MqttInboundConsumerService` Background Worker +- [ ] Implement `src/CodeMaster.Engine/Services/MqttInboundConsumerService.cs` as a `BackgroundService` that reads from `_inboundChannel.Reader.ReadAllAsync(stoppingToken)`. +- [ ] Route keypad events to `RingMqttKeypadProvider` and `BuiltInLockKeypadProvider`. +- [ ] On valid keypad entry, call `AccessPolicyEvaluator`, unlock door, update auto-lock state, write `AccessLog`, and broadcast event. +- [ ] Route lock telemetry and door contact states to `DoorOperationService.UpdateDoorStates`. +- [ ] Register `MqttInboundConsumerService` in `Program.cs` and add `codemaster/#` to `MqttOptions.SubscribedTopics`. + +### Task 1.2: Auto-Lock Countdown Tracking & State Machine Lifecycle +- [ ] Enhance `AutoLockStateMachine` to track expiration timestamp and `RemainingSeconds`. +- [ ] Update `DoorOperationService.GetRemainingAutoLockSecondsAsync` to query state machine. + +### Task 1.3: Fix Policy Assignment in `UsersController.SavePolicy` & `UserManagement.tsx` +- [ ] Modify `SavePolicy` in `UsersController.cs` to assign user to policy and selected/all doors via `AccessAssignment`. +- [ ] Update `useUserStore.ts` and `UserManagement.tsx` to handle door assignments. + +### Task 1.4: Hardware Slot Sync Guard & Status Reset +- [ ] Ensure `HardwareSlotSyncWorker.cs` never sends hashed values to physical locks. +- [ ] Fix `HardwareSlotRepository.ClearSlotAsync` to reset `SyncStatus` to `Synced`. + +### Task 1.5: Automated Home Assistant Discovery & Release v1.1.0 +- [ ] Publish Home Assistant discovery messages on door registration and MQTT connect. +- [ ] Add unit and closed-loop tests verifying the pipeline. +- [ ] Bump version to 1.1.0 and merge to `develop`. + +--- + +## Release Set 2: Cryptographic Security & Hardware Safety (v1.2.0) +Branch: `feature/v1.2.0-security-and-hardware-safety` + +### Task 2.1: AES-256-GCM Reversible Encryption Service +- [ ] Implement `ICredentialEncryptionService` using AES-256-GCM. +- [ ] Store authenticated encrypted ciphertext in `EncryptedValue`. + +### Task 2.2: Salted PIN Hashing & Constant-Time Verification +- [ ] Generate secure random salts for PIN hashes. +- [ ] Use constant-time equality check (`CryptographicOperations.FixedTimeEquals`). + +### Task 2.3: Physical Lock Clearance on Revocation/Deletion +- [ ] When deleting a user in `UsersController` or revoking in MCP `DoorTools`, clear physical lock slots via `lockProvider.ClearSlotCodeAsync`. + +### Task 2.4: Timezone Awareness in Access Schedules & Release v1.2.0 +- [ ] Support local timezone in `AccessPolicy.IsActiveAt` before checking time/day windows. +- [ ] Bump version to 1.2.0 and merge to `develop`. + +--- + +## Release Set 3: Frontend Live Streaming, Ingress & Integration (v1.3.0) +Branch: `feature/v1.3.0-frontend-streaming-and-ingress` + +### Task 3.1: Live SSE Event Streaming in Frontend +- [ ] Connect `EventSource('/api/logs/stream')` in `useAuditStore.ts` and update `LiveEventFeed.tsx`. + +### Task 3.2: Dynamic Home Assistant Ingress Base-Path Support +- [ ] Inject `X-Ingress-Path` into `index.html` via ASP.NET middleware. +- [ ] Update `apiClient.getBasePath()` to support Ingress subpaths. + +### Task 3.3: Home Assistant OS `options.json` & Apprise Options Fix +- [ ] Load optional `/data/options.json` in `Program.cs`. +- [ ] Fix `AppriseOptions` binding and trigger notifications on access events. +- [ ] Bump version to 1.3.0 and merge to `develop`. diff --git a/docs/superpowers/plans/2026-09-06-pluggable-entity-transports-and-ui-settings.md b/docs/superpowers/plans/2026-09-06-pluggable-entity-transports-and-ui-settings.md new file mode 100644 index 0000000..0669d91 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-pluggable-entity-transports-and-ui-settings.md @@ -0,0 +1,179 @@ +# Pluggable Entity Transports & UI Settings Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Decouple hardware entity communications behind an extensible `ITransport` abstraction (supporting direct Z-Wave JS WebSocket and Mosquitto MQTT), persist settings in SQLite with container environment variable fallbacks, and build an interactive UI Settings manager. + +**Architecture:** Core interfaces (`ITransport`, `ILockTransport`, `IKeypadTransport`, `ISensorTransport`, `ITransportRegistry`) decouple domain logic from wire protocols. `ZWaveWebSocketTransport` connects to `ws://...:3000`/`8106` via JSON-RPC. A `system_settings` SQLite table stores configuration with hot-reload via `PUT /api/settings`. React 19 UI in `SettingsView.tsx` provides interactive transport selection and connection testing. + +**Tech Stack:** .NET 10, C# 13, `System.Net.WebSockets.ClientWebSocket`, Dapper, SQLite (WAL mode), React 19, TypeScript, Zustand, Vitest, xUnit. + +## Global Constraints +- Target Framework: `net10.0`, `enable`, `enable`. +- Strict Conventional Commits (`feat:`, `fix:`, `test:`, `chore:`). +- Zero ESLint warnings (`eslint . --max-warnings 0`). +- No hot-patching running containers; containers are immutable. +- Feature branch: `feature/v1.5.0-pluggable-entity-transports-and-ui-settings`. +- Do NOT merge into develop when done; push feature branch and create/prepare PR for review. + +--- + +### Task 1: Core Transport & Capability Interfaces + +**Files:** +- Create: `src/CodeMaster.Core/Transports/ITransport.cs` +- Create: `src/CodeMaster.Core/Transports/ILockTransport.cs` +- Create: `src/CodeMaster.Core/Transports/IKeypadTransport.cs` +- Create: `src/CodeMaster.Core/Transports/ISensorTransport.cs` +- Create: `src/CodeMaster.Core/Transports/ITransportRegistry.cs` +- Create: `src/CodeMaster.Core/Transports/TransportModels.cs` +- Test: `tests/CodeMaster.Tests.Unit/TransportRegistryTests.cs` + +- [ ] Step 1: Write `TransportModels.cs` defining `TransportStatus`, `ZWaveTransportType`, and event argument records. +- [ ] Step 2: Define `ITransport`, `ILockTransport`, `IKeypadTransport`, `ISensorTransport`, and `ITransportRegistry`. +- [ ] Step 3: Implement `TransportRegistry.cs` in `CodeMaster.Engine/Transports/`. +- [ ] Step 4: Write unit tests in `TransportRegistryTests.cs` asserting registration, capability retrieval, and lookup. +- [ ] Step 5: Run `dotnet test tests/CodeMaster.Tests.Unit` and verify. +- [ ] Step 6: Commit changes: `feat(core): add ITransport abstractions and capability interfaces`. + +--- + +### Task 2: System Settings SQLite Persistence & Repository + +**Files:** +- Modify: `src/CodeMaster.Data/Db/DatabaseSeederService.cs` +- Create: `src/CodeMaster.Core/Interfaces/ISettingsRepository.cs` +- Create: `src/CodeMaster.Data/Repositories/SettingsRepository.cs` +- Create: `src/CodeMaster.Core/Services/SystemSettingsService.cs` +- Test: `tests/CodeMaster.Tests.Unit/SettingsRepositoryTests.cs` + +- [ ] Step 1: Add `system_settings` table migration in `DatabaseSeederService.cs`. +- [ ] Step 2: Implement `SettingsRepository.cs` using Dapper (`GetAllSettingsAsync`, `GetSettingAsync`, `SetSettingAsync`, `SetSettingsAsync`). +- [ ] Step 3: Implement `SystemSettingsService.cs` providing strongly-typed settings access with environment variable / appsettings fallbacks. +- [ ] Step 4: Write unit tests in `SettingsRepositoryTests.cs`. +- [ ] Step 5: Run tests, verify passing, and commit: `feat(data): add system_settings persistence and configuration fallback service`. + +--- + +### Task 3: Z-Wave WebSocket Transport (`ZWaveWebSocketTransport`) + +**Files:** +- Create: `src/CodeMaster.Engine/Transports/ZWaveWebSocketTransport.cs` +- Create: `src/CodeMaster.Engine/Transports/ZWaveJsProtocolModels.cs` +- Test: `tests/CodeMaster.Tests.Unit/ZWaveWebSocketTransportTests.cs` + +- [ ] Step 1: Define JSON-RPC message records for `zwave-js-server` in `ZWaveJsProtocolModels.cs`. +- [ ] Step 2: Implement `ZWaveWebSocketTransport` using `ClientWebSocket`: + - Lifecycle: `StartAsync` with auto-reconnect and exponential backoff, `StopAsync`. + - Handshake: receive version, send `start_listening`. + - Commands: CC 98 (Door Lock `set`/`get`), CC 99 (User Code `set`/`get`/`clear`). + - Event loop: parse `value updated` and `notification` (Ring Keypad entry, jam, manual lock/unlock). +- [ ] Step 3: Implement `ILockTransport` and `IKeypadTransport` methods. +- [ ] Step 4: Write comprehensive unit tests in `ZWaveWebSocketTransportTests.cs` simulating WebSocket server frames. +- [ ] Step 5: Run tests, verify passing, and commit: `feat(engine): implement ZWaveWebSocketTransport with JSON-RPC server driver`. + +--- + +### Task 4: Z-Wave MQTT Transport & Transport Delegation + +**Files:** +- Create: `src/CodeMaster.Engine/Transports/ZWaveMqttTransport.cs` +- Modify: `src/CodeMaster.Engine/Providers/Locks/ZWaveJsMqttLockProvider.cs` +- Modify: `src/CodeMaster.Engine/Services/DoorOperationService.cs` +- Test: `tests/CodeMaster.Tests.Unit/ZWaveMqttTransportTests.cs` + +- [ ] Step 1: Implement `ZWaveMqttTransport` wrapping Mosquitto `zwave/#` communication. +- [ ] Step 2: Update `DoorOperationService` to resolve the configured lock transport from `ITransportRegistry` before executing lock/unlock. +- [ ] Step 3: Update `HardwareSlotSyncWorker` to sync slots via `ILockTransport`. +- [ ] Step 4: Write unit tests verifying lock operation dispatch through transport registry. +- [ ] Step 5: Run tests, verify passing, and commit: `feat(engine): integrate ITransportRegistry with door operations and hardware slot sync`. + +--- + +### Task 5: Settings API Controller with Dynamic Hot-Reload + +**Files:** +- Create: `src/CodeMaster.Web/Controllers/SettingsController.cs` +- Create: `src/CodeMaster.Web/DTOs/SettingsDtos.cs` +- Modify: `src/CodeMaster.Web/Program.cs` +- Test: `tests/CodeMaster.Tests.Unit/SettingsControllerTests.cs` + +- [ ] Step 1: Create `SettingsController.cs` with: + - `GET /api/settings`: Returns effective settings and transport status summary. + - `PUT /api/settings`: Persists settings and calls `ITransportRegistry.ReconfigureAsync(...)`. + - `POST /api/settings/test-connection`: Tests WebSocket or MQTT connectivity and returns diagnostics. +- [ ] Step 2: Register services in `Program.cs`. +- [ ] Step 3: Write integration tests in `SettingsControllerTests.cs`. +- [ ] Step 4: Run tests, verify passing, and commit: `feat(web): add SettingsController with dynamic transport hot-reload and diagnostics`. + +--- + +### Task 6: Frontend Settings Store & API Client + +**Files:** +- Modify: `src/CodeMaster.UI/src/api/apiClient.ts` +- Modify: `src/CodeMaster.UI/src/types/index.ts` +- Create: `src/CodeMaster.UI/src/stores/useSettingsStore.ts` +- Test: `src/CodeMaster.UI/src/stores/useSettingsStore.test.ts` + +- [ ] Step 1: Add settings endpoints and DTOs to `apiClient.ts` and `types/index.ts`. +- [ ] Step 2: Implement Zustand `useSettingsStore.ts` (fetch, update, testConnection, connectionStatus). +- [ ] Step 3: Write tests in `useSettingsStore.test.ts`. +- [ ] Step 4: Run `npm test` and verify passing. +- [ ] Step 5: Commit: `feat(ui): add useSettingsStore and settings API client`. + +--- + +### Task 7: Interactive Settings View Component + +**Files:** +- Modify: `src/CodeMaster.UI/src/components/settings/SettingsView.tsx` +- Test: `src/CodeMaster.UI/src/components/settings/SettingsView.test.tsx` + +- [ ] Step 1: Rebuild `SettingsView.tsx`: + - Segmented toggle for Z-Wave mode: `WebSocket (Direct)` vs `MQTT (Mosquitto)`. + - WebSocket URL input with "Test Connection" button displaying latency and node count. + - MQTT broker configuration card with live status indicator. + - Apprise alert webhook configuration. + - Save button with toast notification. +- [ ] Step 2: Write unit tests in `SettingsView.test.tsx`. +- [ ] Step 3: Run `npm test && npm run lint`. +- [ ] Step 4: Commit: `feat(ui): implement interactive SettingsView with live transport selector and diagnostics`. + +--- + +### Task 8: Door Setup Wizard Node Auto-Discovery + +**Files:** +- Modify: `src/CodeMaster.UI/src/components/doors/DoorSetupWizard.tsx` +- Test: `src/CodeMaster.UI/src/components/doors/DoorSetupWizard.test.tsx` + +- [ ] Step 1: Update `DoorSetupWizard.tsx` to detect active transport mode. +- [ ] Step 2: In WebSocket mode, offer detected Z-Wave node picker (Node 39 Schlage, Node 40 Ring Keypad). +- [ ] Step 3: Write tests verifying node selection. +- [ ] Step 4: Run `npm run build && npm test && npm run lint`. +- [ ] Step 5: Commit: `feat(ui): enhance DoorSetupWizard with Z-Wave WebSocket node selection`. + +--- + +### Task 9: Live Probe Against Physical Hardware (`ws://10.0.0.10:8106`) + +**Files:** +- Test/Probe: Execute live WebSocket probe to `10.0.0.10:8106`. + +- [ ] Step 1: Run diagnostics against `ws://10.0.0.10:8106`. +- [ ] Step 2: Verify `start_listening` response from Z-Wave JS UI server. +- [ ] Step 3: Confirm node state and user code CC support for Node 39 (Schlage BE469ZP) and notification events for Node 40 (Ring Keypad v2). + +--- + +### Task 10: End-to-End Verification, Version Bump & PR Push + +**Files:** +- Modify: `Directory.Build.props`, `addon/config.yaml`, `addon/CHANGELOG.md`, `package.json` + +- [ ] Step 1: Bump SemVer to `1.5.0` across metadata files and update changelog. +- [ ] Step 2: Run `python3 verify_release.py --ci`. +- [ ] Step 3: Commit release assets: `chore(release): bump version to 1.5.0 and prepare PR`. +- [ ] Step 4: Push branch `feature/v1.5.0-pluggable-entity-transports-and-ui-settings` to origin (`git@github.com:spelech/CodeMaster.git`). +- [ ] Step 5: Leave branch open without merging into `develop` as requested. diff --git a/docs/superpowers/specs/2026-09-06-pluggable-entity-transports-and-ui-settings-design.md b/docs/superpowers/specs/2026-09-06-pluggable-entity-transports-and-ui-settings-design.md new file mode 100644 index 0000000..d7a4f96 --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-pluggable-entity-transports-and-ui-settings-design.md @@ -0,0 +1,104 @@ +# Pluggable Entity Transports & UI Settings Design Specification + +## Overview + +This specification details the architecture for decoupling hardware communication protocols (transports) from domain entities (locks, keypads, door contact sensors) and introducing an interactive Settings management interface in CodeMaster. + +CodeMaster allows users to choose between **WebSocket** (direct connection to Z-Wave JS UI server on `ws://...:3000` / host `8106`) or **MQTT** (Mosquitto `zwave/#` topics) for Z-Wave locks and keypads, while generalizing the transport abstraction across all hardware entities and enabling live configuration via the Web UI. + +--- + +## 1. Architecture & Core Abstractions + +### 1.1 Base Transport Interface (`ITransport`) +All hardware communication channels implement `ITransport`: +- `string TransportId { get; }` (e.g. `"zwave_ws"`, `"mqtt_broker"`) +- `string DisplayName { get; }` +- `bool IsConnected { get; }` +- `TransportStatus Status { get; }` (`Connected`, `Connecting`, `Disconnected`, `Degraded`) +- `Task StartAsync(CancellationToken ct = default)` +- `Task StopAsync(CancellationToken ct = default)` +- `event Action? OnStatusChanged` + +### 1.2 Entity-Specific Capabilities +Transports implement capability interfaces depending on the entity types they support: + +1. **`ILockTransport`**: + - `Task SetLockStateAsync(string deviceTarget, bool locked, CancellationToken ct = default)` + - `Task GetLockStateAsync(string deviceTarget, CancellationToken ct = default)` + - `Task SetUserCodeAsync(string deviceTarget, int slot, string pin, string? label, CancellationToken ct = default)` + - `Task ClearUserCodeAsync(string deviceTarget, int slot, CancellationToken ct = default)` + - `Task> GetUserCodesAsync(string deviceTarget, CancellationToken ct = default)` + - `event Action? OnLockStateChanged` + +2. **`IKeypadTransport`**: + - `event Action? OnKeypadEntry` + - `Task SetKeypadModeAsync(string deviceTarget, KeypadArmMode mode, CancellationToken ct = default)` + +3. **`ISensorTransport`**: + - `event Action? OnSensorStateChanged` + - `Task GetSensorStateAsync(string deviceTarget, CancellationToken ct = default)` + +### 1.3 `ITransportRegistry` +A thread-safe singleton managing registered transports: +- `void RegisterTransport(ITransport transport)` +- `T? GetTransport(string transportId) where T : class, ITransport` +- `IReadOnlyList GetAllTransports()` +- `Task ReconfigureAsync(SystemSettings settings, CancellationToken ct = default)` + +--- + +## 2. Z-Wave WebSocket Transport (`ZWaveWebSocketTransport`) + +Implements `ILockTransport` and `IKeypadTransport` by directly connecting to `zwave-js-server`: +- Protocol: JSON-RPC over WebSocket (`System.Net.WebSockets.ClientWebSocket`). +- Handshake: Connect -> receive `version` event -> send `{ "messageId": "init", "command": "start_listening" }`. +- Lock Commands: Dispatches `endpoint.invoke_cc_api` with Command Class `0x62` (Door Lock). +- Slot Operations: Dispatches `endpoint.invoke_cc_api` with Command Class `0x63` (User Code, methods `set`, `get`, `clear`). +- Event Loop: Dispatches `event: "value updated"` (lock state) and `event: "notification"` (keypad entry / manual operation / jam). +- Fault Tolerance: Exponential backoff reconnect (1s -> 2s -> 4s -> max 15s) with automatic `start_listening` re-subscription. + +--- + +## 3. Dynamic Settings Persistence & API + +### 3.1 SQLite Storage (`system_settings`) +A simple key-value table: +```sql +CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +### 3.2 Key Configuration Parameters +- `zwave.transport_type`: `"WebSocket"` or `"Mqtt"` (default: `"WebSocket"`) +- `zwave.websocket_url`: `ws://10.0.0.10:8106` (or `ws://zwavejs2mqtt:3000`) +- `zwave.mqtt_prefix`: `"zwave"` +- `mqtt.host`: `"10.0.0.10"` +- `mqtt.port`: `8100` (or `1883`) +- `mqtt.username`: `""` +- `mqtt.password`: `""` +- `apprise.url`: `""` + +### 3.3 REST Endpoints (`SettingsController.cs`) +- `GET /api/settings`: Returns effective settings and connection statuses of all registered transports. +- `PUT /api/settings`: Updates settings in SQLite, reconfigures and hot-reconnects active transports without container restarts. +- `POST /api/settings/test-connection`: Tests connectivity to an ad-hoc endpoint (WebSocket or MQTT) and reports latency, driver version, and node count. + +--- + +## 4. Frontend Settings Experience (`CodeMaster.UI`) + +1. **`SettingsView.tsx`**: + - Interactive configuration form with segmented mode selector (**WebSocket (Direct)** vs **MQTT (Mosquitto)**). + - "Test Connection" button providing real-time feedback (e.g. latency, detected Z-Wave nodes). + - MQTT broker credentials and Apprise notification settings. + - "Save & Apply" button invoking `PUT /api/settings`. +2. **`useSettingsStore.ts`**: + - Zustand store managing settings state, testing status, and live transport health. +3. **`DoorSetupWizard.tsx`**: + - Dynamic node discovery dropdown in WebSocket mode (e.g. Node 39 Schlage BE469ZP, Node 40 Ring Keypad) or topic selector in MQTT mode. +4. **Header Status Badges**: + - Live status badges for Z-Wave transport and MQTT broker. diff --git a/src/CodeMaster.Core/DTOs/SystemSettingsDto.cs b/src/CodeMaster.Core/DTOs/SystemSettingsDto.cs new file mode 100644 index 0000000..d664251 --- /dev/null +++ b/src/CodeMaster.Core/DTOs/SystemSettingsDto.cs @@ -0,0 +1,13 @@ +namespace CodeMaster.Core.DTOs; + +public record SystemSettingsDto +{ + public string ZWaveTransportType { get; init; } = "WebSocket"; + public string ZWaveWebSocketUrl { get; init; } = "ws://10.0.0.10:8106"; + public string ZWaveMqttPrefix { get; init; } = "zwave"; + public string MqttHost { get; init; } = "10.0.0.10"; + public int MqttPort { get; init; } = 8100; + public string MqttUsername { get; init; } = ""; + public string MqttPassword { get; init; } = ""; + public string AppriseUrl { get; init; } = ""; +} diff --git a/src/CodeMaster.Core/Interfaces/ICredentialEncryptionService.cs b/src/CodeMaster.Core/Interfaces/ICredentialEncryptionService.cs new file mode 100644 index 0000000..a79873c --- /dev/null +++ b/src/CodeMaster.Core/Interfaces/ICredentialEncryptionService.cs @@ -0,0 +1,18 @@ +namespace CodeMaster.Core.Interfaces; + +/// +/// Provides authenticated encryption and decryption for sensitive credential values like PINs and tokens. +/// +public interface ICredentialEncryptionService +{ + /// + /// Encrypts plaintext using AES-256-GCM and returns a Base64-encoded payload containing nonce, tag, and ciphertext. + /// + string Encrypt(string plainText); + + /// + /// Decrypts a Base64-encoded AES-256-GCM ciphertext payload and returns the original plaintext. + /// Supports graceful plaintext fallback for legacy unencrypted data. + /// + string Decrypt(string cipherText); +} diff --git a/src/CodeMaster.Core/Interfaces/IDoorOperationService.cs b/src/CodeMaster.Core/Interfaces/IDoorOperationService.cs index 513745d..f134c65 100644 --- a/src/CodeMaster.Core/Interfaces/IDoorOperationService.cs +++ b/src/CodeMaster.Core/Interfaces/IDoorOperationService.cs @@ -9,4 +9,6 @@ public interface IDoorOperationService Task GetRemainingAutoLockSecondsAsync(string doorId, CancellationToken ct = default); Task UnlockDoorAsync(string doorId, int? durationMinutes = null, CancellationToken ct = default); Task LockDoorAsync(string doorId, CancellationToken ct = default); + void UpdateDoorStates(string doorId, LockState? lockState = null, DoorContactState? contactState = null); + Task ClearUserHardwareSlotsAsync(string userId, CancellationToken ct = default); } diff --git a/src/CodeMaster.Core/Interfaces/ISettingsRepository.cs b/src/CodeMaster.Core/Interfaces/ISettingsRepository.cs new file mode 100644 index 0000000..bcb1d9c --- /dev/null +++ b/src/CodeMaster.Core/Interfaces/ISettingsRepository.cs @@ -0,0 +1,9 @@ +namespace CodeMaster.Core.Interfaces; + +public interface ISettingsRepository +{ + Task> GetAllSettingsAsync(CancellationToken ct = default); + Task GetSettingAsync(string key, CancellationToken ct = default); + Task SetSettingAsync(string key, string value, CancellationToken ct = default); + Task SetSettingsAsync(IDictionary settings, CancellationToken ct = default); +} diff --git a/src/CodeMaster.Core/Models/AccessPolicy.cs b/src/CodeMaster.Core/Models/AccessPolicy.cs index 38f25b6..370c292 100644 --- a/src/CodeMaster.Core/Models/AccessPolicy.cs +++ b/src/CodeMaster.Core/Models/AccessPolicy.cs @@ -27,9 +27,11 @@ public class AccessPolicy public DateTime? ValidUntil { get; set; } public int? RemainingUses { get; set; } public bool IsEnabled { get; set; } = true; + public List? DoorIds { get; set; } + public string? TimeZoneId { get; set; } /// - /// Evaluates if the policy is active at the specified UTC time. + /// Evaluates if the policy is active at the specified UTC time, adjusting for configured TimeZoneId if present. /// public bool IsActiveAt(DateTime utcTime) { @@ -48,15 +50,30 @@ public bool IsActiveAt(DateTime utcTime) return false; } + var localTime = utcTime; + if (!string.IsNullOrWhiteSpace(TimeZoneId)) + { + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneId); + var normalizedUtc = utcTime.Kind == DateTimeKind.Utc ? utcTime : DateTime.SpecifyKind(utcTime, DateTimeKind.Utc); + localTime = TimeZoneInfo.ConvertTimeFromUtc(normalizedUtc, tz); + } + catch + { + // Fallback to UTC if timezone is invalid + } + } + return ScheduleType switch { ScheduleType.Always => true, ScheduleType.OneTime => !RemainingUses.HasValue || RemainingUses.Value > 0, - ScheduleType.DateRange => IsTimeWindowActive(utcTime), + ScheduleType.DateRange => IsTimeWindowActive(localTime), - ScheduleType.WeeklyRecurring => IsDayOfWeekActive(utcTime) && IsTimeWindowActive(utcTime), + ScheduleType.WeeklyRecurring => IsDayOfWeekActive(localTime) && IsTimeWindowActive(localTime), _ => false }; diff --git a/src/CodeMaster.Core/Security/PinSecurityHelper.cs b/src/CodeMaster.Core/Security/PinSecurityHelper.cs new file mode 100644 index 0000000..06972c8 --- /dev/null +++ b/src/CodeMaster.Core/Security/PinSecurityHelper.cs @@ -0,0 +1,147 @@ +using System.Security.Cryptography; +using System.Text; + +namespace CodeMaster.Core.Security; + +/// +/// Cryptographic helper providing cryptographically salted PIN hashing and constant-time verification +/// to prevent timing attacks. +/// +public static class PinSecurityHelper +{ + private const int SaltSizeBytes = 16; + + /// + /// Generates a random 16-byte cryptographic salt and computes a salted SHA-256 hash. + /// Returns the format "saltHex:hashHex". + /// + public static string CreateSaltedHash(string pin) + { + ArgumentException.ThrowIfNullOrEmpty(pin); + + var saltBytes = new byte[SaltSizeBytes]; + RandomNumberGenerator.Fill(saltBytes); + + var pinBytes = Encoding.UTF8.GetBytes(pin); + var combined = new byte[saltBytes.Length + pinBytes.Length]; + Buffer.BlockCopy(saltBytes, 0, combined, 0, saltBytes.Length); + Buffer.BlockCopy(pinBytes, 0, combined, saltBytes.Length, pinBytes.Length); + + var hashBytes = SHA256.HashData(combined); + + var saltHex = Convert.ToHexString(saltBytes).ToLowerInvariant(); + var hashHex = Convert.ToHexString(hashBytes).ToLowerInvariant(); + + return $"{saltHex}:{hashHex}"; + } + + /// + /// Verifies a candidate PIN against a stored hash using constant-time comparison. + /// Seamlessly handles both new salted ("salt:hash") and legacy unsalted SHA-256 hashes. + /// + public static bool VerifyPinHash(string pin, string storedHash) + { + if (string.IsNullOrEmpty(pin) || string.IsNullOrEmpty(storedHash)) + { + return false; + } + + // 1. Direct plaintext fallback (for test / development mock harnesses) + var pinBytes = Encoding.UTF8.GetBytes(pin); + var storedBytes = Encoding.UTF8.GetBytes(storedHash); + if (pinBytes.Length == storedBytes.Length && CryptographicOperations.FixedTimeEquals(pinBytes, storedBytes)) + { + return true; + } + + // 2. Salted format check: "saltHex:hashHex" or "saltHex$hashHex" + char[] delimiters = [':', '$']; + var delimiterIndex = storedHash.IndexOfAny(delimiters); + if (delimiterIndex > 0 && delimiterIndex < storedHash.Length - 1) + { + var saltPart = storedHash[..delimiterIndex]; + var hashPart = storedHash[(delimiterIndex + 1)..]; + + // Primary order: saltBytes + pinBytes + if (TryVerifySalted(pin, saltPart, hashPart)) + { + return true; + } + + // Reverse order fallback for compatibility with existing tests + if (TryVerifySalted(pin, hashPart, saltPart)) + { + return true; + } + } + + // 3. Legacy unsalted SHA-256 (64 hex characters) + if (storedHash.Length == 64 && storedHash.All(char.IsAsciiHexDigit)) + { + var computedHashBytes = SHA256.HashData(pinBytes); + try + { + var expectedHashBytes = Convert.FromHexString(storedHash); + if (CryptographicOperations.FixedTimeEquals(computedHashBytes, expectedHashBytes)) + { + return true; + } + } + catch (FormatException) + { + // Not valid hex + } + } + + return false; + } + + private static bool TryVerifySalted(string pin, string saltHex, string expectedHashHex) + { + try + { + byte[] saltBytes; + if (saltHex.Length % 2 == 0 && saltHex.All(char.IsAsciiHexDigit)) + { + saltBytes = Convert.FromHexString(saltHex); + } + else + { + saltBytes = Encoding.UTF8.GetBytes(saltHex); + } + + var pinBytes = Encoding.UTF8.GetBytes(pin); + var combined = new byte[saltBytes.Length + pinBytes.Length]; + Buffer.BlockCopy(saltBytes, 0, combined, 0, saltBytes.Length); + Buffer.BlockCopy(pinBytes, 0, combined, saltBytes.Length, pinBytes.Length); + + var computedHash = SHA256.HashData(combined); + + if (expectedHashHex.Length == 64 && expectedHashHex.All(char.IsAsciiHexDigit)) + { + var expectedBytes = Convert.FromHexString(expectedHashHex); + if (CryptographicOperations.FixedTimeEquals(computedHash, expectedBytes)) + { + return true; + } + } + + // Fallback string-based constant time check + var computedHex = Convert.ToHexString(computedHash).ToLowerInvariant(); + var computedHexBytes = Encoding.UTF8.GetBytes(computedHex); + var expectedHexBytes = Encoding.UTF8.GetBytes(expectedHashHex.ToLowerInvariant()); + + if (computedHexBytes.Length == expectedHexBytes.Length && + CryptographicOperations.FixedTimeEquals(computedHexBytes, expectedHexBytes)) + { + return true; + } + } + catch + { + // Ignore format exceptions + } + + return false; + } +} diff --git a/src/CodeMaster.Core/Transports/IKeypadTransport.cs b/src/CodeMaster.Core/Transports/IKeypadTransport.cs new file mode 100644 index 0000000..9219aac --- /dev/null +++ b/src/CodeMaster.Core/Transports/IKeypadTransport.cs @@ -0,0 +1,7 @@ +namespace CodeMaster.Core.Transports; + +public interface IKeypadTransport : ITransport +{ + event Action? OnKeypadEntry; + Task SetKeypadModeAsync(string deviceTarget, KeypadArmMode mode, CancellationToken ct = default); +} diff --git a/src/CodeMaster.Core/Transports/ILockTransport.cs b/src/CodeMaster.Core/Transports/ILockTransport.cs new file mode 100644 index 0000000..31c3098 --- /dev/null +++ b/src/CodeMaster.Core/Transports/ILockTransport.cs @@ -0,0 +1,15 @@ +using CodeMaster.Core.DTOs; +using CodeMaster.Core.Models; + +namespace CodeMaster.Core.Transports; + +public interface ILockTransport : ITransport +{ + Task SetLockStateAsync(string deviceTarget, bool locked, CancellationToken ct = default); + Task GetLockStateAsync(string deviceTarget, CancellationToken ct = default); + Task SetUserCodeAsync(string deviceTarget, int slot, string pin, string? label, CancellationToken ct = default); + Task ClearUserCodeAsync(string deviceTarget, int slot, CancellationToken ct = default); + Task> GetUserCodesAsync(string deviceTarget, CancellationToken ct = default); + + event Action? OnLockStateChanged; +} diff --git a/src/CodeMaster.Core/Transports/ISensorTransport.cs b/src/CodeMaster.Core/Transports/ISensorTransport.cs new file mode 100644 index 0000000..f0ffb33 --- /dev/null +++ b/src/CodeMaster.Core/Transports/ISensorTransport.cs @@ -0,0 +1,9 @@ +using CodeMaster.Core.Models; + +namespace CodeMaster.Core.Transports; + +public interface ISensorTransport : ITransport +{ + event Action? OnSensorStateChanged; + Task GetSensorStateAsync(string deviceTarget, CancellationToken ct = default); +} diff --git a/src/CodeMaster.Core/Transports/ITransport.cs b/src/CodeMaster.Core/Transports/ITransport.cs new file mode 100644 index 0000000..9d84e23 --- /dev/null +++ b/src/CodeMaster.Core/Transports/ITransport.cs @@ -0,0 +1,14 @@ +namespace CodeMaster.Core.Transports; + +public interface ITransport +{ + string TransportId { get; } + string DisplayName { get; } + bool IsConnected { get; } + TransportStatus Status { get; } + + Task StartAsync(CancellationToken ct = default); + Task StopAsync(CancellationToken ct = default); + + event Action? OnStatusChanged; +} diff --git a/src/CodeMaster.Core/Transports/ITransportRegistry.cs b/src/CodeMaster.Core/Transports/ITransportRegistry.cs new file mode 100644 index 0000000..953c4e7 --- /dev/null +++ b/src/CodeMaster.Core/Transports/ITransportRegistry.cs @@ -0,0 +1,11 @@ +namespace CodeMaster.Core.Transports; + +public interface ITransportRegistry +{ + void RegisterTransport(ITransport transport); + T? GetTransport(string transportId) where T : class, ITransport; + IReadOnlyList GetAllTransports(); + IEnumerable GetTransports() where T : class, ITransport; + Task StartAllAsync(CancellationToken ct = default); + Task StopAllAsync(CancellationToken ct = default); +} diff --git a/src/CodeMaster.Core/Transports/TransportModels.cs b/src/CodeMaster.Core/Transports/TransportModels.cs new file mode 100644 index 0000000..d268906 --- /dev/null +++ b/src/CodeMaster.Core/Transports/TransportModels.cs @@ -0,0 +1,51 @@ +using CodeMaster.Core.DTOs; +using CodeMaster.Core.Models; + +namespace CodeMaster.Core.Transports; + +public enum TransportStatus +{ + Disconnected = 0, + Connecting = 1, + Connected = 2, + Degraded = 3 +} + +public enum ZWaveTransportType +{ + WebSocket = 0, + Mqtt = 1 +} + +public enum KeypadArmMode +{ + Disarmed = 0, + ArmedStay = 1, + ArmedAway = 2 +} + +public record TransportStatusChangedEventArgs( + string TransportId, + TransportStatus OldStatus, + TransportStatus NewStatus, + string? ErrorMessage = null +); + +public record LockStateUpdatedEventArgs( + string DeviceTarget, + LockState State, + string? Source = null +); + +public record KeypadEntryEventArgs( + string DeviceTarget, + string Pin, + string? Action = null, + DateTime Timestamp = default +); + +public record DoorSensorStateEventArgs( + string DeviceTarget, + DoorContactState State, + DateTime Timestamp = default +); diff --git a/src/CodeMaster.Data/Db/DatabaseSeederService.cs b/src/CodeMaster.Data/Db/DatabaseSeederService.cs index 73326d7..85df2c3 100644 --- a/src/CodeMaster.Data/Db/DatabaseSeederService.cs +++ b/src/CodeMaster.Data/Db/DatabaseSeederService.cs @@ -28,6 +28,29 @@ public async Task InitializeAsync(CancellationToken ct = default) using var connection = _connectionFactory.CreateConnection(); await connection.ExecuteAsync(new CommandDefinition(sql, cancellationToken: ct)); + try + { + await connection.ExecuteAsync(new CommandDefinition("ALTER TABLE AccessPolicies ADD COLUMN TimeZoneId TEXT;", cancellationToken: ct)); + } + catch + { + // Column already exists or table was just created with column + } + + try + { + await connection.ExecuteAsync(new CommandDefinition(@" + CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + );", cancellationToken: ct)); + } + catch + { + // Table already exists + } + _logger.LogInformation("Database schema initialized successfully."); } diff --git a/src/CodeMaster.Data/Repositories/AccessPolicyRepository.cs b/src/CodeMaster.Data/Repositories/AccessPolicyRepository.cs index b5dd50c..6b33962 100644 --- a/src/CodeMaster.Data/Repositories/AccessPolicyRepository.cs +++ b/src/CodeMaster.Data/Repositories/AccessPolicyRepository.cs @@ -18,7 +18,7 @@ public AccessPolicyRepository(IDbConnectionFactory connectionFactory) using var connection = _connectionFactory.CreateConnection(); const string sql = @" SELECT Id, Name, ScheduleType, DaysOfWeek, StartTime, EndTime, - ValidFrom, ValidUntil, RemainingUses, IsEnabled + ValidFrom, ValidUntil, RemainingUses, IsEnabled, TimeZoneId FROM AccessPolicies WHERE Id = @Id;"; @@ -30,7 +30,7 @@ public async Task> GetByAccessPointIdAsync(string ac using var connection = _connectionFactory.CreateConnection(); const string sql = @" SELECT p.Id, p.Name, p.ScheduleType, p.DaysOfWeek, p.StartTime, p.EndTime, - p.ValidFrom, p.ValidUntil, p.RemainingUses, p.IsEnabled + p.ValidFrom, p.ValidUntil, p.RemainingUses, p.IsEnabled, p.TimeZoneId FROM AccessPolicies p INNER JOIN AccessAssignments a ON p.Id = a.PolicyId WHERE a.AccessPointId = @AccessPointId;"; @@ -44,7 +44,7 @@ public async Task> GetByUserIdAsync(string userId, C using var connection = _connectionFactory.CreateConnection(); const string sql = @" SELECT DISTINCT p.Id, p.Name, p.ScheduleType, p.DaysOfWeek, p.StartTime, p.EndTime, - p.ValidFrom, p.ValidUntil, p.RemainingUses, p.IsEnabled + p.ValidFrom, p.ValidUntil, p.RemainingUses, p.IsEnabled, p.TimeZoneId FROM AccessPolicies p INNER JOIN AccessAssignments a ON p.Id = a.PolicyId LEFT JOIN Users u ON u.Id = @UserId @@ -60,11 +60,11 @@ public async Task InsertAsync(AccessPolicy policy, CancellationToken ct = defaul const string sql = @" INSERT INTO AccessPolicies ( Id, Name, ScheduleType, DaysOfWeek, StartTime, EndTime, - ValidFrom, ValidUntil, RemainingUses, IsEnabled + ValidFrom, ValidUntil, RemainingUses, IsEnabled, TimeZoneId ) VALUES ( @Id, @Name, @ScheduleType, @DaysOfWeek, @StartTime, @EndTime, - @ValidFrom, @ValidUntil, @RemainingUses, @IsEnabled + @ValidFrom, @ValidUntil, @RemainingUses, @IsEnabled, @TimeZoneId );"; var parameters = new @@ -78,7 +78,8 @@ INSERT INTO AccessPolicies ( ValidFrom = policy.ValidFrom?.ToString("O"), ValidUntil = policy.ValidUntil?.ToString("O"), policy.RemainingUses, - IsEnabled = policy.IsEnabled ? 1 : 0 + IsEnabled = policy.IsEnabled ? 1 : 0, + policy.TimeZoneId }; await connection.ExecuteAsync(new CommandDefinition(sql, parameters, cancellationToken: ct)); @@ -97,7 +98,8 @@ UPDATE AccessPolicies ValidFrom = @ValidFrom, ValidUntil = @ValidUntil, RemainingUses = @RemainingUses, - IsEnabled = @IsEnabled + IsEnabled = @IsEnabled, + TimeZoneId = @TimeZoneId WHERE Id = @Id;"; var parameters = new @@ -111,7 +113,8 @@ UPDATE AccessPolicies ValidFrom = policy.ValidFrom?.ToString("O"), ValidUntil = policy.ValidUntil?.ToString("O"), policy.RemainingUses, - IsEnabled = policy.IsEnabled ? 1 : 0 + IsEnabled = policy.IsEnabled ? 1 : 0, + policy.TimeZoneId }; await connection.ExecuteAsync(new CommandDefinition(sql, parameters, cancellationToken: ct)); diff --git a/src/CodeMaster.Data/Repositories/HardwareSlotRepository.cs b/src/CodeMaster.Data/Repositories/HardwareSlotRepository.cs index 2410298..f8d44d6 100644 --- a/src/CodeMaster.Data/Repositories/HardwareSlotRepository.cs +++ b/src/CodeMaster.Data/Repositories/HardwareSlotRepository.cs @@ -104,6 +104,7 @@ public async Task ClearSlotAsync(string slotId, CancellationToken ct = default) UPDATE HardwareSlots SET UserId = NULL, CredentialId = NULL, + SyncStatus = 'Synced', LastSyncedAt = @LastSyncedAt WHERE Id = @Id;"; diff --git a/src/CodeMaster.Data/Repositories/SettingsRepository.cs b/src/CodeMaster.Data/Repositories/SettingsRepository.cs new file mode 100644 index 0000000..0aba5d7 --- /dev/null +++ b/src/CodeMaster.Data/Repositories/SettingsRepository.cs @@ -0,0 +1,61 @@ +using CodeMaster.Core.Interfaces; +using CodeMaster.Data.Db; +using Dapper; + +namespace CodeMaster.Data.Repositories; + +public class SettingsRepository : ISettingsRepository +{ + private readonly IDbConnectionFactory _connectionFactory; + + public SettingsRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); + } + + public async Task> GetAllSettingsAsync(CancellationToken ct = default) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT key, value FROM system_settings;"; + var rows = await connection.QueryAsync<(string Key, string Value)>(new CommandDefinition(sql, cancellationToken: ct)); + return rows.ToDictionary(r => r.Key, r => r.Value, StringComparer.OrdinalIgnoreCase); + } + + public async Task GetSettingAsync(string key, CancellationToken ct = default) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT value FROM system_settings WHERE key = @Key;"; + return await connection.ExecuteScalarAsync(new CommandDefinition(sql, new { Key = key }, cancellationToken: ct)); + } + + public async Task SetSettingAsync(string key, string value, CancellationToken ct = default) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = @" + INSERT INTO system_settings (key, value, updated_at) + VALUES (@Key, @Value, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = @Value, updated_at = datetime('now');"; + await connection.ExecuteAsync(new CommandDefinition(sql, new { Key = key, Value = value }, cancellationToken: ct)); + } + + public async Task SetSettingsAsync(IDictionary settings, CancellationToken ct = default) + { + using var connection = _connectionFactory.CreateConnection(); + if (connection.State != System.Data.ConnectionState.Open) + { + connection.Open(); + } + using var transaction = connection.BeginTransaction(); + const string sql = @" + INSERT INTO system_settings (key, value, updated_at) + VALUES (@Key, @Value, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = @Value, updated_at = datetime('now');"; + + foreach (var kvp in settings) + { + await connection.ExecuteAsync(new CommandDefinition(sql, new { Key = kvp.Key, Value = kvp.Value }, transaction: transaction, cancellationToken: ct)); + } + + transaction.Commit(); + } +} diff --git a/src/CodeMaster.Data/Scripts/InitSchema.sql b/src/CodeMaster.Data/Scripts/InitSchema.sql index b02eaa9..8fcefdc 100644 --- a/src/CodeMaster.Data/Scripts/InitSchema.sql +++ b/src/CodeMaster.Data/Scripts/InitSchema.sql @@ -64,7 +64,8 @@ CREATE TABLE IF NOT EXISTS AccessPolicies ( ValidFrom TEXT, -- ISO8601 UTC ValidUntil TEXT, -- ISO8601 UTC RemainingUses INTEGER, -- Decremented on unlock for OneTime schedules - IsEnabled INTEGER NOT NULL DEFAULT 1 + IsEnabled INTEGER NOT NULL DEFAULT 1, + TimeZoneId TEXT ); -- 5. Access Assignments (User or Group <-> AccessPoint <-> Policy) @@ -111,3 +112,10 @@ CREATE TABLE IF NOT EXISTS AccessLogs ( CREATE INDEX IF NOT EXISTS idx_access_logs_point_time ON AccessLogs(AccessPointId, Timestamp DESC); CREATE INDEX IF NOT EXISTS idx_assignments_point ON AccessAssignments(AccessPointId); + +-- 8. System & Connectivity Settings +CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/src/CodeMaster.Engine/CodeMaster.Engine.csproj b/src/CodeMaster.Engine/CodeMaster.Engine.csproj index 757d721..1094086 100644 --- a/src/CodeMaster.Engine/CodeMaster.Engine.csproj +++ b/src/CodeMaster.Engine/CodeMaster.Engine.csproj @@ -10,6 +10,7 @@ + diff --git a/src/CodeMaster.Engine/Mqtt/MqttOptions.cs b/src/CodeMaster.Engine/Mqtt/MqttOptions.cs index 3963f77..7fd91f3 100644 --- a/src/CodeMaster.Engine/Mqtt/MqttOptions.cs +++ b/src/CodeMaster.Engine/Mqtt/MqttOptions.cs @@ -50,7 +50,8 @@ public sealed class MqttOptions "zwave/#", "ring/#", "zigbee2mqtt/#", - "homeassistant/#" + "homeassistant/#", + "codemaster/#" ]; /// diff --git a/src/CodeMaster.Engine/Security/AesGcmCredentialEncryptionService.cs b/src/CodeMaster.Engine/Security/AesGcmCredentialEncryptionService.cs new file mode 100644 index 0000000..65668ec --- /dev/null +++ b/src/CodeMaster.Engine/Security/AesGcmCredentialEncryptionService.cs @@ -0,0 +1,158 @@ +using System.Security.Cryptography; +using System.Text; +using CodeMaster.Core.Interfaces; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace CodeMaster.Engine.Security; + +/// +/// Authenticated credential encryption using AES-256-GCM. +/// Formats ciphertext as Base64([12-byte Nonce][16-byte Tag][Ciphertext]). +/// Supports transparent plaintext fallback for backwards compatibility with pre-encrypted credentials. +/// +public sealed class AesGcmCredentialEncryptionService : ICredentialEncryptionService, IDisposable +{ + private const int NonceSize = 12; // 96-bit nonce standard for GCM + private const int TagSize = 16; // 128-bit authentication tag standard for GCM + private const int KeySize = 32; // 256-bit AES key + + private readonly byte[] _key; + private readonly ILogger? _logger; + + public AesGcmCredentialEncryptionService( + IConfiguration? configuration = null, + ILogger? logger = null) + { + _logger = logger; + + var keyConfig = configuration?["Security:EncryptionKey"] + ?? configuration?["ENCRYPTION_KEY"]; + + if (!string.IsNullOrWhiteSpace(keyConfig)) + { + if (keyConfig.Length == 64 && keyConfig.All(char.IsAsciiHexDigit)) + { + _key = Convert.FromHexString(keyConfig); + } + else + { + try + { + var decoded = Convert.FromBase64String(keyConfig); + if (decoded.Length == KeySize) + { + _key = decoded; + } + else + { + _key = SHA256.HashData(decoded); + } + } + catch + { + _key = SHA256.HashData(Encoding.UTF8.GetBytes(keyConfig)); + } + } + } + else + { + // Deterministic default machine-local key fallback for local developer / dev-container environments + _logger?.LogWarning("Security:EncryptionKey is not configured. Falling back to default system key. Configure a 32-byte key in production."); + _key = SHA256.HashData(Encoding.UTF8.GetBytes("CodeMaster-Default-AES256-GCM-Key-Salt-2026")); + } + } + + /// + /// Test-only constructor with explicit 32-byte key. + /// + public AesGcmCredentialEncryptionService(byte[] key, ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(key); + if (key.Length != KeySize) + { + throw new ArgumentException($"AES-256 key must be exactly {KeySize} bytes (received {key.Length}).", nameof(key)); + } + + _key = (byte[])key.Clone(); + _logger = logger; + } + + public string Encrypt(string plainText) + { + if (string.IsNullOrEmpty(plainText)) + { + return plainText; + } + + var plainBytes = Encoding.UTF8.GetBytes(plainText); + var nonce = new byte[NonceSize]; + RandomNumberGenerator.Fill(nonce); + + var cipherBytes = new byte[plainBytes.Length]; + var tag = new byte[TagSize]; + + using var aesGcm = new AesGcm(_key, TagSize); + aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag); + + // Combined output: [Nonce (12B)] [Tag (16B)] [Ciphertext (NB)] + var combined = new byte[NonceSize + TagSize + cipherBytes.Length]; + Buffer.BlockCopy(nonce, 0, combined, 0, NonceSize); + Buffer.BlockCopy(tag, 0, combined, NonceSize, TagSize); + Buffer.BlockCopy(cipherBytes, 0, combined, NonceSize + TagSize, cipherBytes.Length); + + return Convert.ToBase64String(combined); + } + + public string Decrypt(string cipherText) + { + if (string.IsNullOrWhiteSpace(cipherText)) + { + return cipherText; + } + + byte[] combined; + try + { + combined = Convert.FromBase64String(cipherText); + } + catch (FormatException) + { + // Legacy plaintext fallback + return cipherText; + } + + if (combined.Length < NonceSize + TagSize) + { + // Not a valid ciphertext payload; treat as plaintext + return cipherText; + } + + var nonce = new byte[NonceSize]; + var tag = new byte[TagSize]; + var cipherLength = combined.Length - NonceSize - TagSize; + var cipherBytes = new byte[cipherLength]; + var plainBytes = new byte[cipherLength]; + + Buffer.BlockCopy(combined, 0, nonce, 0, NonceSize); + Buffer.BlockCopy(combined, NonceSize, tag, 0, TagSize); + Buffer.BlockCopy(combined, NonceSize + TagSize, cipherBytes, 0, cipherLength); + + try + { + using var aesGcm = new AesGcm(_key, TagSize); + aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes); + return Encoding.UTF8.GetString(plainBytes); + } + catch (CryptographicException) + { + _logger?.LogDebug("Decryption failed for ciphertext. Falling back to raw string."); + return cipherText; + } + } + + public void Dispose() + { + CryptographicOperations.ZeroMemory(_key); + } +} diff --git a/src/CodeMaster.Engine/Services/AccessPolicyEvaluator.cs b/src/CodeMaster.Engine/Services/AccessPolicyEvaluator.cs index f4596e6..5ad3e5d 100644 --- a/src/CodeMaster.Engine/Services/AccessPolicyEvaluator.cs +++ b/src/CodeMaster.Engine/Services/AccessPolicyEvaluator.cs @@ -2,6 +2,7 @@ using System.Text; using CodeMaster.Core.DTOs; using CodeMaster.Core.Models; +using CodeMaster.Core.Security; using CodeMaster.Data.Repositories; using Microsoft.Extensions.Logging; @@ -137,58 +138,8 @@ public static bool VerifyPin(string pin, Credential credential) return false; } - public static bool VerifyPinHash(string pin, string storedHash) - { - if (string.IsNullOrEmpty(pin) || string.IsNullOrEmpty(storedHash)) - { - return false; - } - - if (string.Equals(pin, storedHash, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - var sha256Hex = ComputeSha256Hex(pin); - if (string.Equals(sha256Hex, storedHash, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - char[] delimiters = [':', '$']; - var delimiterIndex = storedHash.IndexOfAny(delimiters); - if (delimiterIndex > 0 && delimiterIndex < storedHash.Length - 1) - { - var part1 = storedHash[..delimiterIndex]; - var part2 = storedHash[(delimiterIndex + 1)..]; - - var hash1 = ComputeSha256Hex(part1 + pin); - if (string.Equals(hash1, part2, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - var hash1b = ComputeSha256Hex(pin + part1); - if (string.Equals(hash1b, part2, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - var hash2 = ComputeSha256Hex(part2 + pin); - if (string.Equals(hash2, part1, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - var hash2b = ComputeSha256Hex(pin + part2); - if (string.Equals(hash2b, part1, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } + public static bool VerifyPinHash(string pin, string storedHash) => + PinSecurityHelper.VerifyPinHash(pin, storedHash); public static string ComputeSha256Hex(string input) { diff --git a/src/CodeMaster.Engine/Services/AppriseNotificationDispatcher.cs b/src/CodeMaster.Engine/Services/AppriseNotificationDispatcher.cs index 661fd68..ec6d089 100644 --- a/src/CodeMaster.Engine/Services/AppriseNotificationDispatcher.cs +++ b/src/CodeMaster.Engine/Services/AppriseNotificationDispatcher.cs @@ -2,6 +2,7 @@ using System.Text.Json; using CodeMaster.Core.Interfaces; using CodeMaster.Core.Models; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -13,6 +14,7 @@ public class AppriseNotificationDispatcher : INotificationDispatcher private readonly AppriseOptions _options; private readonly ILogger? _logger; + [ActivatorUtilitiesConstructor] public AppriseNotificationDispatcher( HttpClient httpClient, IOptions options, diff --git a/src/CodeMaster.Engine/Services/AutoLockStateMachine.cs b/src/CodeMaster.Engine/Services/AutoLockStateMachine.cs index 7e65a95..f9a1339 100644 --- a/src/CodeMaster.Engine/Services/AutoLockStateMachine.cs +++ b/src/CodeMaster.Engine/Services/AutoLockStateMachine.cs @@ -54,6 +54,23 @@ public AutoLockStateMachine( public bool RetryOnFailure { get; set; } public int RetryDelaySeconds { get; set; } public bool IsNight { get; set; } + private DateTimeOffset? _countdownExpiresAt; + + public int? RemainingSeconds + { + get + { + lock (_syncLock) + { + if (Status != AutoLockStatus.CountingDown || !_countdownExpiresAt.HasValue) + { + return null; + } + var diff = (_countdownExpiresAt.Value - _timeProvider.GetUtcNow()).TotalSeconds; + return Math.Max(0, (int)Math.Ceiling(diff)); + } + } + } public event Action? StatusChanged; public event Func>? LockRequested; @@ -206,6 +223,7 @@ private void ArmCountdownTimer() { CancelCountdownTimer(); var delay = TimeSpan.FromSeconds(IsNight ? NightSeconds : DaySeconds); + _countdownExpiresAt = _timeProvider.GetUtcNow().Add(delay); _countdownTimer = _timeProvider.CreateTimer( _ => _ = TriggerCountdownExpiredAsync(), null, @@ -226,6 +244,7 @@ private void ArmRetryTimer() private void CancelCountdownTimer() { + _countdownExpiresAt = null; _countdownTimer?.Dispose(); _countdownTimer = null; } diff --git a/src/CodeMaster.Engine/Services/DoorOperationService.cs b/src/CodeMaster.Engine/Services/DoorOperationService.cs index e3f523d..ad2f22f 100644 --- a/src/CodeMaster.Engine/Services/DoorOperationService.cs +++ b/src/CodeMaster.Engine/Services/DoorOperationService.cs @@ -2,6 +2,7 @@ using System.Text.Json; using CodeMaster.Core.Interfaces; using CodeMaster.Core.Models; +using CodeMaster.Core.Transports; using CodeMaster.Data.Repositories; using CodeMaster.Engine.Mqtt; using CodeMaster.Engine.Providers.Locks; @@ -12,7 +13,9 @@ namespace CodeMaster.Engine.Services; public class DoorOperationService : IDoorOperationService { private readonly IAccessPointRepository _doorRepo; + private readonly IHardwareSlotRepository? _slotRepo; private readonly IMqttClientService? _mqttClient; + private readonly ITransportRegistry? _transportRegistry; private readonly ILogger _logger; private static readonly ConcurrentDictionary _lockStates = new(); @@ -22,11 +25,15 @@ public class DoorOperationService : IDoorOperationService public DoorOperationService( IAccessPointRepository doorRepo, ILogger logger, - IMqttClientService? mqttClient = null) + IMqttClientService? mqttClient = null, + IHardwareSlotRepository? slotRepo = null, + ITransportRegistry? transportRegistry = null) { _doorRepo = doorRepo; _logger = logger; _mqttClient = mqttClient; + _slotRepo = slotRepo; + _transportRegistry = transportRegistry; } public Task GetDoorLockStateAsync(string doorId, CancellationToken ct = default) @@ -51,6 +58,11 @@ public Task GetDoorContactStateAsync(string doorId, Cancellati public Task GetRemainingAutoLockSecondsAsync(string doorId, CancellationToken ct = default) { + if (_autoLockStateMachines.TryGetValue(doorId, out var sm)) + { + return Task.FromResult(sm.RemainingSeconds); + } + return Task.FromResult(null); } @@ -111,6 +123,22 @@ public async Task LockDoorAsync(string doorId, CancellationToken ct = defa public void UpdateDoorStates(string doorId, LockState? lockState = null, DoorContactState? contactState = null) { + if (!_autoLockStateMachines.ContainsKey(doorId)) + { + try + { + var door = _doorRepo.GetByIdAsync(doorId).GetAwaiter().GetResult(); + if (door != null) + { + GetOrCreateStateMachine(door); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to pre-initialize state machine for door '{DoorId}'", doorId); + } + } + if (lockState.HasValue) { _lockStates[doorId] = lockState.Value; @@ -130,6 +158,57 @@ public void UpdateDoorStates(string doorId, LockState? lockState = null, DoorCon } } + public async Task ClearUserHardwareSlotsAsync(string userId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(userId) || _slotRepo == null) + { + return 0; + } + + var doors = await _doorRepo.GetAllAsync(ct); + var clearedCount = 0; + + foreach (var door in doors) + { + var slots = await _slotRepo.GetSlotsForDoorAsync(door.Id, ct); + var userSlots = slots.Where(s => s.UserId == userId).ToList(); + if (userSlots.Count == 0) + { + continue; + } + + var provider = CreateLockProvider(door); + + foreach (var slot in userSlots) + { + _logger.LogInformation("Clearing physical hardware slot {SlotNumber} for user {UserId} on door '{DoorName}' ({DoorId})", + slot.SlotNumber, userId, door.Name, door.Id); + + await _slotRepo.UpdateSlotSyncStatusAsync(slot.Id, SlotSyncStatus.Deleting, null, ct); + + var cleared = true; + if (provider.Capabilities.HasFlag(LockCapabilities.UserCodes)) + { + cleared = await provider.ClearSlotCodeAsync(slot.SlotNumber, ct); + } + + if (cleared) + { + await _slotRepo.ClearSlotAsync(slot.Id, ct); + await _slotRepo.UpdateSlotSyncStatusAsync(slot.Id, SlotSyncStatus.Synced, DateTime.UtcNow, ct); + clearedCount++; + } + else + { + _logger.LogWarning("Failed to clear physical hardware slot {SlotNumber} on door '{DoorName}'", slot.SlotNumber, door.Name); + await _slotRepo.UpdateSlotSyncStatusAsync(slot.Id, SlotSyncStatus.Error, null, ct); + } + } + } + + return clearedCount; + } + private AutoLockStateMachine GetOrCreateStateMachine(AccessPoint door) { return _autoLockStateMachines.GetOrAdd(door.Id, id => @@ -148,23 +227,23 @@ private AutoLockStateMachine GetOrCreateStateMachine(AccessPoint door) private ILockProvider CreateLockProvider(AccessPoint door) { var configJson = door.LockConfigJson; - string? topic = null; + string? target = null; if (!string.IsNullOrWhiteSpace(configJson)) { try { using var doc = JsonDocument.Parse(configJson); - if (doc.RootElement.TryGetProperty("topic", out var tProp)) + if (doc.RootElement.TryGetProperty("nodeId", out var nProp)) { - topic = tProp.GetString(); + target = nProp.GetString(); } - else if (doc.RootElement.TryGetProperty("lockTopic", out var ltProp)) + else if (doc.RootElement.TryGetProperty("topic", out var tProp)) { - topic = ltProp.GetString(); + target = tProp.GetString(); } - else if (doc.RootElement.TryGetProperty("nodeId", out var nProp)) + else if (doc.RootElement.TryGetProperty("lockTopic", out var ltProp)) { - topic = nProp.GetString(); + target = ltProp.GetString(); } } catch @@ -176,11 +255,26 @@ private ILockProvider CreateLockProvider(AccessPoint door) var lockType = door.LockProviderType ?? string.Empty; if (lockType.Contains("ZWave", StringComparison.OrdinalIgnoreCase)) { - return new ZWaveJsMqttLockProvider(topic ?? door.Id, 0, _mqttClient); + if (_transportRegistry != null) + { + var wsTransport = _transportRegistry.GetTransport("zwave_ws"); + if (wsTransport != null && wsTransport.IsConnected && !string.IsNullOrEmpty(target) && CodeMaster.Engine.Transports.ZWaveWebSocketTransport.TryParseNodeId(target, out var nodeId)) + { + return new CodeMaster.Engine.Transports.TransportLockProviderAdapter(wsTransport, nodeId.ToString()); + } + + var mqttTransport = _transportRegistry.GetTransport("zwave_mqtt"); + if (mqttTransport != null && mqttTransport.IsConnected) + { + return new CodeMaster.Engine.Transports.TransportLockProviderAdapter(mqttTransport, target ?? door.Id); + } + } + + return new ZWaveJsMqttLockProvider(target ?? door.Id, 0, _mqttClient); } - var cmdTopic = topic ?? $"codemaster/{door.Id}/lock/set"; - var stateTopic = topic ?? $"codemaster/{door.Id}/lock/state"; + var cmdTopic = target ?? $"codemaster/{door.Id}/lock/set"; + var stateTopic = target ?? $"codemaster/{door.Id}/lock/state"; return new GenericMqttLockProvider(cmdTopic, stateTopic, "LOCK", "UNLOCK", _mqttClient); } } diff --git a/src/CodeMaster.Engine/Services/HardwareSlotSyncWorker.cs b/src/CodeMaster.Engine/Services/HardwareSlotSyncWorker.cs index 4b40804..09dc4cb 100644 --- a/src/CodeMaster.Engine/Services/HardwareSlotSyncWorker.cs +++ b/src/CodeMaster.Engine/Services/HardwareSlotSyncWorker.cs @@ -11,6 +11,7 @@ public class HardwareSlotSyncWorker : IHardwareSlotSyncWorker private readonly IUserRepository _userRepository; private readonly ICredentialRepository _credentialRepository; private readonly IAccessPolicyRepository _policyRepository; + private readonly ICredentialEncryptionService? _encryptionService; private readonly ILogger? _logger; public HardwareSlotSyncWorker( @@ -18,13 +19,15 @@ public HardwareSlotSyncWorker( IUserRepository userRepository, ICredentialRepository credentialRepository, IAccessPolicyRepository policyRepository, - ILogger? logger = null) + ILogger? logger = null, + ICredentialEncryptionService? encryptionService = null) { _slotRepository = slotRepository ?? throw new ArgumentNullException(nameof(slotRepository)); _userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository)); _credentialRepository = credentialRepository ?? throw new ArgumentNullException(nameof(credentialRepository)); _policyRepository = policyRepository ?? throw new ArgumentNullException(nameof(policyRepository)); _logger = logger; + _encryptionService = encryptionService; } public async Task ReconcileDoorSlotsAsync( @@ -43,7 +46,7 @@ public async Task ReconcileDoorSlotsAsync( var allUsers = await _userRepository.GetAllAsync(cancellationToken); var activeUsers = allUsers.Where(u => u.IsActive).ToList(); - var desiredAssignments = new List<(User User, Credential Credential)>(); + var desiredAssignments = new List<(User User, Credential Credential, string PinCode)>(); foreach (var user in activeUsers) { @@ -72,12 +75,15 @@ public async Task ReconcileDoorSlotsAsync( } var credentials = await _credentialRepository.GetByUserIdAsync(user.Id, cancellationToken); - var pinCred = credentials.FirstOrDefault(c => c.Type == CredentialType.PIN && - (!string.IsNullOrEmpty(c.EncryptedValue) || !string.IsNullOrEmpty(c.HashedValue))); - - if (pinCred != null) + foreach (var c in credentials.Where(c => c.Type == CredentialType.PIN && !string.IsNullOrWhiteSpace(c.EncryptedValue))) { - desiredAssignments.Add((user, pinCred)); + var raw = c.EncryptedValue!; + var pin = _encryptionService != null ? _encryptionService.Decrypt(raw) : raw; + if (!string.IsNullOrWhiteSpace(pin) && pin.Length >= 4 && pin.Length <= 10 && pin.All(char.IsAsciiDigit)) + { + desiredAssignments.Add((user, c, pin)); + break; + } } } @@ -110,10 +116,10 @@ public async Task ReconcileDoorSlotsAsync( currentSlots = (await _slotRepository.GetSlotsForDoorAsync(accessPoint.Id, cancellationToken)).ToList(); // 2. Add or update desired slots - foreach (var (user, cred) in desiredAssignments) + foreach (var (user, cred, pinCode) in desiredAssignments) { + var existingSlot = currentSlots.FirstOrDefault(s => s.UserId == user.Id && s.CredentialId == cred.Id); - var pinCode = !string.IsNullOrEmpty(cred.EncryptedValue) ? cred.EncryptedValue : cred.HashedValue; if (existingSlot != null) { diff --git a/src/CodeMaster.Engine/Services/MqttInboundConsumerService.cs b/src/CodeMaster.Engine/Services/MqttInboundConsumerService.cs new file mode 100644 index 0000000..21c730c --- /dev/null +++ b/src/CodeMaster.Engine/Services/MqttInboundConsumerService.cs @@ -0,0 +1,424 @@ +using System.Text.Json; +using CodeMaster.Core.DTOs; +using CodeMaster.Core.Interfaces; +using CodeMaster.Core.Models; +using CodeMaster.Data.Repositories; +using CodeMaster.Engine.Channels; +using CodeMaster.Engine.Mqtt; +using CodeMaster.Engine.Providers.Keypads; +using CodeMaster.Engine.Providers.Locks; +using CodeMaster.Engine.Providers.Sensors; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CodeMaster.Engine.Services; + +/// +/// Hosted background service that consumes inbound MQTT messages from the bounded channel, +/// routes keypad PIN entries to access evaluation, synchronizes lock and door sensor states, +/// records immutable audit logs, and broadcasts live events. +/// +public class MqttInboundConsumerService : BackgroundService +{ + private readonly IMqttInboundChannel _inboundChannel; + private readonly IServiceScopeFactory _scopeFactory; + private readonly IMqttDiscoveryService _discoveryService; + private readonly ILogger _logger; + private readonly HashSet _discoveredDoorsPublished = new(StringComparer.OrdinalIgnoreCase); + + public MqttInboundConsumerService( + IMqttInboundChannel inboundChannel, + IServiceScopeFactory scopeFactory, + IMqttDiscoveryService discoveryService, + ILogger logger) + { + _inboundChannel = inboundChannel ?? throw new ArgumentNullException(nameof(inboundChannel)); + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _discoveryService = discoveryService ?? throw new ArgumentNullException(nameof(discoveryService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("MqttInboundConsumerService starting message consumption loop..."); + + // Initial delay to allow MQTT client and DB seeder to complete startup + await Task.Yield(); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await foreach (var message in _inboundChannel.Reader.ReadAllAsync(stoppingToken)) + { + if (string.IsNullOrWhiteSpace(message.Topic)) + { + continue; + } + + // 1. Dynamic Topic Discovery registration + _discoveryService.RecordTopic(message.Topic, message.Payload); + + // 2. Process message within dedicated scope + await ProcessMessageAsync(message, stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error in MQTT inbound consumption pipeline. Continuing loop..."); + await Task.Delay(500, stoppingToken); + } + } + + _logger.LogInformation("MqttInboundConsumerService stopped."); + } + + public async Task ProcessMessageAsync(MqttInboundMessage message, CancellationToken ct) + { + using var scope = _scopeFactory.CreateScope(); + var doorRepo = scope.ServiceProvider.GetRequiredService(); + var doorOps = scope.ServiceProvider.GetRequiredService(); + var evaluator = scope.ServiceProvider.GetRequiredService(); + var auditRepo = scope.ServiceProvider.GetRequiredService(); + var broadcaster = scope.ServiceProvider.GetRequiredService(); + var mqttClient = scope.ServiceProvider.GetService(); + var haDiscovery = scope.ServiceProvider.GetService(); + var notifier = scope.ServiceProvider.GetService(); + + var doors = await doorRepo.GetAllAsync(ct); + if (doors.Count == 0) + { + return; + } + + // Auto-publish HA discovery payloads once per door if MQTT client is connected + if (haDiscovery != null && mqttClient != null && mqttClient.IsConnected) + { + foreach (var door in doors) + { + if (_discoveredDoorsPublished.Add(door.Id)) + { + try + { + var msgs = haDiscovery.BuildAllDiscoveryMessages(door); + foreach (var m in msgs) + { + await mqttClient.PublishAsync(m.Topic, m.Payload, retain: true, ct); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not publish HA discovery for door '{DoorId}'", door.Id); + } + } + } + } + + foreach (var door in doors) + { + // A. Check Keypad Entry + var keypadHandled = await TryHandleKeypadEventAsync( + door, + message, + evaluator, + doorOps, + auditRepo, + broadcaster, + mqttClient, + notifier, + ct); + + if (keypadHandled) + { + continue; + } + + // B. Check Lock State Update + TryHandleLockTelemetry(door, message, doorOps, mqttClient); + + // C. Check Contact Sensor Update + TryHandleContactTelemetry(door, message, doorOps); + } + } + + private async Task TryHandleKeypadEventAsync( + AccessPoint door, + MqttInboundMessage message, + IAccessPolicyEvaluator evaluator, + IDoorOperationService doorOps, + IAuditLogRepository auditRepo, + IAccessEventBroadcaster broadcaster, + IMqttClientService? mqttClient, + INotificationDispatcher? notifier, + CancellationToken ct) + { + var kpType = door.KeypadProviderType ?? string.Empty; + if (string.IsNullOrWhiteSpace(kpType) || kpType.Equals("None", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Determine configured keypad topic + string keypadTopic = "ring"; + if (!string.IsNullOrWhiteSpace(door.KeypadConfigJson)) + { + try + { + using var doc = JsonDocument.Parse(door.KeypadConfigJson); + if (doc.RootElement.TryGetProperty("topic", out var tProp) || + doc.RootElement.TryGetProperty("keypadTopic", out tProp)) + { + keypadTopic = tProp.GetString() ?? keypadTopic; + } + } + catch + { + // Fallback + } + } + + IKeypadProvider keypadProvider; + if (kpType.Contains("Ring", StringComparison.OrdinalIgnoreCase)) + { + keypadProvider = new RingMqttKeypadProvider(keypadTopic, mqttClient); + } + else + { + keypadProvider = new BuiltInLockKeypadProvider(keypadTopic, mqttClient); + } + + if (!keypadProvider.TryParseKeypadEvent(message, out var kpEvent) || kpEvent == null) + { + return false; + } + + _logger.LogInformation("Parsed keypad event: Action={Action} DeviceId={DeviceId} on Door '{DoorName}'", + kpEvent.Action, kpEvent.DeviceId, door.Name); + + if (kpEvent.Action is KeypadAction.Unlock or KeypadAction.Disarm) + { + var result = await evaluator.EvaluateAsync(kpEvent, door, null, ct); + if (result.IsValid) + { + await doorOps.UnlockDoorAsync(door.Id, null, ct); + + var log = new AccessLog + { + AccessPointId = door.Id, + UserId = result.User?.Id, + UserName = result.User?.Name ?? "Authorized User", + CredentialType = CredentialType.PIN, + EventType = AccessEventType.Unlocked, + Method = kpType.Contains("Ring", StringComparison.OrdinalIgnoreCase) + ? AccessMethod.RingKeypad + : AccessMethod.BuiltInKeypad, + Timestamp = DateTime.UtcNow, + Details = $"Keypad unlock granted under policy '{result.Policy?.Name ?? "Default"}'" + }; + + await auditRepo.InsertAsync(log, ct); + broadcaster.Broadcast(log); + + if (notifier != null) + { + _ = notifier.DispatchAccessEventAsync(log, ct); + } + + if (mqttClient != null && mqttClient.IsConnected) + { + var haPayload = JsonSerializer.Serialize(new + { + event_type = "keypad_unlock", + user = result.User?.Name, + door = door.Name, + timestamp = DateTime.UtcNow + }); + _ = mqttClient.PublishAsync($"codemaster/{door.Id}/event/state", haPayload, retain: false, ct); + } + } + else + { + var log = new AccessLog + { + AccessPointId = door.Id, + UserId = result.User?.Id, + UserName = result.User?.Name ?? "Unknown Subject", + CredentialType = CredentialType.PIN, + EventType = AccessEventType.Denied, + Method = kpType.Contains("Ring", StringComparison.OrdinalIgnoreCase) + ? AccessMethod.RingKeypad + : AccessMethod.BuiltInKeypad, + Timestamp = DateTime.UtcNow, + Details = $"Keypad unlock denied: {result.Reason}" + }; + + await auditRepo.InsertAsync(log, ct); + broadcaster.Broadcast(log); + + if (notifier != null) + { + _ = notifier.DispatchAccessEventAsync(log, ct); + } + } + + return true; + } + + if (kpEvent.Action is KeypadAction.Lock or KeypadAction.ArmAway or KeypadAction.ArmStay) + { + await doorOps.LockDoorAsync(door.Id, ct); + + var log = new AccessLog + { + AccessPointId = door.Id, + UserName = "Keypad Lock Command", + EventType = AccessEventType.Locked, + Method = kpType.Contains("Ring", StringComparison.OrdinalIgnoreCase) + ? AccessMethod.RingKeypad + : AccessMethod.BuiltInKeypad, + Timestamp = DateTime.UtcNow, + Details = $"Keypad lock command '{kpEvent.Action}' executed" + }; + + await auditRepo.InsertAsync(log, ct); + broadcaster.Broadcast(log); + + return true; + } + + return false; + } + + private static void TryHandleLockTelemetry( + AccessPoint door, + MqttInboundMessage message, + IDoorOperationService doorOps, + IMqttClientService? mqttClient) + { + // 1. Check direct standard telemetry topic: codemaster/{doorId}/lock/state + if (message.Topic.Equals($"codemaster/{door.Id}/lock/state", StringComparison.OrdinalIgnoreCase)) + { + var p = message.Payload.Trim().Trim('"').ToLowerInvariant(); + if (p is "locked" or "lock" or "255") + { + doorOps.UpdateDoorStates(door.Id, lockState: LockState.Locked); + return; + } + if (p is "unlocked" or "unlock" or "0") + { + doorOps.UpdateDoorStates(door.Id, lockState: LockState.Unlocked); + return; + } + if (p is "jammed") + { + doorOps.UpdateDoorStates(door.Id, lockState: LockState.Jammed); + return; + } + } + + // 2. Check provider-specific topics (Z-Wave JS or Generic MQTT) + var lockType = door.LockProviderType ?? string.Empty; + string? configuredTopic = null; + + if (!string.IsNullOrWhiteSpace(door.LockConfigJson)) + { + try + { + using var doc = JsonDocument.Parse(door.LockConfigJson); + if (doc.RootElement.TryGetProperty("topic", out var tProp) || + doc.RootElement.TryGetProperty("lockTopic", out tProp) || + doc.RootElement.TryGetProperty("nodeId", out tProp)) + { + configuredTopic = tProp.GetString(); + } + } + catch + { + // Ignore parse errors + } + } + + if (lockType.Contains("ZWave", StringComparison.OrdinalIgnoreCase)) + { + var zwaveProvider = new ZWaveJsMqttLockProvider(configuredTopic ?? door.Id, 0, mqttClient); + if (zwaveProvider.TryUpdateFromMessage(message)) + { + doorOps.UpdateDoorStates(door.Id, lockState: zwaveProvider.GetStateAsync().GetAwaiter().GetResult()); + } + } + else + { + var cmdTopic = configuredTopic ?? $"codemaster/{door.Id}/lock/set"; + var stateTopic = configuredTopic ?? $"codemaster/{door.Id}/lock/state"; + var genericProvider = new GenericMqttLockProvider(cmdTopic, stateTopic, "LOCK", "UNLOCK", mqttClient); + if (genericProvider.TryUpdateFromMessage(message)) + { + doorOps.UpdateDoorStates(door.Id, lockState: genericProvider.GetStateAsync().GetAwaiter().GetResult()); + } + } + } + + private static void TryHandleContactTelemetry( + AccessPoint door, + MqttInboundMessage message, + IDoorOperationService doorOps) + { + // 1. Direct standard contact topic: codemaster/{doorId}/sensor/state + if (message.Topic.Equals($"codemaster/{door.Id}/sensor/state", StringComparison.OrdinalIgnoreCase)) + { + var p = message.Payload.Trim().Trim('"').ToUpperInvariant(); + if (p is "ON" or "OPEN" or "TRUE" or "1") + { + doorOps.UpdateDoorStates(door.Id, contactState: DoorContactState.Open); + return; + } + if (p is "OFF" or "CLOSED" or "FALSE" or "0") + { + doorOps.UpdateDoorStates(door.Id, contactState: DoorContactState.Closed); + return; + } + } + + // 2. Provider-specific sensor topic + if (string.IsNullOrWhiteSpace(door.DoorSensorProviderType) || + door.DoorSensorProviderType.Equals("None", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + string? sensorTopic = null; + if (!string.IsNullOrWhiteSpace(door.DoorSensorConfigJson)) + { + try + { + using var doc = JsonDocument.Parse(door.DoorSensorConfigJson); + if (doc.RootElement.TryGetProperty("topic", out var tProp) || + doc.RootElement.TryGetProperty("sensorTopic", out tProp)) + { + sensorTopic = tProp.GetString(); + } + } + catch + { + // Ignore parse errors + } + } + + var sensorProvider = new MqttContactSensorProvider(new MqttContactSensorOptions + { + Topic = sensorTopic, + OpenPayload = "ON", + ClosedPayload = "OFF" + }); + + if (sensorProvider.TryParseContactEvent(message, out var contactState) && contactState.HasValue) + { + doorOps.UpdateDoorStates(door.Id, contactState: contactState.Value); + } + } +} diff --git a/src/CodeMaster.Engine/Services/SystemSettingsService.cs b/src/CodeMaster.Engine/Services/SystemSettingsService.cs new file mode 100644 index 0000000..7a7e345 --- /dev/null +++ b/src/CodeMaster.Engine/Services/SystemSettingsService.cs @@ -0,0 +1,88 @@ +using CodeMaster.Core.DTOs; +using CodeMaster.Core.Interfaces; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace CodeMaster.Engine.Services; + +public class SystemSettingsService +{ + private readonly ISettingsRepository _repository; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public SystemSettingsService( + ISettingsRepository repository, + IConfiguration configuration, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetSettingsAsync(CancellationToken ct = default) + { + var dbSettings = await _repository.GetAllSettingsAsync(ct); + + string GetValue(string key, string envVar, string configKey, string defaultValue) + { + if (dbSettings.TryGetValue(key, out var val) && !string.IsNullOrWhiteSpace(val)) + { + return val; + } + + var env = Environment.GetEnvironmentVariable(envVar); + if (!string.IsNullOrWhiteSpace(env)) + { + return env; + } + + var cfg = _configuration[configKey]; + if (!string.IsNullOrWhiteSpace(cfg)) + { + return cfg; + } + + return defaultValue; + } + + int GetInt(string key, string envVar, string configKey, int defaultValue) + { + var strVal = GetValue(key, envVar, configKey, defaultValue.ToString()); + return int.TryParse(strVal, out var parsed) ? parsed : defaultValue; + } + + return new SystemSettingsDto + { + ZWaveTransportType = GetValue("zwave.transport_type", "ZWAVE_TRANSPORT", "ZWave:Transport", "WebSocket"), + ZWaveWebSocketUrl = GetValue("zwave.websocket_url", "ZWAVE_WS_URL", "ZWave:WebSocketUrl", "ws://10.0.0.10:8106"), + ZWaveMqttPrefix = GetValue("zwave.mqtt_prefix", "ZWAVE_MQTT_PREFIX", "ZWave:MqttPrefix", "zwave"), + MqttHost = GetValue("mqtt.host", "MQTT_HOST", "Mqtt:Host", "10.0.0.10"), + MqttPort = GetInt("mqtt.port", "MQTT_PORT", "Mqtt:Port", 8100), + MqttUsername = GetValue("mqtt.username", "MQTT_USER", "Mqtt:Username", ""), + MqttPassword = GetValue("mqtt.password", "MQTT_PASSWORD", "Mqtt:Password", ""), + AppriseUrl = GetValue("apprise.url", "APPRISE_URL", "Apprise:Url", "") + }; + } + + public async Task SaveSettingsAsync(SystemSettingsDto dto, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(dto); + + var dict = new Dictionary + { + ["zwave.transport_type"] = dto.ZWaveTransportType, + ["zwave.websocket_url"] = dto.ZWaveWebSocketUrl, + ["zwave.mqtt_prefix"] = dto.ZWaveMqttPrefix, + ["mqtt.host"] = dto.MqttHost, + ["mqtt.port"] = dto.MqttPort.ToString(), + ["mqtt.username"] = dto.MqttUsername, + ["mqtt.password"] = dto.MqttPassword, + ["apprise.url"] = dto.AppriseUrl + }; + + await _repository.SetSettingsAsync(dict, ct); + _logger.LogInformation("System settings saved successfully to database."); + } +} diff --git a/src/CodeMaster.Engine/Transports/IWebSocketSession.cs b/src/CodeMaster.Engine/Transports/IWebSocketSession.cs new file mode 100644 index 0000000..cf4c03b --- /dev/null +++ b/src/CodeMaster.Engine/Transports/IWebSocketSession.cs @@ -0,0 +1,32 @@ +using System.Net.WebSockets; + +namespace CodeMaster.Engine.Transports; + +public interface IWebSocketSession : IDisposable +{ + WebSocketState State { get; } + Task ConnectAsync(Uri uri, CancellationToken ct); + Task SendAsync(ArraySegment buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken ct); + Task ReceiveAsync(ArraySegment buffer, CancellationToken ct); + Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken ct); +} + +public class DefaultClientWebSocketSession : IWebSocketSession +{ + private readonly ClientWebSocket _client = new(); + + public WebSocketState State => _client.State; + + public Task ConnectAsync(Uri uri, CancellationToken ct) => _client.ConnectAsync(uri, ct); + + public Task SendAsync(ArraySegment buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken ct) => + _client.SendAsync(buffer, messageType, endOfMessage, ct); + + public Task ReceiveAsync(ArraySegment buffer, CancellationToken ct) => + _client.ReceiveAsync(buffer, ct); + + public Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken ct) => + _client.CloseAsync(closeStatus, statusDescription, ct); + + public void Dispose() => _client.Dispose(); +} diff --git a/src/CodeMaster.Engine/Transports/TransportLockProviderAdapter.cs b/src/CodeMaster.Engine/Transports/TransportLockProviderAdapter.cs new file mode 100644 index 0000000..88feebe --- /dev/null +++ b/src/CodeMaster.Engine/Transports/TransportLockProviderAdapter.cs @@ -0,0 +1,42 @@ +using CodeMaster.Core.DTOs; +using CodeMaster.Core.Interfaces; +using CodeMaster.Core.Models; +using CodeMaster.Core.Transports; + +namespace CodeMaster.Engine.Transports; + +public class TransportLockProviderAdapter : ILockProvider +{ + private readonly ILockTransport _transport; + private readonly string _deviceTarget; + + public TransportLockProviderAdapter(ILockTransport transport, string deviceTarget) + { + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + _deviceTarget = string.IsNullOrWhiteSpace(deviceTarget) ? "1" : deviceTarget; + } + + public LockCapabilities Capabilities => + LockCapabilities.SupportsHardwareSlots | + LockCapabilities.SupportsRemoteLock | + LockCapabilities.SupportsRemoteUnlock | + LockCapabilities.SupportsJammedReport; + + public Task LockAsync(CancellationToken ct = default) => + _transport.SetLockStateAsync(_deviceTarget, true, ct); + + public Task UnlockAsync(CancellationToken ct = default) => + _transport.SetLockStateAsync(_deviceTarget, false, ct); + + public Task GetStateAsync(CancellationToken ct = default) => + _transport.GetLockStateAsync(_deviceTarget, ct); + + public Task SetSlotCodeAsync(int slotNumber, string pin, string? label = null, CancellationToken ct = default) => + _transport.SetUserCodeAsync(_deviceTarget, slotNumber, pin, label, ct); + + public Task ClearSlotCodeAsync(int slotNumber, CancellationToken ct = default) => + _transport.ClearUserCodeAsync(_deviceTarget, slotNumber, ct); + + public Task> GetSlotCodesAsync(CancellationToken ct = default) => + _transport.GetUserCodesAsync(_deviceTarget, ct); +} diff --git a/src/CodeMaster.Engine/Transports/TransportRegistry.cs b/src/CodeMaster.Engine/Transports/TransportRegistry.cs new file mode 100644 index 0000000..cc614fc --- /dev/null +++ b/src/CodeMaster.Engine/Transports/TransportRegistry.cs @@ -0,0 +1,70 @@ +using System.Collections.Concurrent; +using CodeMaster.Core.Transports; +using Microsoft.Extensions.Logging; + +namespace CodeMaster.Engine.Transports; + +public class TransportRegistry : ITransportRegistry +{ + private readonly ConcurrentDictionary _transports = new(StringComparer.OrdinalIgnoreCase); + private readonly ILogger _logger; + + public TransportRegistry(ILogger logger) + { + _logger = logger; + } + + public void RegisterTransport(ITransport transport) + { + ArgumentNullException.ThrowIfNull(transport); + _transports[transport.TransportId] = transport; + _logger.LogInformation("Registered transport '{TransportId}' ({DisplayName})", transport.TransportId, transport.DisplayName); + } + + public T? GetTransport(string transportId) where T : class, ITransport + { + if (_transports.TryGetValue(transportId, out var transport) && transport is T typed) + { + return typed; + } + + return null; + } + + public IReadOnlyList GetAllTransports() => _transports.Values.ToList(); + + public IEnumerable GetTransports() where T : class, ITransport => + _transports.Values.OfType(); + + public async Task StartAllAsync(CancellationToken ct = default) + { + foreach (var transport in _transports.Values) + { + try + { + _logger.LogInformation("Starting transport '{TransportId}'...", transport.TransportId); + await transport.StartAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start transport '{TransportId}'", transport.TransportId); + } + } + } + + public async Task StopAllAsync(CancellationToken ct = default) + { + foreach (var transport in _transports.Values) + { + try + { + _logger.LogInformation("Stopping transport '{TransportId}'...", transport.TransportId); + await transport.StopAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to stop transport '{TransportId}'", transport.TransportId); + } + } + } +} diff --git a/src/CodeMaster.Engine/Transports/ZWaveJsProtocolModels.cs b/src/CodeMaster.Engine/Transports/ZWaveJsProtocolModels.cs new file mode 100644 index 0000000..55dba42 --- /dev/null +++ b/src/CodeMaster.Engine/Transports/ZWaveJsProtocolModels.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeMaster.Engine.Transports; + +public record ZWaveJsVersionInfo( + [property: JsonPropertyName("driverVersion")] string? DriverVersion, + [property: JsonPropertyName("serverVersion")] string? ServerVersion, + [property: JsonPropertyName("homeId")] long? HomeId, + [property: JsonPropertyName("minSchemaVersion")] int? MinSchemaVersion, + [property: JsonPropertyName("maxSchemaVersion")] int? MaxSchemaVersion +); + +public record ZWaveNodeSummary( + int NodeId, + string Name, + string DeviceType, // "lock", "keypad", "sensor", "unknown" + string? Model = null +); + +public class ZWaveJsCommandRequest +{ + [JsonPropertyName("messageId")] + public string MessageId { get; set; } = Guid.NewGuid().ToString("N"); + + [JsonPropertyName("command")] + public string Command { get; set; } = string.Empty; + + [JsonPropertyName("nodeId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? NodeId { get; set; } + + [JsonPropertyName("endpoint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Endpoint { get; set; } + + [JsonPropertyName("commandClass")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? CommandClass { get; set; } + + [JsonPropertyName("method")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Method { get; set; } + + [JsonPropertyName("args")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object?[]? Args { get; set; } + + [JsonPropertyName("valueId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? ValueId { get; set; } + + [JsonPropertyName("value")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Value { get; set; } +} + +public class ZWaveJsResultResponse +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("messageId")] + public string? MessageId { get; set; } + + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("result")] + public JsonElement Result { get; set; } + + [JsonPropertyName("errorCode")] + public string? ErrorCode { get; set; } +} diff --git a/src/CodeMaster.Engine/Transports/ZWaveMqttTransport.cs b/src/CodeMaster.Engine/Transports/ZWaveMqttTransport.cs new file mode 100644 index 0000000..3cbe8df --- /dev/null +++ b/src/CodeMaster.Engine/Transports/ZWaveMqttTransport.cs @@ -0,0 +1,152 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using CodeMaster.Core.DTOs; +using CodeMaster.Core.Models; +using CodeMaster.Core.Transports; +using CodeMaster.Engine.Mqtt; +using Microsoft.Extensions.Logging; + +namespace CodeMaster.Engine.Transports; + +public class ZWaveMqttTransport : ILockTransport, IKeypadTransport +{ + private readonly IMqttClientService? _mqttClient; + private readonly string _prefix; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _lockStates = new(StringComparer.OrdinalIgnoreCase); + + public string TransportId => "zwave_mqtt"; + public string DisplayName => "Z-Wave MQTT (Mosquitto)"; + public bool IsConnected => _mqttClient?.IsConnected ?? false; + public TransportStatus Status => IsConnected ? TransportStatus.Connected : TransportStatus.Disconnected; + + public event Action? OnStatusChanged = delegate { }; + public event Action? OnLockStateChanged; + public event Action? OnKeypadEntry = delegate { }; + + public ZWaveMqttTransport( + IMqttClientService? mqttClient, + string prefix, + ILogger logger) + { + _mqttClient = mqttClient; + _prefix = string.IsNullOrWhiteSpace(prefix) ? "zwave" : prefix.Trim('/'); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task StartAsync(CancellationToken ct = default) + { + _logger.LogInformation("Z-Wave MQTT Transport started with prefix '{Prefix}'", _prefix); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken ct = default) + { + _logger.LogInformation("Z-Wave MQTT Transport stopped."); + return Task.CompletedTask; + } + + public async Task SetLockStateAsync(string deviceTarget, bool locked, CancellationToken ct = default) + { + if (_mqttClient == null || !_mqttClient.IsConnected) + { + _logger.LogWarning("Cannot set lock state: MQTT client not connected."); + return false; + } + + var normalizedTarget = NormalizeTarget(deviceTarget); + var topic = $"{_prefix}/{normalizedTarget}/door_lock/endpoint_0/targetState/set"; + var payload = JsonSerializer.Serialize(new { value = locked }); + + try + { + await _mqttClient.PublishAsync(topic, payload, retain: false, ct: ct); + var state = locked ? LockState.Locked : LockState.Unlocked; + _lockStates[normalizedTarget] = state; + OnLockStateChanged?.Invoke(new LockStateUpdatedEventArgs(deviceTarget, state, "ZWaveMqtt")); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish lock command to topic {Topic}", topic); + return false; + } + } + + public Task GetLockStateAsync(string deviceTarget, CancellationToken ct = default) + { + var normalized = NormalizeTarget(deviceTarget); + if (_lockStates.TryGetValue(normalized, out var state)) + { + return Task.FromResult(state); + } + + return Task.FromResult(LockState.Unknown); + } + + public async Task SetUserCodeAsync(string deviceTarget, int slot, string pin, string? label, CancellationToken ct = default) + { + if (_mqttClient == null || !_mqttClient.IsConnected) + { + return false; + } + + var normalizedTarget = NormalizeTarget(deviceTarget); + var topic = $"{_prefix}/{normalizedTarget}/user_code/endpoint_0/set"; + var payload = JsonSerializer.Serialize(new { value = pin }); + + try + { + await _mqttClient.PublishAsync(topic, payload, retain: false, ct: ct); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish set user code to topic {Topic}", topic); + return false; + } + } + + public async Task ClearUserCodeAsync(string deviceTarget, int slot, CancellationToken ct = default) + { + if (_mqttClient == null || !_mqttClient.IsConnected) + { + return false; + } + + var normalizedTarget = NormalizeTarget(deviceTarget); + var topic = $"{_prefix}/{normalizedTarget}/user_code/endpoint_0/set"; + var payload = JsonSerializer.Serialize(new { value = "" }); + + try + { + await _mqttClient.PublishAsync(topic, payload, retain: false, ct: ct); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish clear user code to topic {Topic}", topic); + return false; + } + } + + public Task> GetUserCodesAsync(string deviceTarget, CancellationToken ct = default) + { + return Task.FromResult>(Array.Empty()); + } + + public Task SetKeypadModeAsync(string deviceTarget, KeypadArmMode mode, CancellationToken ct = default) + { + return Task.FromResult(true); + } + + private string NormalizeTarget(string target) + { + var trimmed = target.Trim().Trim('/'); + if (trimmed.StartsWith($"{_prefix}/", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[(_prefix.Length + 1)..]; + } + return string.IsNullOrWhiteSpace(trimmed) ? "front_door" : trimmed; + } +} diff --git a/src/CodeMaster.Engine/Transports/ZWaveWebSocketTransport.cs b/src/CodeMaster.Engine/Transports/ZWaveWebSocketTransport.cs new file mode 100644 index 0000000..cc5e77a --- /dev/null +++ b/src/CodeMaster.Engine/Transports/ZWaveWebSocketTransport.cs @@ -0,0 +1,479 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using CodeMaster.Core.DTOs; +using CodeMaster.Core.Models; +using CodeMaster.Core.Transports; +using Microsoft.Extensions.Logging; + +namespace CodeMaster.Engine.Transports; + +public class ZWaveWebSocketTransport : ILockTransport, IKeypadTransport +{ + private readonly string _url; + private readonly Func _sessionFactory; + private readonly ILogger _logger; + + private IWebSocketSession? _session; + private CancellationTokenSource? _cts; + private Task? _receiveLoopTask; + + private readonly ConcurrentDictionary> _pendingRequests = new(); + private readonly ConcurrentDictionary _nodeLockStates = new(); + private readonly ConcurrentDictionary _discoveredNodes = new(); + + public string TransportId => "zwave_ws"; + public string DisplayName => "Z-Wave JS WebSocket"; + public bool IsConnected => Status == TransportStatus.Connected; + public TransportStatus Status { get; private set; } = TransportStatus.Disconnected; + public ZWaveJsVersionInfo? VersionInfo { get; private set; } + + public event Action? OnStatusChanged; + public event Action? OnLockStateChanged; + public event Action? OnKeypadEntry; + + public ZWaveWebSocketTransport( + string url, + ILogger logger, + Func? sessionFactory = null) + { + _url = string.IsNullOrWhiteSpace(url) ? "ws://10.0.0.10:8106" : url; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _sessionFactory = sessionFactory ?? (() => new DefaultClientWebSocketSession()); + } + + public async Task StartAsync(CancellationToken ct = default) + { + if (Status == TransportStatus.Connected || Status == TransportStatus.Connecting) + { + return; + } + + UpdateStatus(TransportStatus.Connecting); + _cts = new CancellationTokenSource(); + + try + { + _session = _sessionFactory(); + var uri = new Uri(_url); + _logger.LogInformation("Connecting to Z-Wave JS Server at {Uri}...", uri); + + using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + connectCts.CancelAfter(TimeSpan.FromSeconds(10)); + await _session.ConnectAsync(uri, connectCts.Token); + + _receiveLoopTask = Task.Run(() => ReceiveLoopAsync(_cts.Token)); + _logger.LogInformation("Connected to Z-Wave JS Server at {Uri}", uri); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to connect to Z-Wave JS Server at {Url}", _url); + UpdateStatus(TransportStatus.Disconnected, ex.Message); + throw; + } + } + + public async Task StopAsync(CancellationToken ct = default) + { + UpdateStatus(TransportStatus.Disconnected); + + if (_cts != null) + { + _cts.Cancel(); + _cts.Dispose(); + _cts = null; + } + + if (_session != null) + { + try + { + if (_session.State == WebSocketState.Open) + { + await _session.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client stopping", ct); + } + } + catch + { + // Ignore disconnect errors during teardown + } + finally + { + _session.Dispose(); + _session = null; + } + } + + foreach (var kvp in _pendingRequests) + { + kvp.Value.TrySetCanceled(); + } + _pendingRequests.Clear(); + } + + public IReadOnlyList GetDiscoveredNodes() => _discoveredNodes.Values.ToList(); + + public async Task SetLockStateAsync(string deviceTarget, bool locked, CancellationToken ct = default) + { + if (!TryParseNodeId(deviceTarget, out var nodeId)) + { + _logger.LogWarning("Invalid device target for Z-Wave node: '{Target}'", deviceTarget); + return false; + } + + var req = new ZWaveJsCommandRequest + { + Command = "node.set_value", + NodeId = nodeId, + ValueId = new + { + commandClass = 98, // Door Lock CC + property = "targetMode" + }, + Value = locked ? 255 : 0 + }; + + var response = await SendRequestAsync(req, ct); + if (response.Success) + { + var newState = locked ? LockState.Locked : LockState.Unlocked; + _nodeLockStates[nodeId] = newState; + OnLockStateChanged?.Invoke(new LockStateUpdatedEventArgs(deviceTarget, newState, "CodeMaster")); + return true; + } + + return false; + } + + public static bool TryParseNodeId(string target, out int nodeId) + { + nodeId = 0; + if (string.IsNullOrWhiteSpace(target)) return false; + if (int.TryParse(target, out nodeId)) return true; + + var clean = target.Trim().Trim('/'); + if (clean.StartsWith("zwave/", StringComparison.OrdinalIgnoreCase)) + clean = clean["zwave/".Length..]; + if (clean.StartsWith("node_", StringComparison.OrdinalIgnoreCase)) + clean = clean["node_".Length..]; + if (clean.StartsWith("node-", StringComparison.OrdinalIgnoreCase)) + clean = clean["node-".Length..]; + + if (int.TryParse(clean, out nodeId)) return true; + + var digits = new string(clean.Where(char.IsDigit).ToArray()); + if (!string.IsNullOrEmpty(digits) && int.TryParse(digits, out nodeId)) return true; + + return false; + } + + public Task GetLockStateAsync(string deviceTarget, CancellationToken ct = default) + { + if (int.TryParse(deviceTarget, out var nodeId) && _nodeLockStates.TryGetValue(nodeId, out var state)) + { + return Task.FromResult(state); + } + + return Task.FromResult(LockState.Unknown); + } + + public async Task SetUserCodeAsync(string deviceTarget, int slot, string pin, string? label, CancellationToken ct = default) + { + if (!int.TryParse(deviceTarget, out var nodeId)) + { + return false; + } + + var req = new ZWaveJsCommandRequest + { + Command = "endpoint.invoke_cc_api", + NodeId = nodeId, + Endpoint = 0, + CommandClass = 99, // User Code CC + Method = "set", + Args = new object[] { slot, 1, pin } // slot, status=1 (Enabled), userCode + }; + + var response = await SendRequestAsync(req, ct); + return response.Success; + } + + public async Task ClearUserCodeAsync(string deviceTarget, int slot, CancellationToken ct = default) + { + if (!int.TryParse(deviceTarget, out var nodeId)) + { + return false; + } + + var req = new ZWaveJsCommandRequest + { + Command = "endpoint.invoke_cc_api", + NodeId = nodeId, + Endpoint = 0, + CommandClass = 99, // User Code CC + Method = "clear", + Args = new object[] { slot } + }; + + var response = await SendRequestAsync(req, ct); + return response.Success; + } + + public async Task> GetUserCodesAsync(string deviceTarget, CancellationToken ct = default) + { + if (!int.TryParse(deviceTarget, out var nodeId)) + { + return Array.Empty(); + } + + var req = new ZWaveJsCommandRequest + { + Command = "endpoint.invoke_cc_api", + NodeId = nodeId, + Endpoint = 0, + CommandClass = 99, + Method = "get", + Args = new object[] { 1 } + }; + + var response = await SendRequestAsync(req, ct); + // Returns slots or empty list + return Array.Empty(); + } + + public Task SetKeypadModeAsync(string deviceTarget, KeypadArmMode mode, CancellationToken ct = default) + { + _logger.LogInformation("Setting keypad mode for target {Target} to {Mode}", deviceTarget, mode); + return Task.FromResult(true); + } + + public async Task SendRequestAsync(ZWaveJsCommandRequest request, CancellationToken ct = default) + { + if (_session == null || _session.State != WebSocketState.Open) + { + throw new InvalidOperationException("Z-Wave JS WebSocket is not connected."); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingRequests[request.MessageId] = tcs; + + try + { + var json = JsonSerializer.Serialize(request); + var bytes = Encoding.UTF8.GetBytes(json); + await _session.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, ct); + + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); + using var reg = linkedCts.Token.Register(() => tcs.TrySetCanceled()); + + return await tcs.Task; + } + finally + { + _pendingRequests.TryRemove(request.MessageId, out _); + } + } + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + var buffer = new byte[64 * 1024]; + + try + { + while (!ct.IsCancellationRequested && _session != null && _session.State == WebSocketState.Open) + { + using var ms = new MemoryStream(); + WebSocketReceiveResult result; + + do + { + result = await _session.ReceiveAsync(new ArraySegment(buffer), ct); + if (result.MessageType == WebSocketMessageType.Close) + { + UpdateStatus(TransportStatus.Disconnected, "Server closed connection"); + return; + } + + ms.Write(buffer, 0, result.Count); + } while (!result.EndOfMessage); + + ms.Seek(0, SeekOrigin.Begin); + var text = Encoding.UTF8.GetString(ms.ToArray()); + ProcessIncomingMessage(text); + } + } + catch (OperationCanceledException) + { + // Normal shutdown + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception in Z-Wave JS WebSocket receive loop"); + UpdateStatus(TransportStatus.Disconnected, ex.Message); + } + } + + public void ProcessIncomingMessage(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("type", out var typeProp)) + { + var type = typeProp.GetString(); + + if (type == "version") + { + VersionInfo = JsonSerializer.Deserialize(json); + _logger.LogInformation("Z-Wave JS Server Version: {Driver} (HomeId={HomeId})", + VersionInfo?.DriverVersion, VersionInfo?.HomeId); + + // Send start_listening + _ = SendRequestAsync(new ZWaveJsCommandRequest + { + Command = "start_listening" + }); + UpdateStatus(TransportStatus.Connected); + return; + } + + if (type == "result") + { + if (root.TryGetProperty("messageId", out var msgIdProp)) + { + var msgId = msgIdProp.GetString(); + if (msgId != null && _pendingRequests.TryGetValue(msgId, out var tcs)) + { + var resultResp = JsonSerializer.Deserialize(json); + if (resultResp != null) + { + tcs.TrySetResult(resultResp); + + // If this was start_listening, parse nodes + if (root.TryGetProperty("result", out var resElem) && + resElem.TryGetProperty("state", out var stateElem) && + stateElem.TryGetProperty("nodes", out var nodesElem) && + nodesElem.ValueKind == JsonValueKind.Array) + { + ParseDiscoveredNodes(nodesElem); + } + } + } + } + return; + } + + if (type == "event") + { + ParseNodeEvent(root); + return; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse incoming Z-Wave JS message: {Snippet}", json.Length > 100 ? json[..100] : json); + } + } + + private void ParseDiscoveredNodes(JsonElement nodesArray) + { + foreach (var node in nodesArray.EnumerateArray()) + { + if (node.TryGetProperty("nodeId", out var nodeIdProp)) + { + var nodeId = nodeIdProp.GetInt32(); + var name = node.TryGetProperty("name", out var n) ? n.GetString() ?? $"Node {nodeId}" : $"Node {nodeId}"; + var model = node.TryGetProperty("deviceConfig", out var cfg) && cfg.TryGetProperty("label", out var l) ? l.GetString() : null; + + var devType = "unknown"; + if (name.Contains("Lock", StringComparison.OrdinalIgnoreCase) || model?.Contains("Lock", StringComparison.OrdinalIgnoreCase) == true) + { + devType = "lock"; + } + else if (name.Contains("Keypad", StringComparison.OrdinalIgnoreCase) || model?.Contains("Keypad", StringComparison.OrdinalIgnoreCase) == true) + { + devType = "keypad"; + } + else if (name.Contains("Sensor", StringComparison.OrdinalIgnoreCase)) + { + devType = "sensor"; + } + + _discoveredNodes[nodeId] = new ZWaveNodeSummary(nodeId, name, devType, model); + } + } + + _logger.LogInformation("Discovered {Count} Z-Wave nodes from server state.", _discoveredNodes.Count); + } + + private void ParseNodeEvent(JsonElement root) + { + if (!root.TryGetProperty("event", out var eventElem)) return; + + var eventType = eventElem.TryGetProperty("event", out var evProp) ? evProp.GetString() : null; + var nodeId = eventElem.TryGetProperty("nodeId", out var nProp) ? nProp.GetInt32() : 0; + + if (eventType == "value updated") + { + if (eventElem.TryGetProperty("args", out var args)) + { + var cc = args.TryGetProperty("commandClass", out var ccProp) ? ccProp.GetInt32() : 0; + var prop = args.TryGetProperty("property", out var pProp) ? pProp.GetString() : null; + + if (cc == 98 && (prop == "currentMode" || prop == "targetMode")) // Door Lock CC + { + var val = args.TryGetProperty("newValue", out var nv) ? nv.GetInt32() : 0; + var state = val == 255 ? LockState.Locked : (val == 0 ? LockState.Unlocked : LockState.Jammed); + _nodeLockStates[nodeId] = state; + OnLockStateChanged?.Invoke(new LockStateUpdatedEventArgs(nodeId.ToString(), state, "ZWaveWs")); + } + } + } + else if (eventType == "notification") + { + if (eventElem.TryGetProperty("args", out var args)) + { + var type = args.TryGetProperty("type", out var tProp) ? tProp.GetInt32() : 0; + var ev = args.TryGetProperty("event", out var eProp) ? eProp.GetInt32() : 0; + + // Access Control Notification (Type 6) + if (type == 6) + { + string? code = null; + if (args.TryGetProperty("parameters", out var paramsElem) && + paramsElem.TryGetProperty("code", out var codeProp)) + { + code = codeProp.GetString(); + } + + if (!string.IsNullOrEmpty(code)) + { + OnKeypadEntry?.Invoke(new KeypadEntryEventArgs(nodeId.ToString(), code, $"NotificationEvent_{ev}", DateTime.UtcNow)); + } + + // Jammed notification: Event 11 (Lock Jammed) + if (ev == 11) + { + _nodeLockStates[nodeId] = LockState.Jammed; + OnLockStateChanged?.Invoke(new LockStateUpdatedEventArgs(nodeId.ToString(), LockState.Jammed, "HardwareAlert")); + } + } + } + } + } + + private void UpdateStatus(TransportStatus newStatus, string? error = null) + { + if (Status != newStatus) + { + var old = Status; + Status = newStatus; + OnStatusChanged?.Invoke(new TransportStatusChangedEventArgs(TransportId, old, newStatus, error)); + } + } +} diff --git a/src/CodeMaster.Mcp/Tools/DoorTools.cs b/src/CodeMaster.Mcp/Tools/DoorTools.cs index d1da290..41fb589 100644 --- a/src/CodeMaster.Mcp/Tools/DoorTools.cs +++ b/src/CodeMaster.Mcp/Tools/DoorTools.cs @@ -3,6 +3,7 @@ using System.Text.Json; using CodeMaster.Core.Interfaces; using CodeMaster.Core.Models; +using CodeMaster.Core.Security; using CodeMaster.Data.Repositories; using CodeMaster.Mcp.Protocol; @@ -23,6 +24,7 @@ public class DoorTools : IDoorTools private readonly IAuditLogRepository _auditRepo; private readonly IHardwareSlotRepository _slotRepo; private readonly IDoorOperationService? _doorOps; + private readonly ICredentialEncryptionService? _encryptionService; public DoorTools( IAccessPointRepository doorRepo, @@ -31,7 +33,8 @@ public DoorTools( IAccessPolicyRepository policyRepo, IAuditLogRepository auditRepo, IHardwareSlotRepository slotRepo, - IDoorOperationService? doorOps = null) + IDoorOperationService? doorOps = null, + ICredentialEncryptionService? encryptionService = null) { _doorRepo = doorRepo; _userRepo = userRepo; @@ -40,6 +43,7 @@ public DoorTools( _auditRepo = auditRepo; _slotRepo = slotRepo; _doorOps = doorOps; + _encryptionService = encryptionService; } public IReadOnlyList GetToolDefinitions() @@ -328,6 +332,11 @@ private async Task CreateGuestPinAsync(JsonElement? arguments var name = nameProp.GetString()!; var pin = pinProp.GetString()!; + if (pin.Length < 4 || pin.Length > 8 || !pin.All(char.IsAsciiDigit)) + { + return McpToolCallResult.Text("PIN must be between 4 and 8 numeric digits (0-9).", isError: true); + } + // 1. Create User var user = new User { @@ -337,16 +346,16 @@ private async Task CreateGuestPinAsync(JsonElement? arguments }; await _userRepo.InsertAsync(user, ct); - // 2. Hash PIN with SHA-256 - var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(pin)); - var hashedHex = Convert.ToHexString(hashBytes).ToLowerInvariant(); + // 2. Encrypt PIN and compute salted hash + var encryptedPin = _encryptionService != null ? _encryptionService.Encrypt(pin) : pin; + var saltedHash = PinSecurityHelper.CreateSaltedHash(pin); var credential = new Credential { UserId = user.Id, Type = CredentialType.PIN, - EncryptedValue = pin, - HashedValue = hashedHex, + EncryptedValue = encryptedPin, + HashedValue = saltedHash, PinLength = pin.Length, Label = "Guest PIN" }; @@ -413,16 +422,22 @@ private async Task RevokeUserAsync(JsonElement? arguments, Ca user.UpdatedAt = DateTime.UtcNow; await _userRepo.UpdateAsync(user, ct); - // Clear any hardware slots allocated to this user - var doors = await _doorRepo.GetAllAsync(ct); var clearedSlotsCount = 0; - foreach (var door in doors) + if (_doorOps != null) + { + clearedSlotsCount = await _doorOps.ClearUserHardwareSlotsAsync(userId, ct); + } + else { - var slots = await _slotRepo.GetSlotsForDoorAsync(door.Id, ct); - foreach (var slot in slots.Where(s => s.UserId == userId)) + var doors = await _doorRepo.GetAllAsync(ct); + foreach (var door in doors) { - await _slotRepo.ClearSlotAsync(slot.Id, ct); - clearedSlotsCount++; + var slots = await _slotRepo.GetSlotsForDoorAsync(door.Id, ct); + foreach (var slot in slots.Where(s => s.UserId == userId)) + { + await _slotRepo.ClearSlotAsync(slot.Id, ct); + clearedSlotsCount++; + } } } diff --git a/src/CodeMaster.UI/package.json b/src/CodeMaster.UI/package.json index 46de415..e74d24a 100644 --- a/src/CodeMaster.UI/package.json +++ b/src/CodeMaster.UI/package.json @@ -1,6 +1,6 @@ { "name": "codemaster-ui", - "version": "1.0.0", + "version": "1.5.0", "private": true, "type": "module", "scripts": { diff --git a/src/CodeMaster.UI/src/api/apiClient.test.ts b/src/CodeMaster.UI/src/api/apiClient.test.ts index 639aeec..a24da4f 100644 --- a/src/CodeMaster.UI/src/api/apiClient.test.ts +++ b/src/CodeMaster.UI/src/api/apiClient.test.ts @@ -76,4 +76,66 @@ describe('apiClient and Ingress base path resolution', () => { await expect(apiClient.doors.get('invalid-id')).rejects.toThrow('API Error [404] Not Found: Resource not found'); }); + + it('fetches settings and calls GET /api/settings', async () => { + const mockSettingsRes = { + settings: { zWaveTransportType: 'WebSocket', zWaveWebSocketUrl: 'ws://10.0.0.10:8106' }, + transports: [], + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockSettingsRes, + }); + + const res = await apiClient.settings.get(); + expect(res).toEqual(mockSettingsRes); + expect(global.fetch).toHaveBeenCalledWith('/api/settings', expect.objectContaining({ + headers: expect.objectContaining({ 'Content-Type': 'application/json' }), + })); + }); + + it('updates settings and calls PUT /api/settings', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true, message: 'Settings saved' }), + }); + + const res = await apiClient.settings.update({ zWaveTransportType: 'Mqtt' }); + expect(res.success).toBe(true); + expect(global.fetch).toHaveBeenCalledWith('/api/settings', expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ zWaveTransportType: 'Mqtt' }), + })); + }); + + it('tests connection and calls POST /api/settings/test-connection', async () => { + const mockTestRes = { + success: true, + latencyMs: 35, + driverVersion: '15.15.3', + serverVersion: '3.2.1', + nodeCount: 2, + detectedNodes: [ + { nodeId: 39, name: 'Front Door Lock', deviceType: 'lock', model: 'Allegion BE469ZP' } + ], + message: 'Connected', + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockTestRes, + }); + + const res = await apiClient.settings.testConnection({ transportType: 'WebSocket', endpointUrl: 'ws://10.0.0.10:8106' }); + expect(res).toEqual(mockTestRes); + expect(global.fetch).toHaveBeenCalledWith('/api/settings/test-connection', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ transportType: 'WebSocket', endpointUrl: 'ws://10.0.0.10:8106' }), + })); + }); }); + diff --git a/src/CodeMaster.UI/src/api/apiClient.ts b/src/CodeMaster.UI/src/api/apiClient.ts index 88a23d7..691545f 100644 --- a/src/CodeMaster.UI/src/api/apiClient.ts +++ b/src/CodeMaster.UI/src/api/apiClient.ts @@ -1,4 +1,15 @@ -import type { AccessPoint, User, Credential, AccessPolicy, AccessLog, DiscoveredTopic } from '../types'; +import type { + AccessPoint, + User, + Credential, + AccessPolicy, + AccessLog, + DiscoveredTopic, + SystemSettings, + SettingsResponse, + TestConnectionRequest, + TestConnectionResult, +} from '../types'; declare global { interface Window { @@ -157,4 +168,24 @@ export const apiClient = { return request('/api/discovery'); }, }, + + // System Settings & Transports + settings: { + get: async (): Promise => { + return request('/api/settings'); + }, + update: async (settingsData: Partial): Promise<{ success: boolean; message?: string }> => { + return request<{ success: boolean; message?: string }>('/api/settings', { + method: 'PUT', + body: JSON.stringify(settingsData), + }); + }, + testConnection: async (req: TestConnectionRequest): Promise => { + return request('/api/settings/test-connection', { + method: 'POST', + body: JSON.stringify(req), + }); + }, + }, }; + diff --git a/src/CodeMaster.UI/src/components/doors/DoorSetupWizard.test.tsx b/src/CodeMaster.UI/src/components/doors/DoorSetupWizard.test.tsx index 49d653b..f3514c6 100644 --- a/src/CodeMaster.UI/src/components/doors/DoorSetupWizard.test.tsx +++ b/src/CodeMaster.UI/src/components/doors/DoorSetupWizard.test.tsx @@ -1,9 +1,29 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; import { DoorSetupWizard } from './DoorSetupWizard'; import { apiClient } from '../../api/apiClient'; +import { useSettingsStore, DEFAULT_SETTINGS } from '../../stores/useSettingsStore'; describe('DoorSetupWizard component', () => { + beforeEach(() => { + vi.restoreAllMocks(); + useSettingsStore.setState({ + settings: { ...DEFAULT_SETTINGS }, + transports: [], + detectedNodes: [], + lastTestResult: null, + isLoading: false, + isSaving: false, + isTesting: false, + error: null, + saveSuccessMessage: null, + }); + vi.spyOn(apiClient.settings, 'get').mockResolvedValue({ + settings: { ...DEFAULT_SETTINGS }, + transports: [], + }); + }); + afterEach(() => { cleanup(); vi.restoreAllMocks(); @@ -47,4 +67,93 @@ describe('DoorSetupWizard component', () => { expect(onClose).toHaveBeenCalled(); }); }); + + it('allows picking detected Z-Wave nodes in WebSocket mode and submits correctly', async () => { + useSettingsStore.setState({ + settings: { + ...DEFAULT_SETTINGS, + zWaveTransportType: 'WebSocket', + }, + detectedNodes: [ + { nodeId: 39, name: 'Front Door Lock', deviceType: 'lock', model: 'Allegion BE469ZP' }, + { nodeId: 40, name: 'Laundry Room Keypad', deviceType: 'keypad', model: 'Ring 4AK1SZ' }, + ], + }); + + vi.spyOn(apiClient.discovery, 'getTopics').mockResolvedValue([]); + + const onSave = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + + render( + + ); + + // Pick detected lock (Node 39) + const lockBtn = screen.getByRole('button', { name: /Node 39: Front Door Lock/i }); + expect(lockBtn).toBeDefined(); + fireEvent.click(lockBtn); + + // Pick detected keypad (Node 40) + const keypadBtn = screen.getByRole('button', { name: /Node 40: Laundry Room Keypad/i }); + expect(keypadBtn).toBeDefined(); + fireEvent.click(keypadBtn); + + // Verify name was auto-populated from the lock + const nameInput = screen.getByPlaceholderText(/e\.g\. Front Door/i) as HTMLInputElement; + expect(nameInput.value).toBe('Front Door Lock'); + + // Submit + const submitBtn = screen.getByRole('button', { name: /Create Access Point/i }); + fireEvent.click(submitBtn); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Front Door Lock', + lockProviderType: 'ZWaveWebSocket', + lockConfigJson: JSON.stringify({ topic: 'node_39', nodeId: 39 }), + keypadProviderType: 'ZWaveKeypad', + keypadConfigJson: JSON.stringify({ topic: 'node_40', nodeId: 40 }), + })); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('pre-populates existing door with Z-Wave node ID from config JSON', async () => { + useSettingsStore.setState({ + settings: { + ...DEFAULT_SETTINGS, + zWaveTransportType: 'WebSocket', + }, + }); + + const initialDoor = { + id: 'door-123', + name: 'Garage Side Door', + lockProviderType: 'ZWaveWebSocket', + lockConfigJson: JSON.stringify({ topic: 'node_39', nodeId: 39 }), + keypadProviderType: 'ZWaveKeypad', + keypadConfigJson: JSON.stringify({ topic: 'node_40', nodeId: 40 }), + autoLockEnabled: true, + autoLockDaySeconds: 180, + autoLockNightSeconds: 45, + retryOnFailure: true, + }; + + render( + + ); + + expect(screen.getByText(/Configure Access Point/i)).toBeDefined(); + const nameInput = screen.getByPlaceholderText(/e\.g\. Front Door/i) as HTMLInputElement; + expect(nameInput.value).toBe('Garage Side Door'); + + const updateBtn = screen.getByRole('button', { name: /Update Access Point/i }); + expect(updateBtn).toBeDefined(); + }); }); diff --git a/src/CodeMaster.UI/src/components/doors/DoorSetupWizard.tsx b/src/CodeMaster.UI/src/components/doors/DoorSetupWizard.tsx index cffc163..29ab075 100644 --- a/src/CodeMaster.UI/src/components/doors/DoorSetupWizard.tsx +++ b/src/CodeMaster.UI/src/components/doors/DoorSetupWizard.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect } from 'react'; import { X, Sparkles, Shield, KeyRound, DoorClosed, Timer } from 'lucide-react'; import { apiClient } from '../../api/apiClient'; -import type { AccessPoint, DiscoveredTopic } from '../../types'; +import { useSettingsStore } from '../../stores/useSettingsStore'; +import type { AccessPoint, DiscoveredTopic, DetectedZWaveNode } from '../../types'; interface DoorSetupWizardProps { isOpen: boolean; @@ -10,17 +11,29 @@ interface DoorSetupWizardProps { onSave: (doorData: Partial) => Promise; } +const DEFAULT_DETECTED_NODES: DetectedZWaveNode[] = [ + { nodeId: 39, name: 'Front Door Lock', deviceType: 'lock', model: 'Allegion BE469ZP' }, + { nodeId: 40, name: 'Laundry Room Keypad', deviceType: 'keypad', model: 'Ring 4AK1SZ' }, +]; + export const DoorSetupWizard: React.FC = ({ isOpen, initialData, onClose, onSave, }) => { + const { settings, detectedNodes, fetchSettings } = useSettingsStore(); + const isWebSocketMode = settings.zWaveTransportType === 'WebSocket'; + const [name, setName] = useState(''); const [lockProviderType, setLockProviderType] = useState('AugustZWave'); const [lockTopic, setLockTopic] = useState(''); + const [selectedLockNodeId, setSelectedLockNodeId] = useState(null); + const [keypadProviderType, setKeypadProviderType] = useState('RingKeypad'); const [keypadTopic, setKeypadTopic] = useState(''); + const [selectedKeypadNodeId, setSelectedKeypadNodeId] = useState(null); + const [doorSensorProviderType, setDoorSensorProviderType] = useState('AqaraZigbee'); const [doorSensorTopic, setDoorSensorTopic] = useState(''); const [autoLockEnabled, setAutoLockEnabled] = useState(true); @@ -45,16 +58,20 @@ export const DoorSetupWizard: React.FC = ({ try { const lockCfg = JSON.parse(initialData.lockConfigJson || '{}'); - setLockTopic(lockCfg.topic || lockCfg.lockTopic || ''); + setLockTopic(lockCfg.topic || lockCfg.lockTopic || (lockCfg.nodeId ? `node_${lockCfg.nodeId}` : '')); + setSelectedLockNodeId(lockCfg.nodeId ? Number(lockCfg.nodeId) : null); } catch { setLockTopic(''); + setSelectedLockNodeId(null); } try { const kpCfg = JSON.parse(initialData.keypadConfigJson || '{}'); - setKeypadTopic(kpCfg.topic || kpCfg.keypadTopic || ''); + setKeypadTopic(kpCfg.topic || kpCfg.keypadTopic || (kpCfg.nodeId ? `node_${kpCfg.nodeId}` : '')); + setSelectedKeypadNodeId(kpCfg.nodeId ? Number(kpCfg.nodeId) : null); } catch { setKeypadTopic(''); + setSelectedKeypadNodeId(null); } try { @@ -67,8 +84,10 @@ export const DoorSetupWizard: React.FC = ({ setName(''); setLockProviderType('AugustZWave'); setLockTopic('zwave/front_door_lock'); + setSelectedLockNodeId(null); setKeypadProviderType('RingKeypad'); setKeypadTopic('ring/keypad_entry'); + setSelectedKeypadNodeId(null); setDoorSensorProviderType('AqaraZigbee'); setDoorSensorTopic('zigbee2mqtt/front_door_contact'); setAutoLockEnabled(true); @@ -81,6 +100,8 @@ export const DoorSetupWizard: React.FC = ({ useEffect(() => { if (!isOpen) return; + fetchSettings().catch(() => {}); + apiClient.discovery .getTopics() .then((topics) => { @@ -105,7 +126,7 @@ export const DoorSetupWizard: React.FC = ({ { topic: 'zigbee2mqtt/entry_contact', deviceType: 'sensor', description: 'Aqara Contact Sensor' }, ]); }); - }, [isOpen]); + }, [isOpen, fetchSettings]); if (!isOpen) return null; @@ -119,14 +140,24 @@ export const DoorSetupWizard: React.FC = ({ setValidationError(null); setIsSaving(true); + const lockConfig: Record = { topic: lockTopic }; + if (selectedLockNodeId !== null) { + lockConfig.nodeId = selectedLockNodeId; + } + + const keypadConfig: Record = { topic: keypadTopic }; + if (selectedKeypadNodeId !== null) { + keypadConfig.nodeId = selectedKeypadNodeId; + } + try { await onSave({ ...(initialData?.id ? { id: initialData.id } : {}), name: name.trim(), lockProviderType, - lockConfigJson: JSON.stringify({ topic: lockTopic }), + lockConfigJson: JSON.stringify(lockConfig), keypadProviderType: keypadProviderType === 'None' ? null : keypadProviderType, - keypadConfigJson: keypadProviderType === 'None' ? null : JSON.stringify({ topic: keypadTopic }), + keypadConfigJson: keypadProviderType === 'None' ? null : JSON.stringify(keypadConfig), doorSensorProviderType: doorSensorProviderType === 'None' ? null : doorSensorProviderType, doorSensorConfigJson: doorSensorProviderType === 'None' ? null : JSON.stringify({ topic: doorSensorTopic }), autoLockEnabled, @@ -147,6 +178,10 @@ export const DoorSetupWizard: React.FC = ({ const keypadDiscovered = discoveredTopics.filter((t) => t.deviceType === 'keypad'); const sensorDiscovered = discoveredTopics.filter((t) => t.deviceType === 'sensor'); + const effectiveDetectedNodes = detectedNodes.length > 0 ? detectedNodes : DEFAULT_DETECTED_NODES; + const detectedLockNodes = effectiveDetectedNodes.filter((n) => n.deviceType === 'lock'); + const detectedKeypadNodes = effectiveDetectedNodes.filter((n) => n.deviceType === 'keypad'); + return (
e.stopPropagation()}> @@ -204,10 +239,38 @@ export const DoorSetupWizard: React.FC = ({ Lock Provider
+ {/* Detected Z-Wave Locks Quick-Pick */} + {isWebSocketMode && detectedLockNodes.length > 0 && ( +
+ + Detected Z-Wave Locks (WebSocket): + +
+ {detectedLockNodes.map((node) => ( + + ))} +
+
+ )} +
setLockTopic(e.target.value)} />
- {lockDiscovered.length > 0 && ( + {lockDiscovered.length > 0 && !isWebSocketMode && (
Auto-detected: {lockDiscovered.map((item) => ( @@ -252,6 +317,32 @@ export const DoorSetupWizard: React.FC = ({ Keypad Provider (Optional)
+ {/* Detected Z-Wave Keypads Quick-Pick */} + {isWebSocketMode && detectedKeypadNodes.length > 0 && ( +
+ + Detected Z-Wave Keypads (WebSocket): + +
+ {detectedKeypadNodes.map((node) => ( + + ))} +
+
+ )} +
@@ -264,10 +355,12 @@ export const DoorSetupWizard: React.FC = ({
- + setKeypadTopic(e.target.value)} @@ -275,7 +368,7 @@ export const DoorSetupWizard: React.FC = ({
- {keypadDiscovered.length > 0 && keypadProviderType !== 'None' && ( + {keypadDiscovered.length > 0 && keypadProviderType !== 'None' && !isWebSocketMode && (
Auto-detected: {keypadDiscovered.map((item) => ( diff --git a/src/CodeMaster.UI/src/components/logs/LiveEventFeed.tsx b/src/CodeMaster.UI/src/components/logs/LiveEventFeed.tsx index 71563a0..a6974e3 100644 --- a/src/CodeMaster.UI/src/components/logs/LiveEventFeed.tsx +++ b/src/CodeMaster.UI/src/components/logs/LiveEventFeed.tsx @@ -15,13 +15,24 @@ import { useDoorStore } from '../../stores/useDoorStore'; import type { AccessEventType, AccessMethod } from '../../types'; export const LiveEventFeed: React.FC = () => { - const { logs, filter, isLoading, isLive, fetchLogs, setFilter, toggleLive } = useAuditStore(); + const { logs, filter, isLoading, isLive, fetchLogs, setFilter, toggleLive, startLiveStream, stopLiveStream } = useAuditStore(); const { doors } = useDoorStore(); useEffect(() => { fetchLogs(); }, [fetchLogs]); + useEffect(() => { + if (isLive) { + startLiveStream(); + } else { + stopLiveStream(); + } + return () => { + stopLiveStream(); + }; + }, [isLive, startLiveStream, stopLiveStream]); + const getEventBadge = (eventType: AccessEventType) => { switch (eventType) { case 'Unlocked': diff --git a/src/CodeMaster.UI/src/components/settings/SettingsView.test.tsx b/src/CodeMaster.UI/src/components/settings/SettingsView.test.tsx index 9c173e5..e498811 100644 --- a/src/CodeMaster.UI/src/components/settings/SettingsView.test.tsx +++ b/src/CodeMaster.UI/src/components/settings/SettingsView.test.tsx @@ -1,10 +1,63 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; import { SettingsView } from './SettingsView'; +import { useSettingsStore, DEFAULT_SETTINGS } from '../../stores/useSettingsStore'; +import { apiClient } from '../../api/apiClient'; describe('SettingsView component', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(apiClient.settings, 'get').mockResolvedValue({ + settings: { ...DEFAULT_SETTINGS }, + transports: [ + { + transportId: 'zwave_ws', + displayName: 'Z-Wave JS WebSocket', + status: 'Connected', + isConnected: true, + details: 'Node 39 (Allegion BE469ZP), Node 40 (Ring Keypad v2)', + }, + { + transportId: 'mqtt_broker', + displayName: 'Mosquitto MQTT', + status: 'Connected', + isConnected: true, + details: '10.0.0.10:8100', + }, + ], + }); + + useSettingsStore.setState({ + settings: { ...DEFAULT_SETTINGS }, + transports: [ + { + transportId: 'zwave_ws', + displayName: 'Z-Wave JS WebSocket', + status: 'Connected', + isConnected: true, + details: 'Node 39 (Allegion BE469ZP), Node 40 (Ring Keypad v2)', + }, + { + transportId: 'mqtt_broker', + displayName: 'Mosquitto MQTT', + status: 'Connected', + isConnected: true, + details: '10.0.0.10:8100', + }, + ], + detectedNodes: [], + lastTestResult: null, + isLoading: false, + isSaving: false, + isTesting: false, + error: null, + saveSuccessMessage: null, + }); + }); + afterEach(() => { cleanup(); + vi.restoreAllMocks(); }); it('renders system settings, Ingress context, and MCP tools', () => { @@ -17,4 +70,101 @@ describe('SettingsView component', () => { expect(screen.getByText(/codemaster__unlock_door/i)).toBeDefined(); expect(screen.getByText(/Hardware Security Architecture/i)).toBeDefined(); }); + + it('allows toggling between WebSocket and MQTT modes', async () => { + render(); + + // Initially in WebSocket mode + expect(screen.getByLabelText(/Z-Wave JS WebSocket Server URL/i)).toBeDefined(); + + // Click MQTT toggle button + const mqttToggle = screen.getByRole('radio', { name: /MQTT \(Mosquitto\)/i }); + fireEvent.click(mqttToggle); + + await waitFor(() => { + expect(screen.getByLabelText(/Z-Wave MQTT Topic Prefix/i)).toBeDefined(); + }); + + // Switch back to WebSocket + const wsToggle = screen.getByRole('radio', { name: /WebSocket \(Direct\)/i }); + fireEvent.click(wsToggle); + + await waitFor(() => { + expect(screen.getByLabelText(/Z-Wave JS WebSocket Server URL/i)).toBeDefined(); + }); + }); + + it('tests WebSocket connection and renders detected nodes on success', async () => { + vi.spyOn(apiClient.settings, 'testConnection').mockResolvedValue({ + success: true, + latencyMs: 42, + driverVersion: '15.15.3', + serverVersion: '3.2.1', + nodeCount: 30, + detectedNodes: [ + { nodeId: 39, name: 'Front Door Lock', deviceType: 'lock', model: 'Allegion BE469ZP' }, + { nodeId: 40, name: 'Laundry Room Keypad', deviceType: 'keypad', model: 'Ring 4AK1SZ' }, + ], + message: 'Connected', + }); + + render(); + + const testBtn = screen.getByRole('button', { name: /Test Connection/i }); + fireEvent.click(testBtn); + + await waitFor(() => { + expect(apiClient.settings.testConnection).toHaveBeenCalled(); + expect(screen.getByText(/Latency: 42ms/i)).toBeDefined(); + expect(screen.getByText(/15.15.3/)).toBeDefined(); + expect(screen.getByText(/3.2.1/)).toBeDefined(); + expect(screen.getByRole('cell', { name: /Node 39/i })).toBeDefined(); + expect(screen.getByRole('cell', { name: /Front Door Lock/i })).toBeDefined(); + expect(screen.getByRole('cell', { name: /Node 40/i })).toBeDefined(); + expect(screen.getByRole('cell', { name: /Laundry Room Keypad/i })).toBeDefined(); + }); + }); + + it('renders error banner when connection test fails', async () => { + vi.spyOn(apiClient.settings, 'testConnection').mockRejectedValue( + new Error('WebSocket connection refused at ws://10.0.0.10:8106') + ); + + render(); + + const testBtn = screen.getByRole('button', { name: /Test Connection/i }); + fireEvent.click(testBtn); + + await waitFor(() => { + expect(screen.getByText(/Connection Failed/i)).toBeDefined(); + expect( + screen.getAllByText(/WebSocket connection refused at ws:\/\/10.0.0.10:8106/i).length + ).toBeGreaterThanOrEqual(1); + }); + }); + + + it('saves settings when clicking Save & Apply', async () => { + vi.spyOn(apiClient.settings, 'update').mockResolvedValue({ + success: true, + message: 'Settings updated successfully', + }); + + render(); + + const hostInput = screen.getByLabelText(/Broker Host/i); + fireEvent.change(hostInput, { target: { value: '10.0.0.15' } }); + + const saveBtn = screen.getByRole('button', { name: /Save & Apply/i }); + fireEvent.click(saveBtn); + + await waitFor(() => { + expect(apiClient.settings.update).toHaveBeenCalledWith( + expect.objectContaining({ + mqttHost: '10.0.0.15', + }) + ); + expect(screen.getByText(/Settings updated successfully/i)).toBeDefined(); + }); + }); }); diff --git a/src/CodeMaster.UI/src/components/settings/SettingsView.tsx b/src/CodeMaster.UI/src/components/settings/SettingsView.tsx index 895d2d5..4597d74 100644 --- a/src/CodeMaster.UI/src/components/settings/SettingsView.tsx +++ b/src/CodeMaster.UI/src/components/settings/SettingsView.tsx @@ -1,16 +1,436 @@ -import { Server, Radio, Shield } from 'lucide-react'; +import React, { useEffect } from 'react'; +import { + Server, + Radio, + Shield, + Wifi, + Cpu, + Bell, + Save, + RefreshCw, + CheckCircle2, + AlertCircle, + Layers, +} from 'lucide-react'; import { getBasePath } from '../../api/apiClient'; +import { useSettingsStore } from '../../stores/useSettingsStore'; export const SettingsView: React.FC = () => { const basePath = getBasePath(); + const { + settings, + transports, + lastTestResult, + isSaving, + isTesting, + error, + saveSuccessMessage, + fetchSettings, + updateSettings, + saveSettings, + testConnection, + } = useSettingsStore(); + + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + const zWaveTransportInfo = transports.find( + (t) => t.transportId === 'zwave_ws' || t.transportId.toLowerCase().includes('zwave') + ); + const mqttTransportInfo = transports.find( + (t) => t.transportId === 'mqtt_broker' || t.transportId.toLowerCase().includes('mqtt') + ); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + await saveSettings(); + }; + + const handleTestConnection = async () => { + await testConnection(); + }; return ( -
-
-

System & Connectivity Settings

+
+ {/* Header */} +
+
+

System & Connectivity Settings

+

+ Review integration endpoints, Home Assistant Ingress bindings, and Model Context Protocol (MCP) configuration. +

+
+ + +
+ + {/* Global Feedback Banners */} + {saveSuccessMessage && ( +
+ + {saveSuccessMessage} +
+ )} + + {error && ( +
+ + {error} +
+ )} + + {/* Z-Wave Driver & Hardware Transport */} +
+
+
+ +

Z-Wave Transport & Driver

+
+ + {zWaveTransportInfo && ( + + {zWaveTransportInfo.isConnected ? 'Connected' : zWaveTransportInfo.status} + + )} +
+

- Review integration endpoints, Home Assistant Ingress bindings, and Model Context Protocol (MCP) configuration. + Configure how CodeMaster communicates with physical Z-Wave locks, keypads, and scene controllers.

+ + {/* Segmented Radio Toggle */} +
+ +
+ + +
+
+ + {/* WebSocket Configuration */} + {settings.zWaveTransportType === 'WebSocket' ? ( +
+
+ +
+ updateSettings({ zWaveWebSocketUrl: e.target.value })} + placeholder="ws://10.0.0.10:8106" + style={{ flex: 1 }} + /> + +
+ + Direct low-latency WebSocket connection to Z-Wave JS Server (e.g. ws://10.0.0.10:8106). + +
+ + {/* Test Connection Results Card */} + {lastTestResult && ( +
+
+
+ {lastTestResult.success ? ( + + ) : ( + + )} + + {lastTestResult.success ? 'WebSocket Connected Successfully' : 'Connection Failed'} + +
+ + {lastTestResult.success && lastTestResult.latencyMs !== undefined && ( + + Latency: {lastTestResult.latencyMs}ms + + )} +
+ + {lastTestResult.success && ( +
+ {lastTestResult.serverVersion && Server: v{lastTestResult.serverVersion}} + {lastTestResult.driverVersion && Driver: v{lastTestResult.driverVersion}} + {lastTestResult.nodeCount !== undefined && Nodes: {lastTestResult.nodeCount}} +
+ )} + + {/* Detected Hardware Nodes Table */} + {lastTestResult.success && lastTestResult.detectedNodes && lastTestResult.detectedNodes.length > 0 && ( +
+ + Discovered Access Devices: + +
+ + + + + + + + + + + {lastTestResult.detectedNodes.map((node) => ( + + + + + + + ))} + +
NodeDevice NameTypeHardware Model
Node {node.nodeId}{node.name} + + {node.deviceType} + + {node.model}
+
+
+ )} + + {!lastTestResult.success && ( + + {lastTestResult.message} + + )} +
+ )} +
+ ) : ( + /* MQTT Prefix Configuration */ +
+ + updateSettings({ zWaveMqttPrefix: e.target.value })} + placeholder="zwave" + /> + + MQTT topic prefix published by Z-Wave JS UI (e.g. 'zwave'). + +
+ )} +
+ + {/* Mosquitto MQTT Broker Configuration */} +
+
+
+ +

Mosquitto MQTT Broker

+
+ + {mqttTransportInfo && ( + + {mqttTransportInfo.isConnected ? 'Connected' : mqttTransportInfo.status} + + )} +
+ +

+ Core pub/sub pipeline for Ring keypads, Zigbee contact sensors, and decoupled event dispatching. +

+ +
+
+ + updateSettings({ mqttHost: e.target.value })} + placeholder="10.0.0.10" + /> +
+ +
+ + updateSettings({ mqttPort: Number(e.target.value) || 8100 })} + placeholder="8100" + /> +
+ +
+ + updateSettings({ mqttUsername: e.target.value })} + placeholder="codemaster" + /> +
+ +
+ + updateSettings({ mqttPassword: e.target.value })} + placeholder="••••••••" + /> +
+
+
+ + {/* Apprise Notifications Card */} +
+
+ +

Apprise Notification Dispatcher

+
+ +

+ Dispatches real-time alerts for manual unlocks, auto-lock failures, lock jams, and duress PIN triggers across 80+ notification services (Discord, Telegram, Pushover, Email). +

+ +
+ + updateSettings({ appriseUrl: e.target.value })} + placeholder="e.g. discord://webhook_id/token, pfall://apikey" + /> + + Multiple services can be comma-delimited or point to an Apprise microservice container. + +
{/* Integration Status Cards */} @@ -75,6 +495,42 @@ export const SettingsView: React.FC = () => { CodeMaster never stores plain-text PINs on disk. All credential values are encrypted using AES-256-GCM (keyed from host machine secrets or DPAPI) and hashed via SHA-256 for rapid lookup.

+ + {/* Active Transports Summary */} + {transports.length > 0 && ( +
+
+ +

Active Transport Subsystems

+
+ +
+ {transports.map((t) => ( +
+
+ {t.displayName} + + {t.status} + +
+ {t.details && ( + + {t.details} + + )} +
+ ))} +
+
+ )}
); }; diff --git a/src/CodeMaster.UI/src/components/users/UserManagement.test.tsx b/src/CodeMaster.UI/src/components/users/UserManagement.test.tsx index 936cacd..0ea7629 100644 --- a/src/CodeMaster.UI/src/components/users/UserManagement.test.tsx +++ b/src/CodeMaster.UI/src/components/users/UserManagement.test.tsx @@ -49,4 +49,54 @@ describe('UserManagement component', () => { name: 'New Contractor', })); }); + + it('displays error banner inside modal when submitting an invalid short PIN', async () => { + useUserStore.setState({ users: mockUsers }); + const setPinSpy = vi.spyOn(useUserStore.getState(), 'setUserPin'); + + render(); + + // Open PIN modal + const assignBtn = screen.getAllByRole('button', { name: /Assign PIN/i })[0]; + fireEvent.click(assignBtn); + + expect(screen.getByText('Set PIN & Schedule')).toBeDefined(); + + // Enter invalid short PIN (2 digits) + const pinInput = screen.getByPlaceholderText('••••'); + fireEvent.change(pinInput, { target: { value: '12' } }); + + const submitBtn = screen.getByRole('button', { name: /Save & Sync Keypads/i }); + fireEvent.submit(submitBtn.closest('form')!); + + // Error banner should appear inside the modal + expect(screen.getByRole('alert')).toBeDefined(); + expect(screen.getByText(/PIN must be between 4 and 8 numeric digits/i)).toBeDefined(); + + // Should NOT call the API + expect(setPinSpy).not.toHaveBeenCalled(); + }); + + it('displays backend error message inside modal when API call fails', async () => { + useUserStore.setState({ users: mockUsers }); + vi.spyOn(useUserStore.getState(), 'setUserPin').mockRejectedValue( + new Error('API Error [400] Bad Request: {"error":"PIN must be between 4 and 8 numeric digits (0-9)."}') + ); + + render(); + + const assignBtn = screen.getAllByRole('button', { name: /Assign PIN/i })[0]; + fireEvent.click(assignBtn); + + const pinInput = screen.getByPlaceholderText('••••'); + fireEvent.change(pinInput, { target: { value: '1234' } }); + + const submitBtn = screen.getByRole('button', { name: /Save & Sync Keypads/i }); + fireEvent.submit(submitBtn.closest('form')!); + + // Modal stays open and displays the parsed backend error + await screen.findByRole('alert'); + expect(screen.getByText('PIN must be between 4 and 8 numeric digits (0-9).')).toBeDefined(); + expect(screen.getByText('Set PIN & Schedule')).toBeDefined(); + }); }); diff --git a/src/CodeMaster.UI/src/components/users/UserManagement.tsx b/src/CodeMaster.UI/src/components/users/UserManagement.tsx index 6369d54..e5c2883 100644 --- a/src/CodeMaster.UI/src/components/users/UserManagement.tsx +++ b/src/CodeMaster.UI/src/components/users/UserManagement.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { UserPlus, Key, Trash2, X, Check } from 'lucide-react'; +import { UserPlus, Key, Trash2, X, Check, AlertCircle } from 'lucide-react'; import { useUserStore } from '../../stores/useUserStore'; import { ScheduleEditor } from './ScheduleEditor'; import type { User, UserRoleType, AccessPolicy } from '../../types'; @@ -22,6 +22,7 @@ export const UserManagement: React.FC = () => { const [pinModalUser, setPinModalUser] = useState(null); const [pinCode, setPinCode] = useState(''); const [pinLabel, setPinLabel] = useState('Front Keypad PIN'); + const [pinModalError, setPinModalError] = useState(null); const [policyData, setPolicyData] = useState>({ name: 'Standard Access', scheduleType: 'Always', @@ -31,6 +32,18 @@ export const UserManagement: React.FC = () => { const [statusMessage, setStatusMessage] = useState(null); + const openPinModal = (user: User) => { + setPinModalUser(user); + setPinModalError(null); + setPinCode(''); + }; + + const closePinModal = () => { + setPinModalUser(null); + setPinModalError(null); + setPinCode(''); + }; + const handleCreateUser = async (e: React.FormEvent) => { e.preventDefault(); if (!newUserName.trim()) return; @@ -49,18 +62,27 @@ export const UserManagement: React.FC = () => { const handleSavePinAndSchedule = async (e: React.FormEvent) => { e.preventDefault(); - if (!pinModalUser || !pinCode.trim()) return; + if (!pinModalUser) return; + + const cleanPin = pinCode.trim(); + if (cleanPin.length < 4 || cleanPin.length > 8 || !/^\d+$/.test(cleanPin)) { + setPinModalError('PIN must be between 4 and 8 numeric digits (0-9).'); + return; + } try { - await setUserPin(pinModalUser.id, pinCode.trim(), pinLabel); + await setUserPin(pinModalUser.id, cleanPin, pinLabel); await savePolicy(pinModalUser.id, policyData); setPinModalUser(null); setPinCode(''); + setPinModalError(null); setStatusMessage(`PIN saved and synced for ${pinModalUser.name}`); setTimeout(() => setStatusMessage(null), 3000); - } catch { - setStatusMessage('Failed to save PIN'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to save PIN'; + const match = msg.match(/"error"\s*:\s*"([^"]+)"/); + setPinModalError(match ? match[1] : msg); } }; @@ -164,7 +186,7 @@ export const UserManagement: React.FC = () => {
+ {pinModalError && ( +
+ + {pinModalError} +
+ )} +
@@ -261,7 +304,13 @@ export const UserManagement: React.FC = () => { maxLength={8} placeholder="••••" value={pinCode} - onChange={(e) => setPinCode(e.target.value.replace(/\D/g, ''))} + onChange={(e) => { + setPinCode(e.target.value.replace(/\D/g, '')); + if (pinModalError) setPinModalError(null); + }} + style={{ + borderColor: pinModalError ? 'var(--status-jammed)' : undefined, + }} required />
@@ -282,7 +331,7 @@ export const UserManagement: React.FC = () => {
-