From 66195d23c8d59609ed37f568701c55196d6e7567 Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Wed, 8 Jul 2026 21:12:56 -0400 Subject: [PATCH 01/16] Add comprehensive documentation for GateMonitor project, including architecture boundaries, copilot instructions, and troubleshooting guidelines --- .../agents/gatemonitor-specialist.agent.md | 39 +++++++++ .github/copilot-instructions.md | 50 +++++++++++ .../gatemonitor-architecture.instructions.md | 26 ++++++ .github/prompts/gate-troubleshoot.prompt.md | 19 ++++ .github/prompts/gatemonitor-ui.prompt.md | 17 ++++ .github/prompts/remootio-control.prompt.md | 17 ++++ AGENTS.md | 87 +++++++++++++++++++ 7 files changed, 255 insertions(+) create mode 100644 .github/agents/gatemonitor-specialist.agent.md create mode 100644 .github/copilot-instructions.md create mode 100644 .github/instructions/gatemonitor-architecture.instructions.md create mode 100644 .github/prompts/gate-troubleshoot.prompt.md create mode 100644 .github/prompts/gatemonitor-ui.prompt.md create mode 100644 .github/prompts/remootio-control.prompt.md create mode 100644 AGENTS.md diff --git a/.github/agents/gatemonitor-specialist.agent.md b/.github/agents/gatemonitor-specialist.agent.md new file mode 100644 index 0000000..90fe143 --- /dev/null +++ b/.github/agents/gatemonitor-specialist.agent.md @@ -0,0 +1,39 @@ +--- +name: GateMonitor Specialist +description: "Use when working on the GateMonitor Angular app, remootio-angular subproject, gate state UI behavior, gate open/close control logic, or Remootio service integration." +tools: [read, edit, search, execute, todo] +model: "GPT-5 (copilot)" +--- +You are the GateMonitor project specialist for this repository. + +Your focus is to keep the project understandable and stable while improving either: +- Main app UX and behavior in src/ +- Remootio control library behavior in projects/remootio-angular/ + +## Primary Responsibilities +1. Preserve the architecture boundary: +- UI and page interactions in src/ +- Device protocol and control orchestration in projects/remootio-angular/ + +2. Keep gate control behavior explicit and safe: +- Open/Close actions should remain obvious and predictable. +- State-driven UI (open vs closed) should remain observable-based. + +3. Keep documentation aligned with behavior: +- If usage, config, or API contracts change, update repo docs. + +## Working Rules +- Do not hardcode real credentials, keys, or endpoint secrets. +- Prefer small, targeted edits over broad refactors. +- Keep public library interfaces backward compatible unless asked to change them. +- Validate changes with tests when practical. + +## Decision Heuristics +- If change request is UI-only, edit app component/template/style files under src/. +- If change request is protocol/state/control related, edit library files under projects/remootio-angular/. +- If request crosses both, keep each concern in its correct layer and connect via interfaces/observables. + +## Output Expectations +- Summarize what changed and why. +- Call out any assumptions or placeholders that still require user values. +- Provide clear next steps only when useful. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..fcfbc46 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,50 @@ +# Copilot Instructions For GateMonitor + +## Purpose +This repository contains: +- A main Angular dashboard app (gate status + image + open/close controls). +- A reusable Angular library subproject for Remootio device communication and gate control. + +## Project Intent +Generate changes that keep the app simple, reliable, and local-control focused: +- Show accurate gate state quickly. +- Keep control actions explicit and safe. +- Keep device communication details inside the remootio-angular library. + +## Architecture Boundaries +- UI and page behavior belong in src/. +- Remootio protocol/device communication belongs in projects/remootio-angular/. +- Shared types for gate/device state should come from remootioInterfaces.ts. + +Do not move low-level protocol logic into app components. + +## Coding Preferences +- Use Angular and RxJS idioms already present in the repo. +- Keep methods and changes small and readable. +- Avoid broad refactors unless requested. +- Keep naming aligned with existing gate terminology (gateState, openGate, closeGate). + +## Configuration and Secrets +- Never hardcode real credentials. +- If adding config, prefer environment-based patterns. +- Preserve existing placeholder-driven setup unless asked to redesign configuration. + +## Testing and Validation +When making changes, prefer validating with: +- Existing unit tests (npm test). +- Targeted checks for gate state flow and UI enable/disable logic. + +## Documentation Expectations +If behavior changes, update: +- README.md (main app behavior/setup) +- projects/remootio-angular/README.md (library usage/API) + +## Common Tasks +- UI improvements: update home component and templates/styles. +- Device control behavior: update remootio-angular service and related models. +- API surface changes: update public-api.ts and both READMEs. + +## Out of Scope Unless Requested +- Replacing Remootio transport/protocol approach. +- Migrating framework versions. +- Large design-system or architecture rewrites. diff --git a/.github/instructions/gatemonitor-architecture.instructions.md b/.github/instructions/gatemonitor-architecture.instructions.md new file mode 100644 index 0000000..6f42f53 --- /dev/null +++ b/.github/instructions/gatemonitor-architecture.instructions.md @@ -0,0 +1,26 @@ +--- +name: GateMonitor Architecture Boundaries +description: "Use when changing Angular UI components, gate control logic, or remootio-angular integration in this repository. Enforces separation between src app code and projects/remootio-angular control code." +applyTo: "src/**, projects/remootio-angular/**" +--- +# GateMonitor Architecture Rules + +- Keep UI concerns in src/: + - page components + - templates/styles + - view-model wiring and presentation logic + +- Keep device/control concerns in projects/remootio-angular/: + - websocket connection lifecycle + - authentication handshake + - frame/message parsing + - gate command send operations + - mapping raw state to shared interfaces + +- Prefer connecting app and library via exported interfaces and observables. + +- Avoid embedding real secrets in source files. + +- If changing library exports or integration behavior: + - update projects/remootio-angular/src/public-api.ts when required + - update root README and library README when usage changes diff --git a/.github/prompts/gate-troubleshoot.prompt.md b/.github/prompts/gate-troubleshoot.prompt.md new file mode 100644 index 0000000..51add33 --- /dev/null +++ b/.github/prompts/gate-troubleshoot.prompt.md @@ -0,0 +1,19 @@ +--- +name: GateMonitor Troubleshooting Mode +description: "Use when diagnosing gate status bugs, Remootio connection/auth issues, stale camera image behavior, or open/close button state problems in this project." +argument-hint: "Describe the bug, error, or unexpected behavior" +agent: "GateMonitor Specialist" +--- +Diagnose and fix the reported issue in GateMonitor. + +Troubleshooting process: +1. Reproduce or infer the failure path from code and available tests. +2. Identify root cause with smallest reliable fix. +3. Add or update tests when appropriate. +4. Verify no regression in gate-state and control flow. + +Output: +- Root cause +- Exact fix made +- Validation performed +- Remaining risks or follow-ups diff --git a/.github/prompts/gatemonitor-ui.prompt.md b/.github/prompts/gatemonitor-ui.prompt.md new file mode 100644 index 0000000..805fced --- /dev/null +++ b/.github/prompts/gatemonitor-ui.prompt.md @@ -0,0 +1,17 @@ +--- +name: GateMonitor UI Work Mode +description: "Use when improving or refactoring the GateMonitor Angular UI in src/, especially home page layout, status display, camera image behavior, and gate control buttons." +argument-hint: "Describe the UI change you want in the main app" +agent: "GateMonitor Specialist" +--- +Implement the requested UI change in the main GateMonitor app under src/. + +Requirements: +- Keep existing gate-state semantics intact. +- Preserve open/close safety and button enable/disable behavior. +- Do not move protocol/device logic out of the remootio-angular library. +- Keep styles and templates consistent with current app structure. + +After edits: +- Run appropriate checks or tests when practical. +- Summarize changed files and behavioral impact. diff --git a/.github/prompts/remootio-control.prompt.md b/.github/prompts/remootio-control.prompt.md new file mode 100644 index 0000000..ef6bbb2 --- /dev/null +++ b/.github/prompts/remootio-control.prompt.md @@ -0,0 +1,17 @@ +--- +name: Remootio Control Library Mode +description: "Use when updating the remootio-angular subproject control layer, including connection/auth flow, message parsing, gate state mapping, public interfaces, and API behavior." +argument-hint: "Describe the remootio library/control change you want" +agent: "GateMonitor Specialist" +--- +Implement the requested change in projects/remootio-angular/. + +Requirements: +- Keep low-level control and protocol logic in the library. +- Preserve observable-first integration patterns for Angular consumers. +- Minimize breaking API changes. +- If public interfaces or exported symbols change, update public-api and docs. + +After edits: +- Run relevant tests/build checks when practical. +- Summarize API or behavior changes and migration notes if needed. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..aefb414 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,87 @@ +# AGENTS.md + +## Project Overview +GateMonitor is an Angular application that provides a simple local dashboard for checking and controlling a driveway gate. + +Primary use case: +- Show whether the gate is currently Open or Closed. +- Show a near-live camera snapshot from the gate area. +- Let the user send Open/Close commands to the gate controller. + +This app is built around a Remootio smart gate controller connected to a Ghost Controls gate system. + +## Workspace Structure +- Main application: src/ +- Angular library subproject: projects/remootio-angular/ + +## What The Main App Does +The main app is a single-page Angular UI with a home screen. + +Main behaviors: +- Connects to the Remootio device over WebSocket on startup. +- Authenticates using API values configured in the Home component. +- Subscribes to gate state updates through observables. +- Displays gate state and enables/disables Open/Close buttons based on current state. +- Refreshes a gate camera image URL every second with a cache-busting query value. + +Key implementation point: +- Home component uses RemootioAngularService and reacts to gateState$. + +## What The Subproject/Control Library Does +Subproject: projects/remootio-angular + +The remootio-angular library is the control layer for the app. +It wraps low-level device communication and exposes Angular-friendly observables and methods. + +Core responsibilities: +- Manage connection lifecycle to the Remootio device. +- Authenticate after connection. +- Parse incoming Remootio messages and state change events. +- Map device sensor state to a simple IGateState model. +- Expose control methods: openGate() and closeGate(). + +Public API surface includes: +- RemootioAngularService +- IConnectionStatus +- IRemootioDeviceConfig +- IGateState + +## Runtime Flow (High Level) +1. App starts and Home component calls connect(...). +2. Service opens WebSocket to the device. +3. Service authenticates and queries current state. +4. Incoming messages are parsed into Open/Closed state. +5. UI updates from observable stream. +6. User presses Open/Close and service sends the command. + +## Configuration Notes +The app currently uses placeholders in Home component for: +- deviceIp +- apiSecretKey +- apiAuthKey +- gate image URL + +Do not commit real secrets. +If productionizing, move these values to environment config or secure local config. + +## Build and Run +From repository root: +1. npm install +2. npm install --prefix .\\projects\\remootio-angular\\ +3. ng build remootio-angular +4. ng serve -o + +## Agent Guidance For This Repo +When modifying code in this repo: +- Preserve the separation between UI logic (src/) and control protocol logic (projects/remootio-angular/). +- Keep public interfaces in remootio-angular stable unless explicitly changing the API. +- Prefer observable-based state updates over polling for gate status. +- Avoid embedding sensitive values in source files. +- Update README files when behavior or setup changes. + +## Useful Files +- Root app overview: README.md +- Home UI logic: src/app/pages/home/home.component.ts +- Library service: projects/remootio-angular/src/lib/services/remootio-angular.service.ts +- Library interfaces: projects/remootio-angular/src/lib/services/remootioInterfaces.ts +- Library package docs: projects/remootio-angular/README.md From 5d8a1c59c2ce4873000517d388101e53a56d7223 Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Thu, 9 Jul 2026 00:18:50 -0400 Subject: [PATCH 02/16] Modernize repo: add Blazor/.NET, CI/CD, docs, tests - Add dotnet/ subtree: Blazor WASM app, myNOC.Remootio .NET library (NuGet-ready), Aspire AppHost, and MSTest/bUnit test projects - Implement Remootio WebSocket protocol, crypto, and gate state events in .NET - Add unified CI/CD: PR build/test, NuGet/NPM publish, GitHub release/tag - Add architecture/agent instructions and update .gitignore for .NET/Angular/Aspire/secrets - Update documentation for both Angular and .NET stacks - Add Blazor layouts, navigation, gate UI, and CSS - Add comprehensive unit/component tests for .NET and Blazor - Add MIT license for .NET library - Add @types/crypto-js to Angular devDependencies for type safety - Remove legacy remootio-angular-deploy.yml workflow --- .github/agents/dotnet-specialist.agent.md | 51 ++++ .../agents/gatemonitor-specialist.agent.md | 25 +- .github/copilot-instructions.md | 71 +++-- .../dotnet-architecture.instructions.md | 59 ++++ .github/workflows/dotnet-build.yml | 83 ++++++ .github/workflows/dotnet-release.yml | 109 +++++++ .github/workflows/remootio-angular-deploy.yml | 38 --- .gitignore | 99 +++++-- .vscode/launch.json | 21 +- .vscode/tasks.json | 29 ++ GitVersion.yml | 8 + README.md | 137 +++++++-- angular.json => angular/angular.json | 0 karma.conf.js => angular/karma.conf.js | 0 .../package-lock.json | 14 + package.json => angular/package.json | 4 +- .../projects}/remootio-angular/README.md | 0 .../projects}/remootio-angular/karma.conf.js | 0 .../remootio-angular/ng-package.json | 0 .../remootio-angular/npm/package-lock.json | 0 .../remootio-angular/npm/package.json | 0 .../remootio-angular/package-lock.json | 0 .../projects}/remootio-angular/package.json | 0 .../services/remootio-angular.service.spec.ts | 0 .../lib/services/remootio-angular.service.ts | 0 .../src/lib/services/remootioApiCrypto.ts | 0 .../src/lib/services/remootioDevice.ts | 0 .../src/lib/services/remootioDeviceEvents.ts | 0 .../src/lib/services/remootioFrames.ts | 0 .../src/lib/services/remootioInterfaces.ts | 0 .../remootio-angular/src/public-api.ts | 0 .../projects}/remootio-angular/src/test.ts | 0 .../remootio-angular/tsconfig.lib.json | 0 .../remootio-angular/tsconfig.lib.prod.json | 0 .../remootio-angular/tsconfig.spec.json | 0 .../src}/app/app-routing.module.ts | 0 {src => angular/src}/app/app.module.ts | 0 .../src}/app/footer/footer.component.html | 0 .../src}/app/footer/footer.component.scss | 0 .../src}/app/footer/footer.component.spec.ts | 0 .../src}/app/footer/footer.component.ts | 0 .../src}/app/pages/home/home.component.html | 0 .../src}/app/pages/home/home.component.scss | 0 .../app/pages/home/home.component.spec.ts | 0 .../src}/app/pages/home/home.component.ts | 0 .../src}/app/root/app.component.html | 0 .../src}/app/root/app.component.scss | 0 .../src}/app/root/app.component.spec.ts | 0 .../src}/app/root/app.component.ts | 0 {src => angular/src}/assets/.gitkeep | 0 .../src}/environments/environment.prod.ts | 0 .../src}/environments/environment.ts | 0 {src => angular/src}/favicon.ico | Bin {src => angular/src}/index.html | 0 {src => angular/src}/main.ts | 0 {src => angular/src}/polyfills.ts | 0 {src => angular/src}/styles.scss | 0 {src => angular/src}/test.ts | 0 .../tsconfig.app.json | 0 tsconfig.json => angular/tsconfig.json | 0 .../tsconfig.spec.json | 0 dotnet/GateMonitor.AppHost/AppHost.cs | 10 + .../GateMonitor.AppHost.csproj | 16 ++ .../Properties/launchSettings.json | 29 ++ dotnet/GateMonitor.AppHost/appsettings.json | 9 + dotnet/GateMonitor.AppHost/aspire.config.json | 5 + .../GateMonitor.Blazor/Components/App.razor | 1 + .../Components/Layout/MainLayout.razor | 23 ++ .../Components/Layout/MainLayout.razor.css | 98 +++++++ .../Components/Layout/NavMenu.razor | 18 ++ .../Components/Layout/NavMenu.razor.css | 105 +++++++ .../Components/Pages/Counter.razor | 19 ++ .../Components/Pages/Error.razor | 22 ++ .../Components/Pages/Home.razor | 88 ++++++ .../Components/Pages/Home.razor.css | 23 ++ .../Components/Pages/NotFound.razor | 5 + .../Components/Routes.razor | 6 + .../Components/_Imports.razor | 12 + .../GateMonitor.Blazor.csproj | 19 ++ dotnet/GateMonitor.Blazor/Program.cs | 16 ++ .../Properties/launchSettings.json | 15 + dotnet/GateMonitor.Blazor/appsettings.json | 17 ++ dotnet/GateMonitor.Blazor/wwwroot/app.css | 60 ++++ .../wwwroot/appsettings.json | 10 + dotnet/GateMonitor.Blazor/wwwroot/favicon.png | Bin 0 -> 1148 bytes dotnet/GateMonitor.Blazor/wwwroot/index.html | 17 ++ dotnet/GateMonitor.slnx | 9 + dotnet/myNOC.Remootio/GateState.cs | 7 + dotnet/myNOC.Remootio/IRemootioService.cs | 11 + dotnet/myNOC.Remootio/LICENSE.txt | 21 ++ dotnet/myNOC.Remootio/README.md | 108 +++++++ dotnet/myNOC.Remootio/RemootioApiCrypto.cs | 119 ++++++++ dotnet/myNOC.Remootio/RemootioDevice.cs | 267 ++++++++++++++++++ dotnet/myNOC.Remootio/RemootioDeviceConfig.cs | 11 + dotnet/myNOC.Remootio/RemootioService.cs | 95 +++++++ dotnet/myNOC.Remootio/myNOC.Remootio.csproj | 63 +++++ .../GateMonitor.Tests.Blazor.csproj | 29 ++ .../HomeComponentTests.cs | 122 ++++++++ .../tests/GateMonitor.Tests.Blazor/Usings.cs | 8 + .../myNOC.Tests.Remootio/GateStateTests.cs | 30 ++ .../RemootioApiCryptoTests.cs | 122 ++++++++ dotnet/tests/myNOC.Tests.Remootio/Usings.cs | 2 + .../myNOC.Tests.Remootio.csproj | 26 ++ 103 files changed, 2322 insertions(+), 119 deletions(-) create mode 100644 .github/agents/dotnet-specialist.agent.md create mode 100644 .github/instructions/dotnet-architecture.instructions.md create mode 100644 .github/workflows/dotnet-build.yml create mode 100644 .github/workflows/dotnet-release.yml delete mode 100644 .github/workflows/remootio-angular-deploy.yml create mode 100644 GitVersion.yml rename angular.json => angular/angular.json (100%) rename karma.conf.js => angular/karma.conf.js (100%) rename package-lock.json => angular/package-lock.json (99%) rename package.json => angular/package.json (94%) rename {projects => angular/projects}/remootio-angular/README.md (100%) rename {projects => angular/projects}/remootio-angular/karma.conf.js (100%) rename {projects => angular/projects}/remootio-angular/ng-package.json (100%) rename {projects => angular/projects}/remootio-angular/npm/package-lock.json (100%) rename {projects => angular/projects}/remootio-angular/npm/package.json (100%) rename {projects => angular/projects}/remootio-angular/package-lock.json (100%) rename {projects => angular/projects}/remootio-angular/package.json (100%) rename {projects => angular/projects}/remootio-angular/src/lib/services/remootio-angular.service.spec.ts (100%) rename {projects => angular/projects}/remootio-angular/src/lib/services/remootio-angular.service.ts (100%) rename {projects => angular/projects}/remootio-angular/src/lib/services/remootioApiCrypto.ts (100%) rename {projects => angular/projects}/remootio-angular/src/lib/services/remootioDevice.ts (100%) rename {projects => angular/projects}/remootio-angular/src/lib/services/remootioDeviceEvents.ts (100%) rename {projects => angular/projects}/remootio-angular/src/lib/services/remootioFrames.ts (100%) rename {projects => angular/projects}/remootio-angular/src/lib/services/remootioInterfaces.ts (100%) rename {projects => angular/projects}/remootio-angular/src/public-api.ts (100%) rename {projects => angular/projects}/remootio-angular/src/test.ts (100%) rename {projects => angular/projects}/remootio-angular/tsconfig.lib.json (100%) rename {projects => angular/projects}/remootio-angular/tsconfig.lib.prod.json (100%) rename {projects => angular/projects}/remootio-angular/tsconfig.spec.json (100%) rename {src => angular/src}/app/app-routing.module.ts (100%) rename {src => angular/src}/app/app.module.ts (100%) rename {src => angular/src}/app/footer/footer.component.html (100%) rename {src => angular/src}/app/footer/footer.component.scss (100%) rename {src => angular/src}/app/footer/footer.component.spec.ts (100%) rename {src => angular/src}/app/footer/footer.component.ts (100%) rename {src => angular/src}/app/pages/home/home.component.html (100%) rename {src => angular/src}/app/pages/home/home.component.scss (100%) rename {src => angular/src}/app/pages/home/home.component.spec.ts (100%) rename {src => angular/src}/app/pages/home/home.component.ts (100%) rename {src => angular/src}/app/root/app.component.html (100%) rename {src => angular/src}/app/root/app.component.scss (100%) rename {src => angular/src}/app/root/app.component.spec.ts (100%) rename {src => angular/src}/app/root/app.component.ts (100%) rename {src => angular/src}/assets/.gitkeep (100%) rename {src => angular/src}/environments/environment.prod.ts (100%) rename {src => angular/src}/environments/environment.ts (100%) rename {src => angular/src}/favicon.ico (100%) rename {src => angular/src}/index.html (100%) rename {src => angular/src}/main.ts (100%) rename {src => angular/src}/polyfills.ts (100%) rename {src => angular/src}/styles.scss (100%) rename {src => angular/src}/test.ts (100%) rename tsconfig.app.json => angular/tsconfig.app.json (100%) rename tsconfig.json => angular/tsconfig.json (100%) rename tsconfig.spec.json => angular/tsconfig.spec.json (100%) create mode 100644 dotnet/GateMonitor.AppHost/AppHost.cs create mode 100644 dotnet/GateMonitor.AppHost/GateMonitor.AppHost.csproj create mode 100644 dotnet/GateMonitor.AppHost/Properties/launchSettings.json create mode 100644 dotnet/GateMonitor.AppHost/appsettings.json create mode 100644 dotnet/GateMonitor.AppHost/aspire.config.json create mode 100644 dotnet/GateMonitor.Blazor/Components/App.razor create mode 100644 dotnet/GateMonitor.Blazor/Components/Layout/MainLayout.razor create mode 100644 dotnet/GateMonitor.Blazor/Components/Layout/MainLayout.razor.css create mode 100644 dotnet/GateMonitor.Blazor/Components/Layout/NavMenu.razor create mode 100644 dotnet/GateMonitor.Blazor/Components/Layout/NavMenu.razor.css create mode 100644 dotnet/GateMonitor.Blazor/Components/Pages/Counter.razor create mode 100644 dotnet/GateMonitor.Blazor/Components/Pages/Error.razor create mode 100644 dotnet/GateMonitor.Blazor/Components/Pages/Home.razor create mode 100644 dotnet/GateMonitor.Blazor/Components/Pages/Home.razor.css create mode 100644 dotnet/GateMonitor.Blazor/Components/Pages/NotFound.razor create mode 100644 dotnet/GateMonitor.Blazor/Components/Routes.razor create mode 100644 dotnet/GateMonitor.Blazor/Components/_Imports.razor create mode 100644 dotnet/GateMonitor.Blazor/GateMonitor.Blazor.csproj create mode 100644 dotnet/GateMonitor.Blazor/Program.cs create mode 100644 dotnet/GateMonitor.Blazor/Properties/launchSettings.json create mode 100644 dotnet/GateMonitor.Blazor/appsettings.json create mode 100644 dotnet/GateMonitor.Blazor/wwwroot/app.css create mode 100644 dotnet/GateMonitor.Blazor/wwwroot/appsettings.json create mode 100644 dotnet/GateMonitor.Blazor/wwwroot/favicon.png create mode 100644 dotnet/GateMonitor.Blazor/wwwroot/index.html create mode 100644 dotnet/GateMonitor.slnx create mode 100644 dotnet/myNOC.Remootio/GateState.cs create mode 100644 dotnet/myNOC.Remootio/IRemootioService.cs create mode 100644 dotnet/myNOC.Remootio/LICENSE.txt create mode 100644 dotnet/myNOC.Remootio/README.md create mode 100644 dotnet/myNOC.Remootio/RemootioApiCrypto.cs create mode 100644 dotnet/myNOC.Remootio/RemootioDevice.cs create mode 100644 dotnet/myNOC.Remootio/RemootioDeviceConfig.cs create mode 100644 dotnet/myNOC.Remootio/RemootioService.cs create mode 100644 dotnet/myNOC.Remootio/myNOC.Remootio.csproj create mode 100644 dotnet/tests/GateMonitor.Tests.Blazor/GateMonitor.Tests.Blazor.csproj create mode 100644 dotnet/tests/GateMonitor.Tests.Blazor/HomeComponentTests.cs create mode 100644 dotnet/tests/GateMonitor.Tests.Blazor/Usings.cs create mode 100644 dotnet/tests/myNOC.Tests.Remootio/GateStateTests.cs create mode 100644 dotnet/tests/myNOC.Tests.Remootio/RemootioApiCryptoTests.cs create mode 100644 dotnet/tests/myNOC.Tests.Remootio/Usings.cs create mode 100644 dotnet/tests/myNOC.Tests.Remootio/myNOC.Tests.Remootio.csproj diff --git a/.github/agents/dotnet-specialist.agent.md b/.github/agents/dotnet-specialist.agent.md new file mode 100644 index 0000000..8a13bf3 --- /dev/null +++ b/.github/agents/dotnet-specialist.agent.md @@ -0,0 +1,51 @@ +--- +name: GateMonitor .NET Specialist +description: "Use when working on the Blazor WASM dashboard, myNOC.Remootio library, Aspire AppHost, .NET unit tests, NuGet packaging, or the dotnet-build/dotnet-release CI pipelines." +tools: [read, edit, search, execute, todo] +model: "GPT-5 (copilot)" +--- +You are the .NET specialist for the GateMonitor repository. + +Your focus is the `dotnet/` subtree: the Blazor WASM app, the myNOC.Remootio library, the Aspire AppHost, and the GitHub Actions CI/CD pipelines. + +## Primary Responsibilities + +1. **Enforce architecture boundaries:** + - `myNOC.Remootio` — pure .NET library; no Blazor/UI dependencies allowed + - `GateMonitor.Blazor` — UI only; consumes `IRemootioService` via DI, never internal types + - `GateMonitor.AppHost` — Aspire orchestrator; no business logic + +2. **Keep the library NuGet-publishable:** + - Targets `net8.0;net10.0` (drop EOL versions when needed) + - `GitVersion.MsBuild` sets versions from git tags + - `README.md` and `LICENSE.txt` bundled into the package + - `SourceLink` enabled for debugger source-stepping + +3. **Keep Blazor WASM patterns correct:** + - Always use `await InvokeAsync(StateHasChanged)` from event handlers + - Dispose event subscriptions in `IDisposable.Dispose()` + - Config from `wwwroot/appsettings.json` — no secrets committed + +4. **Maintain test coverage:** + - `myNOC.Tests.Remootio` — MSTest + NSubstitute, matches library TFMs + - `GateMonitor.Tests.Blazor` — MSTest + bUnit + NSubstitute, net10.0 only + - Tests run in `dotnet-build.yml` on every PR + +## Working Rules + +- Do not commit real Remootio API keys or device IPs. +- Prefer small targeted edits; avoid broad refactors unless requested. +- Keep `IRemootioService` public API backward-compatible unless explicitly changing it. +- Use NSubstitute (not Moq) for mocking — consistent with sibling project myNOC.WeatherLink. +- Validate changes with `dotnet build GateMonitor.slnx` and `dotnet test GateMonitor.slnx`. + +## Key Files + +| File | Purpose | +|------|---------| +| `dotnet/myNOC.Remootio/RemootioService.cs` | Hosted service — connection lifecycle | +| `dotnet/myNOC.Remootio/RemootioDevice.cs` | WebSocket client + protocol | +| `dotnet/myNOC.Remootio/RemootioApiCrypto.cs` | AES-CBC + HMAC-SHA256 (internal) | +| `dotnet/GateMonitor.Blazor/Components/Pages/Home.razor` | Main dashboard component | +| `dotnet/GateMonitor.AppHost/AppHost.cs` | Aspire resource declarations | +| `.github/workflows/dotnet-release.yml` | Release pipeline (NuGet + NPM + tag) | diff --git a/.github/agents/gatemonitor-specialist.agent.md b/.github/agents/gatemonitor-specialist.agent.md index 90fe143..e2ccc24 100644 --- a/.github/agents/gatemonitor-specialist.agent.md +++ b/.github/agents/gatemonitor-specialist.agent.md @@ -1,19 +1,19 @@ --- name: GateMonitor Specialist -description: "Use when working on the GateMonitor Angular app, remootio-angular subproject, gate state UI behavior, gate open/close control logic, or Remootio service integration." +description: "Use when working on the GateMonitor Angular app, remootio-angular subproject, gate state UI behavior, gate open/close control logic, or Remootio service integration. For .NET/Blazor/Aspire work use the GateMonitor .NET Specialist agent instead." tools: [read, edit, search, execute, todo] model: "GPT-5 (copilot)" --- -You are the GateMonitor project specialist for this repository. +You are the GateMonitor Angular project specialist for this repository. -Your focus is to keep the project understandable and stable while improving either: -- Main app UX and behavior in src/ -- Remootio control library behavior in projects/remootio-angular/ +Your focus is the `angular/` subtree: the Angular dashboard app and the `remootio-angular` Angular library. + +> For work in `dotnet/` (Blazor, myNOC.Remootio, Aspire, CI pipelines) use the **GateMonitor .NET Specialist** agent instead. ## Primary Responsibilities 1. Preserve the architecture boundary: -- UI and page interactions in src/ -- Device protocol and control orchestration in projects/remootio-angular/ +- UI and page interactions → `angular/src/` +- Device protocol and control orchestration → `angular/projects/remootio-angular/` 2. Keep gate control behavior explicit and safe: - Open/Close actions should remain obvious and predictable. @@ -26,7 +26,16 @@ Your focus is to keep the project understandable and stable while improving eith - Do not hardcode real credentials, keys, or endpoint secrets. - Prefer small, targeted edits over broad refactors. - Keep public library interfaces backward compatible unless asked to change them. -- Validate changes with tests when practical. +- Validate changes with tests when practical (`npm test` in `angular/`). + +## Key Files + +| File | Purpose | +|------|---------| +| `angular/src/app/pages/home/home.component.ts` | Main dashboard — gate state subscriptions | +| `angular/projects/remootio-angular/src/lib/services/remootio-angular.service.ts` | Angular service | +| `angular/projects/remootio-angular/src/lib/services/remootioDevice.ts` | WebSocket client | +| `angular/projects/remootio-angular/src/lib/services/remootioInterfaces.ts` | Shared types | ## Decision Heuristics - If change request is UI-only, edit app component/template/style files under src/. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fcfbc46..a350c58 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,50 +1,73 @@ # Copilot Instructions For GateMonitor ## Purpose -This repository contains: -- A main Angular dashboard app (gate status + image + open/close controls). -- A reusable Angular library subproject for Remootio device communication and gate control. + +This repository contains two equivalent front-end dashboards for a Remootio-controlled driveway gate, plus a shared .NET library: + +| Subtree | Stack | Description | +|---------|-------|-------------| +| `angular/` | Angular 15 + RxJS | Original dashboard + remootio-angular NPM library | +| `dotnet/GateMonitor.Blazor` | Blazor WASM (.NET 10) | Blazor equivalent dashboard | +| `dotnet/myNOC.Remootio` | .NET 8/10 library | Remootio protocol → NuGet: myNOC.Remootio | +| `dotnet/GateMonitor.AppHost` | .NET Aspire | Dev orchestrator for the Blazor app | ## Project Intent -Generate changes that keep the app simple, reliable, and local-control focused: + - Show accurate gate state quickly. - Keep control actions explicit and safe. -- Keep device communication details inside the remootio-angular library. +- Keep device communication details inside the libraries (not in UI components). +- Both front-ends connect directly from the browser to the Remootio WebSocket. ## Architecture Boundaries -- UI and page behavior belong in src/. -- Remootio protocol/device communication belongs in projects/remootio-angular/. -- Shared types for gate/device state should come from remootioInterfaces.ts. -Do not move low-level protocol logic into app components. +**Angular (`angular/`):** +- UI and page behavior → `src/` +- Remootio protocol/device communication → `projects/remootio-angular/` +- Shared types → `remootioInterfaces.ts` + +**Blazor / .NET (`dotnet/`):** +- UI and components → `GateMonitor.Blazor/Components/` +- Remootio protocol, crypto, WebSocket → `myNOC.Remootio/` library only +- `IRemootioService` is the only public interface components should use +- `GateMonitor.AppHost` → Aspire orchestration only, no business logic + +Do not move low-level protocol logic into UI components in either codebase. ## Coding Preferences -- Use Angular and RxJS idioms already present in the repo. -- Keep methods and changes small and readable. + +- Angular: use Angular and RxJS idioms already present in the repo. +- Blazor: use `await InvokeAsync(StateHasChanged)` for thread-safe UI updates; dispose event subscriptions. +- Keep methods small and readable. - Avoid broad refactors unless requested. -- Keep naming aligned with existing gate terminology (gateState, openGate, closeGate). +- Keep naming aligned with gate terminology: `gateState`, `openGate`, `closeGate`, `IsOpen`, `Description`. ## Configuration and Secrets -- Never hardcode real credentials. -- If adding config, prefer environment-based patterns. -- Preserve existing placeholder-driven setup unless asked to redesign configuration. + +- Never hardcode real credentials (`deviceIp`, `apiSecretKey`, `apiAuthKey`). +- Angular: placeholders in `home.component.ts`. +- Blazor: placeholders in `wwwroot/appsettings.json`; use `appsettings.Development.json` for real values locally. ## Testing and Validation -When making changes, prefer validating with: -- Existing unit tests (npm test). -- Targeted checks for gate state flow and UI enable/disable logic. + +- Angular: `npm test` in `angular/` +- .NET: `dotnet test dotnet/GateMonitor.slnx` +- Both run automatically in `dotnet-build.yml` on PRs. ## Documentation Expectations + If behavior changes, update: -- README.md (main app behavior/setup) -- projects/remootio-angular/README.md (library usage/API) +- `README.md` (repo overview) +- `angular/projects/remootio-angular/README.md` (Angular library) +- `dotnet/myNOC.Remootio/README.md` (.NET library) -## Common Tasks -- UI improvements: update home component and templates/styles. -- Device control behavior: update remootio-angular service and related models. -- API surface changes: update public-api.ts and both READMEs. +## Agent Guidance + +- **Angular UI / remootio-angular**: use the **GateMonitor Specialist** agent. +- **Blazor / myNOC.Remootio / Aspire / CI pipelines**: use the **GateMonitor .NET Specialist** agent. ## Out of Scope Unless Requested + - Replacing Remootio transport/protocol approach. - Migrating framework versions. - Large design-system or architecture rewrites. + diff --git a/.github/instructions/dotnet-architecture.instructions.md b/.github/instructions/dotnet-architecture.instructions.md new file mode 100644 index 0000000..e685916 --- /dev/null +++ b/.github/instructions/dotnet-architecture.instructions.md @@ -0,0 +1,59 @@ +--- +applyTo: "dotnet/**" +--- + +# .NET Architecture — GateMonitor + +## Solution Layout + +``` +dotnet/ +├── GateMonitor.AppHost/ Aspire orchestrator — dev entry point +├── GateMonitor.Blazor/ Blazor WASM SPA — Components/, wwwroot/ +├── myNOC.Remootio/ .NET library → NuGet package +└── tests/ + ├── myNOC.Tests.Remootio/ MSTest + NSubstitute — library unit tests + └── GateMonitor.Tests.Blazor/ MSTest + bUnit + NSubstitute — component tests +``` + +## Architecture Boundaries + +- **`myNOC.Remootio`** owns ALL Remootio protocol logic: WebSocket lifecycle, AES-CBC/HMAC-SHA256 crypto, authentication flow, gate state mapping. + - Public surface: `IRemootioService`, `RemootioService`, `GateState`, `RemootioDeviceConfig` + - Internal: `RemootioDevice`, `RemootioApiCrypto` (exposed to test project via InternalsVisibleTo) + - Do NOT add Blazor/UI dependencies here — it must remain a pure .NET library. + +- **`GateMonitor.Blazor`** owns the UI: Razor components, wwwroot static assets, and `Program.cs`. + - Consumes `IRemootioService` via DI injection — never references `RemootioDevice` or `RemootioApiCrypto` directly. + - Config is read from `wwwroot/appsettings.json` (public, no secrets at rest). + +- **`GateMonitor.AppHost`** is the Aspire orchestrator for local development only. + - References `GateMonitor.Blazor` as an Aspire project resource. + - No business logic here — only resource declarations. + +## Blazor Component Patterns + +- Components use `@inject IRemootioService` and `@inject IConfiguration` — no direct `RemootioService` references. +- Subscribe to `GateStateChanged` / `ConnectionChanged` events in `OnInitialized`, unsubscribe in `Dispose()`. +- Always call `await InvokeAsync(StateHasChanged)` from event handlers (thread-safe UI updates). +- Gate image refresh uses `System.Timers.Timer` started only when `GateImageUrl` is non-empty. + +## Library Versioning + +- `myNOC.Remootio` uses `GitVersion.MsBuild` with `GitHubFlow/v1` workflow. +- Targets `net8.0` and `net10.0` (net9.0 removed — EOL May 2026). +- `GeneratePackageOnBuild` is `false` — pack explicitly via `dotnet pack` or the release pipeline. + +## Testing + +- Library tests target the same TFMs as the library (`net8.0;net10.0`). +- Component tests target `net10.0` only. +- `bUnit.TestContext` aliased as `BunitContext` to avoid ambiguity with `MSTest.TestContext`. +- `System.Timers.Timer` is not started in tests because `GateImageUrl` is set to empty string in test setup. +- Use `NSubstitute` for mocking (not Moq) — consistent with the WeatherLink sibling project. + +## Secrets + +- Never commit real `DeviceIp`, `ApiSecretKey`, or `ApiAuthKey` values. +- Use `appsettings.Development.json` (git-ignored) for local overrides. +- The release pipeline uses `NUGET_PUBLISH` and `npm_token` repository secrets. diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml new file mode 100644 index 0000000..a5c900d --- /dev/null +++ b/.github/workflows/dotnet-build.yml @@ -0,0 +1,83 @@ +name: Build + +on: + pull_request: + branches: + - main + +jobs: + version: + runs-on: ubuntu-latest + outputs: + nuGetVersion: ${{ steps.gitversion.outputs.nuGetVersion }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v3 + with: + versionSpec: '6.x' + + - name: Determine Version + id: gitversion + uses: gittools/actions/gitversion/execute@v3 + with: + useConfigFile: true + + - name: Echo version + run: echo "Build version ${{ steps.gitversion.outputs.nuGetVersion }}" + + build-dotnet: + needs: version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 10.x + + - name: Restore + run: dotnet restore dotnet/GateMonitor.slnx + + - name: Build + run: dotnet build dotnet/GateMonitor.slnx --no-restore + + - name: Test + run: dotnet test dotnet/GateMonitor.slnx --no-restore --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" + + - name: Test Pack nuget + run: | + dotnet pack dotnet/myNOC.Remootio/myNOC.Remootio.csproj --no-build --no-restore --include-symbols --verbosity normal --configuration Debug -p:PackageVersion="${{ needs.version.outputs.nuGetVersion }}" + + build-angular: + needs: version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + working-directory: angular + + - name: Install library dependencies + run: npm install + working-directory: angular + + - name: Set library version + run: npm version ${{ needs.version.outputs.nuGetVersion }} --no-git-tag-version --allow-same-version + working-directory: angular/projects/remootio-angular + + - name: Build Angular library + run: npm run buildService + working-directory: angular diff --git a/.github/workflows/dotnet-release.yml b/.github/workflows/dotnet-release.yml new file mode 100644 index 0000000..47ddad4 --- /dev/null +++ b/.github/workflows/dotnet-release.yml @@ -0,0 +1,109 @@ +name: Release + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + version: + runs-on: ubuntu-latest + outputs: + nuGetVersion: ${{ steps.gitversion.outputs.nuGetVersion }} + assemblySemVer: ${{ steps.gitversion.outputs.assemblySemVer }} + assemblySemFileVer: ${{ steps.gitversion.outputs.assemblySemFileVer }} + informationalVersion: ${{ steps.gitversion.outputs.informationalVersion }} + preReleaseLabel: ${{ steps.gitversion.outputs.preReleaseLabel }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v3 + with: + versionSpec: '6.x' + + - name: Determine Version + id: gitversion + uses: gittools/actions/gitversion/execute@v3 + with: + useConfigFile: true + + deploy-dotnet: + needs: version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 10.x + + - name: Restore + run: dotnet restore dotnet/GateMonitor.slnx + + - name: Build + run: | + dotnet build dotnet/myNOC.Remootio/myNOC.Remootio.csproj --configuration Release --no-restore /p:Version="${{ needs.version.outputs.assemblySemVer }}" /p:AssemblyVersion="${{ needs.version.outputs.assemblySemVer }}" /p:FileVersion="${{ needs.version.outputs.assemblySemFileVer }}" /p:InformationalVersion="${{ needs.version.outputs.informationalVersion }}" /p:PackageVersion="${{ needs.version.outputs.nuGetVersion }}" /p:ContinuousIntegrationBuild=true + + - name: Pack nuget + run: | + dotnet pack dotnet/myNOC.Remootio/myNOC.Remootio.csproj --configuration Release --no-build --no-restore --include-symbols --output ./.pack --verbosity normal -p:PackageVersion="${{ needs.version.outputs.nuGetVersion }}" + + - name: Nuget Push + run: | + dotnet nuget push "**/*.nupkg" --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_PUBLISH }} --skip-duplicate + + deploy-angular: + needs: version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org/ + + - name: Install dependencies + run: npm ci + working-directory: angular + + - name: Install library dependencies + run: npm install + working-directory: angular + + - name: Set library version + run: npm version ${{ needs.version.outputs.nuGetVersion }} --no-git-tag-version --allow-same-version + working-directory: angular/projects/remootio-angular + + - name: Deploy Angular library to NPM + run: npm run deployService + working-directory: angular + env: + NODE_AUTH_TOKEN: ${{ secrets.npm_token }} + + tag: + needs: [version, deploy-dotnet, deploy-angular] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Create GitHub Release and Tag + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: v${{ needs.version.outputs.nuGetVersion }} + name: v${{ needs.version.outputs.nuGetVersion }} + generate_release_notes: true + prerelease: ${{ needs.version.outputs.preReleaseLabel != '' }} + diff --git a/.github/workflows/remootio-angular-deploy.yml b/.github/workflows/remootio-angular-deploy.yml deleted file mode 100644 index 59e574a..0000000 --- a/.github/workflows/remootio-angular-deploy.yml +++ /dev/null @@ -1,38 +0,0 @@ -# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created -# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages - -name: Deploy Remootio Angular Service to NPM - -on: - release: - types: [created] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: 16 - - run: npm ci - - run: npm install - - run: npm install ./projects/remootio-angular - - run: npm run buildService - # - run: npm test - - publish-npm: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: 16 - registry-url: https://registry.npmjs.org/ - - run: npm ci - - run: npm install - - run: npm install ./projects/remootio-angular - - run: npm run deployService - env: - NODE_AUTH_TOKEN: ${{secrets.npm_token}} diff --git a/.gitignore b/.gitignore index aa130c1..804897f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,42 +1,83 @@ # See http://help.github.com/ignore-files/ for more about ignoring files. -# Compiled output -/dist -/tmp -/out-tsc -/bazel-out - -# Node -**/node_modules +# ─── Angular / Node ────────────────────────────────────────────────────────── + +**/node_modules/ +**/dist/ +**/tmp/ +**/out-tsc/ +**/bazel-out/ +**/.angular/cache/ +**/.sass-cache/ +**/coverage/ +**/typings/ npm-debug.log yarn-error.log -# IDEs and editors +# ─── .NET / Visual Studio ──────────────────────────────────────────────────── + +# Build output +**/bin/ +**/obj/ + +# Visual Studio user/workspace files +.vs/ +*.user +*.suo +*.userosscache +*.sln.docstates + +# NuGet packages (do not commit) +*.nupkg +*.snupkg +.pack/ +**/packages/ +!**/packages/repositories.config +**/[Pp]ackages/ + +# Publish profiles +**/Properties/PublishProfiles/ + +# Test results +**/TestResults/ +**/[Tt]est[Rr]esult*/ + +# Code coverage +**/coverage.xml +**/*.coveragexml +**/*.coverage + +# Rider .idea/ -.project -.classpath -.c9/ -*.launch -.settings/ -*.sublime-workspace - -# Visual Studio Code +**/.idea/ + +# ─── .NET Aspire ───────────────────────────────────────────────────────────── + +# Aspire dashboard manifest (generated at runtime) +**/aspire-manifest.json + +# ─── Secrets / Local config ────────────────────────────────────────────────── + +# These files may contain real device IPs, API keys, etc. +**/appsettings.Development.json +**/appsettings.*.json +# Re-allow the base appsettings.json (placeholder values only) +!**/appsettings.json + +# Angular local config overrides +**/environments/environment.local.ts + +# ─── VS Code ───────────────────────────────────────────────────────────────── + .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json -.history/* - -# Miscellaneous -/.angular/cache -.sass-cache/ -/connect.lock -/coverage -/libpeerconnection.log -testem.log -/typings - -# System files +.history/ + +# ─── System ────────────────────────────────────────────────────────────────── + .DS_Store Thumbs.db +*.log diff --git a/.vscode/launch.json b/.vscode/launch.json index 740e35a..0f18c64 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,18 +3,35 @@ "version": "0.2.0", "configurations": [ { - "name": "ng serve", + "name": "Angular: ng serve", "type": "pwa-chrome", "request": "launch", "preLaunchTask": "npm: start", "url": "http://localhost:4200/" }, { - "name": "ng test", + "name": "Angular: ng test", "type": "chrome", "request": "launch", "preLaunchTask": "npm: test", "url": "http://localhost:9876/debug.html" + }, + { + "name": "Blazor: dotnet run", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "dotnet: build", + "program": "${workspaceFolder}/dotnet/GateMonitor.Blazor/bin/Debug/net10.0/GateMonitor.Blazor.dll", + "args": [], + "cwd": "${workspaceFolder}/dotnet/GateMonitor.Blazor", + "stopAtEntry": false, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + } } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index a298b5b..e51bd4c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -5,6 +5,9 @@ { "type": "npm", "script": "start", + "options": { + "cwd": "${workspaceFolder}/angular" + }, "isBackground": true, "problemMatcher": { "owner": "typescript", @@ -23,6 +26,9 @@ { "type": "npm", "script": "test", + "options": { + "cwd": "${workspaceFolder}/angular" + }, "isBackground": true, "problemMatcher": { "owner": "typescript", @@ -37,6 +43,29 @@ } } } + }, + { + "label": "dotnet: build", + "type": "shell", + "command": "dotnet build", + "options": { + "cwd": "${workspaceFolder}/dotnet/GateMonitor.Blazor" + }, + "group": "build", + "presentation": { + "reveal": "silent" + }, + "problemMatcher": "$msCompile" + }, + { + "label": "dotnet: watch", + "type": "shell", + "command": "dotnet watch", + "options": { + "cwd": "${workspaceFolder}/dotnet/GateMonitor.Blazor" + }, + "isBackground": true, + "problemMatcher": "$msCompile" } ] } diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..1c6e30b --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,8 @@ +workflow: GitHubFlow/v1 +next-version: 1.0.0 +branches: + work: + regex: '^work[/-]' + label: alpha + increment: Minor + is-mainline: false diff --git a/README.md b/README.md index f249780..baa6f41 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,135 @@ -# gateMonitor +# GateMonitor -This is an Angular 15.0.0 project I created so I could easily check the state of the gate at the end of my driveway. I wanted to be able to easily see if the gate was open or closed and see an image from the camera at the gate before I let out my dog. +A local NOC dashboard for checking and controlling a driveway gate via a [Remootio](https://remootio.com) smart gate controller connected to a [Ghost Controls](https://ghostcontrols.com) gate system. -Using a [Remootio](https://www.remootio.com/) smart gate contr5oller with my [Ghost Controls](https://ghostcontrols.com) gate. I was able to convert their [Remootio API Client for Node.js](https://github.com/remootio/remootio-api-client-node) module for use in Angular and create a small site that gave me what I needed. +The repo ships **two equivalent front-end implementations** and **one shared .NET library**: -I will be running this site on a Raspberry PI with a touch screen. The goal is to place this near the door so I can easily check and then close the gate if needed before I let out my dog. +| Project | Stack | Description | +|---------|-------|-------------| +| `angular/` | Angular 15 + RxJS | Original dashboard — runs in the browser, connects to the Remootio device WebSocket directly | +| `dotnet/GateMonitor.Blazor` | Blazor WebAssembly (.NET 10) | Equivalent SPA — same direct WebSocket approach, orchestrated by Aspire | +| `dotnet/myNOC.Remootio` | .NET 8 / 10 class library | Remootio protocol implementation — also published as a NuGet package | -This was my first attempt at creating my own Observable components that my site could subscribe to and display changes as they happened. I haven't been using Angular a lot lately, so this was a way for me to experiment and also build something cool. +--- -As part of this I also created an Angular Service **[remootio-angular](./projects/remootio-angular/README.md)** as a library, so hopefully other people may also find this useful. I have not published it yet, but I am thinking I will after I have more of the base methods implemented. +## Repository Structure -## Website +``` +gateMonitor/ +├── angular/ # Angular dashboard app + remootio-angular NPM library +│ ├── src/ # App UI (home component, routing) +│ └── projects/remootio-angular/ # Angular library (Remootio WebSocket client) +├── dotnet/ +│ ├── GateMonitor.AppHost/ # .NET Aspire orchestrator (dev launcher) +│ ├── GateMonitor.Blazor/ # Blazor WASM dashboard +│ ├── myNOC.Remootio/ # Remootio library → NuGet: myNOC.Remootio +│ ├── tests/ +│ │ ├── myNOC.Tests.Remootio/ # MSTest unit tests for the library +│ │ └── GateMonitor.Tests.Blazor/# bUnit component tests for the Blazor app +│ └── GateMonitor.slnx # .NET solution (SLNX format) +└── .github/ + ├── workflows/ + │ ├── dotnet-build.yml # PR: build + test (.NET & Angular) + │ └── dotnet-release.yml # Main: NuGet + NPM publish + tag + └── instructions/ # Copilot architecture guidance +``` + +--- + +## Angular App + +The Angular app connects directly from the browser to the Remootio device WebSocket (`ws://{deviceIp}:8080/`), authenticates, and subscribes to gate state events. + +### Setup -This is a very basic site with only 1 page and route. The main page is [home.component.html](./src/app/pages/home/home.component.html). If you wanted to use this with your own Remootio device and camera you will need to update the [home.component.ts](./src/app/pages/home/home.component.ts) file. +```bash +cd angular +npm install +npm install --prefix ./projects/remootio-angular/ +ng build remootio-angular +ng serve -o +``` -You will need to change the `deviceIp`, `apiSecretKey`, and `apiAuthKey` in the `ngOnInit` method. +### Configuration + +Edit `angular/src/app/pages/home/home.component.ts` and replace the placeholders: ```ts -ngOnInit(): void { - this.remootioService.connect({ - deviceIp: '{remootioDeviceIp}', - apiSecretKey: '{apiSecretKey}', - apiAuthKey: '{apiAuthKey}', - autoReconnect: true - }); +this.remootioService.connect({ + deviceIp: '{remootioDeviceIp}', + apiSecretKey: '{apiSecretKey}', // 64-char hex — from Remootio app + apiAuthKey: '{apiAuthKey}', // 64-char hex — from Remootio app + autoReconnect: true +}); +``` + +The gate image URL placeholder is also in that file. Keys are found in the Remootio mobile app under **Settings → WebSocket API**. + +--- + +## Blazor WASM App + +The Blazor app is a standalone WebAssembly SPA that also connects directly from the browser to the Remootio device. It uses the `myNOC.Remootio` library. + +### Run with Aspire (recommended) + +```bash +cd dotnet +dotnet run --project GateMonitor.AppHost ``` -You can get this information from the Remootio application on your mobile device. It is located under Settings...Websocket API. +This launches the Aspire dashboard and starts the Blazor dev server. Open the Aspire dashboard URL to navigate to the app. + +### Run standalone + +```bash +cd dotnet +dotnet run --project GateMonitor.Blazor +# opens http://localhost:5122 +``` + +### Configuration + +Edit `dotnet/GateMonitor.Blazor/wwwroot/appsettings.json`: + +```json +{ + "Remootio": { + "DeviceIp": "192.168.1.50", + "ApiSecretKey": "<64-char hex>", + "ApiAuthKey": "<64-char hex>", + "AutoReconnect": true, + "GateImageUrl": "http://192.168.1.51/snapshot.jpg" + } +} +``` + +> **Note:** `wwwroot/appsettings.json` is served as a public static file. Do not commit real credentials — use `appsettings.Development.json` (git-ignored) or environment-local overrides. + +--- + +## myNOC.Remootio Library + +A .NET port of the [remootio-api-client-node](https://github.com/remootio/remootio-api-client-node) library. Handles AES-CBC + HMAC-SHA256 encryption, the authentication challenge/response flow, and real-time state change events. + +See [dotnet/myNOC.Remootio/README.md](dotnet/myNOC.Remootio/README.md) for full API documentation. + +**Install:** +```bash +dotnet add package myNOC.Remootio +``` + +--- + +## CI/CD + +| Workflow | Trigger | What it does | +|----------|---------|--------------| +| `dotnet-build.yml` | PR → main | GitVersion, .NET build + test, Angular build | +| `dotnet-release.yml` | Push → main | GitVersion, NuGet push, NPM publish, git tag + GitHub release | + +**Required secrets:** `NUGET_PUBLISH`, `npm_token` + Once the connection to Remootio is made and authenticated the web site will display a bar indicating if the gate is open or closed and 2 buttons. One to Open and one to Close the gate. diff --git a/angular.json b/angular/angular.json similarity index 100% rename from angular.json rename to angular/angular.json diff --git a/karma.conf.js b/angular/karma.conf.js similarity index 100% rename from karma.conf.js rename to angular/karma.conf.js diff --git a/package-lock.json b/angular/package-lock.json similarity index 99% rename from package-lock.json rename to angular/package-lock.json index 306891e..4816adc 100644 --- a/package-lock.json +++ b/angular/package-lock.json @@ -27,6 +27,7 @@ "@angular-devkit/build-angular": "^15.0.0", "@angular/cli": "^15.0.0", "@angular/compiler-cli": "^15.0.0", + "@types/crypto-js": "^4.2.2", "@types/jasmine": "^4.3.0", "@types/node": "^18.11.9", "jasmine-core": "~4.5.0", @@ -3671,6 +3672,13 @@ "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==", "dev": true }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "8.4.6", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz", @@ -15839,6 +15847,12 @@ "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==", "dev": true }, + "@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true + }, "@types/eslint": { "version": "8.4.6", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz", diff --git a/package.json b/angular/package.json similarity index 94% rename from package.json rename to angular/package.json index d8f026b..09f5299 100644 --- a/package.json +++ b/angular/package.json @@ -3,6 +3,7 @@ "version": "0.0.1", "scripts": { "ng": "ng", + "prestart": "ng build remootio-angular", "start": "ng serve", "buildService": "ng build remootio-angular", "build": "ng build", @@ -31,6 +32,7 @@ "@angular-devkit/build-angular": "^15.0.0", "@angular/cli": "^15.0.0", "@angular/compiler-cli": "^15.0.0", + "@types/crypto-js": "^4.2.2", "@types/jasmine": "^4.3.0", "@types/node": "^18.11.9", "jasmine-core": "~4.5.0", @@ -43,4 +45,4 @@ "ngx-deploy-npm": "^4.3.5", "typescript": "^4.8.4" } -} \ No newline at end of file +} diff --git a/projects/remootio-angular/README.md b/angular/projects/remootio-angular/README.md similarity index 100% rename from projects/remootio-angular/README.md rename to angular/projects/remootio-angular/README.md diff --git a/projects/remootio-angular/karma.conf.js b/angular/projects/remootio-angular/karma.conf.js similarity index 100% rename from projects/remootio-angular/karma.conf.js rename to angular/projects/remootio-angular/karma.conf.js diff --git a/projects/remootio-angular/ng-package.json b/angular/projects/remootio-angular/ng-package.json similarity index 100% rename from projects/remootio-angular/ng-package.json rename to angular/projects/remootio-angular/ng-package.json diff --git a/projects/remootio-angular/npm/package-lock.json b/angular/projects/remootio-angular/npm/package-lock.json similarity index 100% rename from projects/remootio-angular/npm/package-lock.json rename to angular/projects/remootio-angular/npm/package-lock.json diff --git a/projects/remootio-angular/npm/package.json b/angular/projects/remootio-angular/npm/package.json similarity index 100% rename from projects/remootio-angular/npm/package.json rename to angular/projects/remootio-angular/npm/package.json diff --git a/projects/remootio-angular/package-lock.json b/angular/projects/remootio-angular/package-lock.json similarity index 100% rename from projects/remootio-angular/package-lock.json rename to angular/projects/remootio-angular/package-lock.json diff --git a/projects/remootio-angular/package.json b/angular/projects/remootio-angular/package.json similarity index 100% rename from projects/remootio-angular/package.json rename to angular/projects/remootio-angular/package.json diff --git a/projects/remootio-angular/src/lib/services/remootio-angular.service.spec.ts b/angular/projects/remootio-angular/src/lib/services/remootio-angular.service.spec.ts similarity index 100% rename from projects/remootio-angular/src/lib/services/remootio-angular.service.spec.ts rename to angular/projects/remootio-angular/src/lib/services/remootio-angular.service.spec.ts diff --git a/projects/remootio-angular/src/lib/services/remootio-angular.service.ts b/angular/projects/remootio-angular/src/lib/services/remootio-angular.service.ts similarity index 100% rename from projects/remootio-angular/src/lib/services/remootio-angular.service.ts rename to angular/projects/remootio-angular/src/lib/services/remootio-angular.service.ts diff --git a/projects/remootio-angular/src/lib/services/remootioApiCrypto.ts b/angular/projects/remootio-angular/src/lib/services/remootioApiCrypto.ts similarity index 100% rename from projects/remootio-angular/src/lib/services/remootioApiCrypto.ts rename to angular/projects/remootio-angular/src/lib/services/remootioApiCrypto.ts diff --git a/projects/remootio-angular/src/lib/services/remootioDevice.ts b/angular/projects/remootio-angular/src/lib/services/remootioDevice.ts similarity index 100% rename from projects/remootio-angular/src/lib/services/remootioDevice.ts rename to angular/projects/remootio-angular/src/lib/services/remootioDevice.ts diff --git a/projects/remootio-angular/src/lib/services/remootioDeviceEvents.ts b/angular/projects/remootio-angular/src/lib/services/remootioDeviceEvents.ts similarity index 100% rename from projects/remootio-angular/src/lib/services/remootioDeviceEvents.ts rename to angular/projects/remootio-angular/src/lib/services/remootioDeviceEvents.ts diff --git a/projects/remootio-angular/src/lib/services/remootioFrames.ts b/angular/projects/remootio-angular/src/lib/services/remootioFrames.ts similarity index 100% rename from projects/remootio-angular/src/lib/services/remootioFrames.ts rename to angular/projects/remootio-angular/src/lib/services/remootioFrames.ts diff --git a/projects/remootio-angular/src/lib/services/remootioInterfaces.ts b/angular/projects/remootio-angular/src/lib/services/remootioInterfaces.ts similarity index 100% rename from projects/remootio-angular/src/lib/services/remootioInterfaces.ts rename to angular/projects/remootio-angular/src/lib/services/remootioInterfaces.ts diff --git a/projects/remootio-angular/src/public-api.ts b/angular/projects/remootio-angular/src/public-api.ts similarity index 100% rename from projects/remootio-angular/src/public-api.ts rename to angular/projects/remootio-angular/src/public-api.ts diff --git a/projects/remootio-angular/src/test.ts b/angular/projects/remootio-angular/src/test.ts similarity index 100% rename from projects/remootio-angular/src/test.ts rename to angular/projects/remootio-angular/src/test.ts diff --git a/projects/remootio-angular/tsconfig.lib.json b/angular/projects/remootio-angular/tsconfig.lib.json similarity index 100% rename from projects/remootio-angular/tsconfig.lib.json rename to angular/projects/remootio-angular/tsconfig.lib.json diff --git a/projects/remootio-angular/tsconfig.lib.prod.json b/angular/projects/remootio-angular/tsconfig.lib.prod.json similarity index 100% rename from projects/remootio-angular/tsconfig.lib.prod.json rename to angular/projects/remootio-angular/tsconfig.lib.prod.json diff --git a/projects/remootio-angular/tsconfig.spec.json b/angular/projects/remootio-angular/tsconfig.spec.json similarity index 100% rename from projects/remootio-angular/tsconfig.spec.json rename to angular/projects/remootio-angular/tsconfig.spec.json diff --git a/src/app/app-routing.module.ts b/angular/src/app/app-routing.module.ts similarity index 100% rename from src/app/app-routing.module.ts rename to angular/src/app/app-routing.module.ts diff --git a/src/app/app.module.ts b/angular/src/app/app.module.ts similarity index 100% rename from src/app/app.module.ts rename to angular/src/app/app.module.ts diff --git a/src/app/footer/footer.component.html b/angular/src/app/footer/footer.component.html similarity index 100% rename from src/app/footer/footer.component.html rename to angular/src/app/footer/footer.component.html diff --git a/src/app/footer/footer.component.scss b/angular/src/app/footer/footer.component.scss similarity index 100% rename from src/app/footer/footer.component.scss rename to angular/src/app/footer/footer.component.scss diff --git a/src/app/footer/footer.component.spec.ts b/angular/src/app/footer/footer.component.spec.ts similarity index 100% rename from src/app/footer/footer.component.spec.ts rename to angular/src/app/footer/footer.component.spec.ts diff --git a/src/app/footer/footer.component.ts b/angular/src/app/footer/footer.component.ts similarity index 100% rename from src/app/footer/footer.component.ts rename to angular/src/app/footer/footer.component.ts diff --git a/src/app/pages/home/home.component.html b/angular/src/app/pages/home/home.component.html similarity index 100% rename from src/app/pages/home/home.component.html rename to angular/src/app/pages/home/home.component.html diff --git a/src/app/pages/home/home.component.scss b/angular/src/app/pages/home/home.component.scss similarity index 100% rename from src/app/pages/home/home.component.scss rename to angular/src/app/pages/home/home.component.scss diff --git a/src/app/pages/home/home.component.spec.ts b/angular/src/app/pages/home/home.component.spec.ts similarity index 100% rename from src/app/pages/home/home.component.spec.ts rename to angular/src/app/pages/home/home.component.spec.ts diff --git a/src/app/pages/home/home.component.ts b/angular/src/app/pages/home/home.component.ts similarity index 100% rename from src/app/pages/home/home.component.ts rename to angular/src/app/pages/home/home.component.ts diff --git a/src/app/root/app.component.html b/angular/src/app/root/app.component.html similarity index 100% rename from src/app/root/app.component.html rename to angular/src/app/root/app.component.html diff --git a/src/app/root/app.component.scss b/angular/src/app/root/app.component.scss similarity index 100% rename from src/app/root/app.component.scss rename to angular/src/app/root/app.component.scss diff --git a/src/app/root/app.component.spec.ts b/angular/src/app/root/app.component.spec.ts similarity index 100% rename from src/app/root/app.component.spec.ts rename to angular/src/app/root/app.component.spec.ts diff --git a/src/app/root/app.component.ts b/angular/src/app/root/app.component.ts similarity index 100% rename from src/app/root/app.component.ts rename to angular/src/app/root/app.component.ts diff --git a/src/assets/.gitkeep b/angular/src/assets/.gitkeep similarity index 100% rename from src/assets/.gitkeep rename to angular/src/assets/.gitkeep diff --git a/src/environments/environment.prod.ts b/angular/src/environments/environment.prod.ts similarity index 100% rename from src/environments/environment.prod.ts rename to angular/src/environments/environment.prod.ts diff --git a/src/environments/environment.ts b/angular/src/environments/environment.ts similarity index 100% rename from src/environments/environment.ts rename to angular/src/environments/environment.ts diff --git a/src/favicon.ico b/angular/src/favicon.ico similarity index 100% rename from src/favicon.ico rename to angular/src/favicon.ico diff --git a/src/index.html b/angular/src/index.html similarity index 100% rename from src/index.html rename to angular/src/index.html diff --git a/src/main.ts b/angular/src/main.ts similarity index 100% rename from src/main.ts rename to angular/src/main.ts diff --git a/src/polyfills.ts b/angular/src/polyfills.ts similarity index 100% rename from src/polyfills.ts rename to angular/src/polyfills.ts diff --git a/src/styles.scss b/angular/src/styles.scss similarity index 100% rename from src/styles.scss rename to angular/src/styles.scss diff --git a/src/test.ts b/angular/src/test.ts similarity index 100% rename from src/test.ts rename to angular/src/test.ts diff --git a/tsconfig.app.json b/angular/tsconfig.app.json similarity index 100% rename from tsconfig.app.json rename to angular/tsconfig.app.json diff --git a/tsconfig.json b/angular/tsconfig.json similarity index 100% rename from tsconfig.json rename to angular/tsconfig.json diff --git a/tsconfig.spec.json b/angular/tsconfig.spec.json similarity index 100% rename from tsconfig.spec.json rename to angular/tsconfig.spec.json diff --git a/dotnet/GateMonitor.AppHost/AppHost.cs b/dotnet/GateMonitor.AppHost/AppHost.cs new file mode 100644 index 0000000..ae6c99d --- /dev/null +++ b/dotnet/GateMonitor.AppHost/AppHost.cs @@ -0,0 +1,10 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// Angular SPA — prestart hook builds the remootio-angular library, then ng serve on port 4200 +builder.AddNpmApp("gate-monitor-angular", "../../angular", "start") + .WithHttpEndpoint(port: 4200, isProxied: false); + +// Blazor WASM SPA — served by the dev server on port 5122 +builder.AddProject("gate-monitor-blazor"); + +builder.Build().Run(); diff --git a/dotnet/GateMonitor.AppHost/GateMonitor.AppHost.csproj b/dotnet/GateMonitor.AppHost/GateMonitor.AppHost.csproj new file mode 100644 index 0000000..35ccd31 --- /dev/null +++ b/dotnet/GateMonitor.AppHost/GateMonitor.AppHost.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + 363569b3-fb8b-4bf5-ba54-2cb3365ed7d3 + + + + + + + + diff --git a/dotnet/GateMonitor.AppHost/Properties/launchSettings.json b/dotnet/GateMonitor.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..c0ca812 --- /dev/null +++ b/dotnet/GateMonitor.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17282;http://localhost:15147", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21066", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22138" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15147", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19159", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20294" + } + } + } +} diff --git a/dotnet/GateMonitor.AppHost/appsettings.json b/dotnet/GateMonitor.AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/dotnet/GateMonitor.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/dotnet/GateMonitor.AppHost/aspire.config.json b/dotnet/GateMonitor.AppHost/aspire.config.json new file mode 100644 index 0000000..ef11be6 --- /dev/null +++ b/dotnet/GateMonitor.AppHost/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "GateMonitor.AppHost.csproj" + } +} diff --git a/dotnet/GateMonitor.Blazor/Components/App.razor b/dotnet/GateMonitor.Blazor/Components/App.razor new file mode 100644 index 0000000..5902636 --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Components/App.razor @@ -0,0 +1 @@ + diff --git a/dotnet/GateMonitor.Blazor/Components/Layout/MainLayout.razor b/dotnet/GateMonitor.Blazor/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..78624f3 --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Components/Layout/MainLayout.razor @@ -0,0 +1,23 @@ +@inherits LayoutComponentBase + +
+ + +
+
+ About +
+ +
+ @Body +
+
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/dotnet/GateMonitor.Blazor/Components/Layout/MainLayout.razor.css b/dotnet/GateMonitor.Blazor/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000..38d1f25 --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Components/Layout/MainLayout.razor.css @@ -0,0 +1,98 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} + +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/dotnet/GateMonitor.Blazor/Components/Layout/NavMenu.razor b/dotnet/GateMonitor.Blazor/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..7a937df --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Components/Layout/NavMenu.razor @@ -0,0 +1,18 @@ + + + + + + diff --git a/dotnet/GateMonitor.Blazor/Components/Layout/NavMenu.razor.css b/dotnet/GateMonitor.Blazor/Components/Layout/NavMenu.razor.css new file mode 100644 index 0000000..a2aeace --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Components/Layout/NavMenu.razor.css @@ -0,0 +1,105 @@ +.navbar-toggler { + appearance: none; + cursor: pointer; + width: 3.5rem; + height: 2.5rem; + color: white; + position: absolute; + top: 0.5rem; + right: 1rem; + border: 1px solid rgba(255, 255, 255, 0.1); + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1); +} + +.navbar-toggler:checked { + background-color: rgba(255, 255, 255, 0.5); +} + +.top-row { + min-height: 3.5rem; + background-color: rgba(0,0,0,0.4); +} + +.navbar-brand { + font-size: 1.1rem; +} + +.bi { + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + top: -1px; + background-size: cover; +} + +.bi-house-door-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); +} + +.bi-plus-square-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); +} + +.bi-list-nested-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); +} + +.nav-item { + font-size: 0.9rem; + padding-bottom: 0.5rem; +} + + .nav-item:first-of-type { + padding-top: 1rem; + } + + .nav-item:last-of-type { + padding-bottom: 1rem; + } + + .nav-item ::deep .nav-link { + color: #d7d7d7; + background: none; + border: none; + border-radius: 4px; + height: 3rem; + display: flex; + align-items: center; + line-height: 3rem; + width: 100%; + } + +.nav-item ::deep a.active { + background-color: rgba(255,255,255,0.37); + color: white; +} + +.nav-item ::deep .nav-link:hover { + background-color: rgba(255,255,255,0.1); + color: white; +} + +.nav-scrollable { + display: none; +} + +.navbar-toggler:checked ~ .nav-scrollable { + display: block; +} + +@media (min-width: 641px) { + .navbar-toggler { + display: none; + } + + .nav-scrollable { + /* Never collapse the sidebar for wide screens */ + display: block; + + /* Allow sidebar to scroll for tall menus */ + height: calc(100vh - 3.5rem); + overflow-y: auto; + } +} diff --git a/dotnet/GateMonitor.Blazor/Components/Pages/Counter.razor b/dotnet/GateMonitor.Blazor/Components/Pages/Counter.razor new file mode 100644 index 0000000..1a4f8e7 --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Components/Pages/Counter.razor @@ -0,0 +1,19 @@ +@page "/counter" +@rendermode InteractiveServer + +Counter + +

Counter

+ +

Current count: @currentCount

+ + + +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} diff --git a/dotnet/GateMonitor.Blazor/Components/Pages/Error.razor b/dotnet/GateMonitor.Blazor/Components/Pages/Error.razor new file mode 100644 index 0000000..94ea8b0 --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Components/Pages/Error.razor @@ -0,0 +1,22 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +@code { + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id; +} diff --git a/dotnet/GateMonitor.Blazor/Components/Pages/Home.razor b/dotnet/GateMonitor.Blazor/Components/Pages/Home.razor new file mode 100644 index 0000000..97553a5 --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Components/Pages/Home.razor @@ -0,0 +1,88 @@ +@page "/" +@inject IRemootioService RemootioService +@inject IConfiguration Configuration +@implements IDisposable + +Gate Monitor + +@if (!RemootioService.IsAuthenticated) +{ +
+ Connecting to gate controller… +
+} +else +{ +
+
+
Status
+
+ @(RemootioService.CurrentGateState?.Description ?? "Unknown") +
+
+ + +
+
+
+} + +
+
+
Gate
+ @if (!string.IsNullOrEmpty(_gateImageUrl)) + { + Gate camera + } +
+
+ +@code { + private string _baseImageUrl = string.Empty; + private string _gateImageUrl = string.Empty; + private System.Timers.Timer? _imageTimer; + + protected override void OnInitialized() + { + _baseImageUrl = Configuration["Remootio:GateImageUrl"] ?? string.Empty; + _gateImageUrl = _baseImageUrl; + + RemootioService.GateStateChanged += OnGateStateChanged; + RemootioService.ConnectionChanged += OnConnectionChanged; + + if (!string.IsNullOrEmpty(_baseImageUrl)) + { + _imageTimer = new System.Timers.Timer(1000); + _imageTimer.Elapsed += async (_, _) => + { + _gateImageUrl = $"{_baseImageUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + await InvokeAsync(StateHasChanged); + }; + _imageTimer.Start(); + } + } + + private async void OnGateStateChanged(object? sender, GateState state) + => await InvokeAsync(StateHasChanged); + + private async void OnConnectionChanged(object? sender, bool connected) + => await InvokeAsync(StateHasChanged); + + private void OpenGate() => RemootioService.OpenGate(); + private void CloseGate() => RemootioService.CloseGate(); + + public void Dispose() + { + RemootioService.GateStateChanged -= OnGateStateChanged; + RemootioService.ConnectionChanged -= OnConnectionChanged; + _imageTimer?.Dispose(); + } +} diff --git a/dotnet/GateMonitor.Blazor/Components/Pages/Home.razor.css b/dotnet/GateMonitor.Blazor/Components/Pages/Home.razor.css new file mode 100644 index 0000000..d620ffa --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Components/Pages/Home.razor.css @@ -0,0 +1,23 @@ +.gate-status { + display: inline-block; + padding: 0.5rem 1.25rem; + border-radius: 0.375rem; + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.gate-status.opened { + background-color: #198754; + color: #fff; +} + +.gate-status.closed { + background-color: #dc3545; + color: #fff; +} + +.gate-image { + max-width: 100%; + border-radius: 0.375rem; +} diff --git a/dotnet/GateMonitor.Blazor/Components/Pages/NotFound.razor b/dotnet/GateMonitor.Blazor/Components/Pages/NotFound.razor new file mode 100644 index 0000000..917ada1 --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Components/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/dotnet/GateMonitor.Blazor/Components/Routes.razor b/dotnet/GateMonitor.Blazor/Components/Routes.razor new file mode 100644 index 0000000..105855d --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/dotnet/GateMonitor.Blazor/Components/_Imports.razor b/dotnet/GateMonitor.Blazor/Components/_Imports.razor new file mode 100644 index 0000000..f32ee89 --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Components/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using GateMonitor.Blazor +@using GateMonitor.Blazor.Components +@using GateMonitor.Blazor.Components.Layout +@using myNOC.Remootio diff --git a/dotnet/GateMonitor.Blazor/GateMonitor.Blazor.csproj b/dotnet/GateMonitor.Blazor/GateMonitor.Blazor.csproj new file mode 100644 index 0000000..6405131 --- /dev/null +++ b/dotnet/GateMonitor.Blazor/GateMonitor.Blazor.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/dotnet/GateMonitor.Blazor/Program.cs b/dotnet/GateMonitor.Blazor/Program.cs new file mode 100644 index 0000000..d31fa47 --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Program.cs @@ -0,0 +1,16 @@ +using GateMonitor.Blazor.Components; +using myNOC.Remootio; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.Configure(builder.Configuration.GetSection("Remootio")); +// Same singleton instance for IRemootioService consumption and IHostedService lifecycle +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +await builder.Build().RunAsync(); diff --git a/dotnet/GateMonitor.Blazor/Properties/launchSettings.json b/dotnet/GateMonitor.Blazor/Properties/launchSettings.json new file mode 100644 index 0000000..7e80bcd --- /dev/null +++ b/dotnet/GateMonitor.Blazor/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5122", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/GateMonitor.Blazor/appsettings.json b/dotnet/GateMonitor.Blazor/appsettings.json new file mode 100644 index 0000000..6be0a0f --- /dev/null +++ b/dotnet/GateMonitor.Blazor/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Remootio": { + "DeviceIp": "{deviceIp}", + "ApiSecretKey": "{apiSecretKey}", + "ApiAuthKey": "{apiAuthKey}", + "SendPingMessageEveryXMs": 60000, + "AutoReconnect": true, + "GateImageUrl": "{gateImageUrl}" + } +} diff --git a/dotnet/GateMonitor.Blazor/wwwroot/app.css b/dotnet/GateMonitor.Blazor/wwwroot/app.css new file mode 100644 index 0000000..73a69d6 --- /dev/null +++ b/dotnet/GateMonitor.Blazor/wwwroot/app.css @@ -0,0 +1,60 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +a, .btn-link { + color: #006bb7; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} \ No newline at end of file diff --git a/dotnet/GateMonitor.Blazor/wwwroot/appsettings.json b/dotnet/GateMonitor.Blazor/wwwroot/appsettings.json new file mode 100644 index 0000000..854c732 --- /dev/null +++ b/dotnet/GateMonitor.Blazor/wwwroot/appsettings.json @@ -0,0 +1,10 @@ +{ + "Remootio": { + "DeviceIp": "{deviceIp}", + "ApiSecretKey": "{apiSecretKey}", + "ApiAuthKey": "{apiAuthKey}", + "SendPingMessageEveryXMs": 60000, + "AutoReconnect": true, + "GateImageUrl": "{gateImageUrl}" + } +} diff --git a/dotnet/GateMonitor.Blazor/wwwroot/favicon.png b/dotnet/GateMonitor.Blazor/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 GIT binary patch literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~ + + + + + Gate Monitor + + + + + + + +
Loading…
+ + + diff --git a/dotnet/GateMonitor.slnx b/dotnet/GateMonitor.slnx new file mode 100644 index 0000000..2ae4bd3 --- /dev/null +++ b/dotnet/GateMonitor.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dotnet/myNOC.Remootio/GateState.cs b/dotnet/myNOC.Remootio/GateState.cs new file mode 100644 index 0000000..cfff913 --- /dev/null +++ b/dotnet/myNOC.Remootio/GateState.cs @@ -0,0 +1,7 @@ +namespace myNOC.Remootio; + +public sealed class GateState +{ + public bool IsOpen { get; init; } + public string Description => IsOpen ? "Open" : "Closed"; +} diff --git a/dotnet/myNOC.Remootio/IRemootioService.cs b/dotnet/myNOC.Remootio/IRemootioService.cs new file mode 100644 index 0000000..08ee7ba --- /dev/null +++ b/dotnet/myNOC.Remootio/IRemootioService.cs @@ -0,0 +1,11 @@ +namespace myNOC.Remootio; + +public interface IRemootioService +{ + GateState? CurrentGateState { get; } + bool IsAuthenticated { get; } + event EventHandler? GateStateChanged; + event EventHandler? ConnectionChanged; + void OpenGate(); + void CloseGate(); +} diff --git a/dotnet/myNOC.Remootio/LICENSE.txt b/dotnet/myNOC.Remootio/LICENSE.txt new file mode 100644 index 0000000..b2cef3c --- /dev/null +++ b/dotnet/myNOC.Remootio/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 myNOC LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/dotnet/myNOC.Remootio/README.md b/dotnet/myNOC.Remootio/README.md new file mode 100644 index 0000000..1368807 --- /dev/null +++ b/dotnet/myNOC.Remootio/README.md @@ -0,0 +1,108 @@ +# myNOC.Remootio + +## Overview + +A .NET client library for communicating with a [Remootio](https://remootio.com) smart gate and garage door controller via its WebSocket API. + +This library ports the official [remootio-api-client-node](https://github.com/remootio/remootio-api-client-node) TypeScript implementation to C#, including the full AES-CBC + HMAC-SHA256 encryption protocol, the authentication challenge/response flow, and real-time state change events. + +Designed to work in both ASP.NET Core server apps and Blazor WebAssembly SPAs (the browser's native WebSocket handles the device connection in WASM). + +## Setup and Configuration + +Add the Remootio service to your `IServiceCollection`. The same `RemootioService` instance is registered as both `IHostedService` (manages the WebSocket connection lifecycle) and `IRemootioService` (exposes gate state to your components): + +```csharp +services.Configure(configuration.GetSection("Remootio")); +services.AddSingleton(); +services.AddSingleton(sp => sp.GetRequiredService()); +services.AddHostedService(sp => sp.GetRequiredService()); +``` + +### Configuration + +Bind `RemootioDeviceConfig` from your `appsettings.json`: + +```json +{ + "Remootio": { + "DeviceIp": "192.168.1.50", + "ApiSecretKey": "<64-char hex — from the Remootio app>", + "ApiAuthKey": "<64-char hex — from the Remootio app>", + "SendPingMessageEveryXMs": 60000, + "AutoReconnect": true, + "GateImageUrl": "http://192.168.1.51/snapshot.jpg" + } +} +``` + +The `ApiSecretKey` and `ApiAuthKey` are shown in the Remootio mobile app under **Remootio device → API Settings**. + +> **Security note:** Store the API keys in environment variables or a secrets manager rather than in a committed `appsettings.json` file. + +## Usage + +### Subscribing to gate state + +```csharp +var remootio = serviceProvider.GetRequiredService(); + +remootio.GateStateChanged += (sender, state) => +{ + Console.WriteLine($"Gate is now: {state.Description}"); // "Open" or "Closed" + Console.WriteLine($"IsOpen: {state.IsOpen}"); +}; + +remootio.ConnectionChanged += (sender, connected) => +{ + Console.WriteLine($"Connected: {connected}, Authenticated: {remootio.IsAuthenticated}"); +}; +``` + +### Sending commands + +```csharp +// Only sent if the gate is currently closed (device enforces this) +remootio.OpenGate(); + +// Only sent if the gate is currently open (device enforces this) +remootio.CloseGate(); +``` + +### Reading current state synchronously + +```csharp +GateState? state = remootio.CurrentGateState; +bool authenticated = remootio.IsAuthenticated; +``` + +## Blazor Usage + +In a Blazor WebAssembly component, subscribe to events in `OnInitialized` and call `InvokeAsync(StateHasChanged)` from event handlers to trigger re-renders: + +```razor +@inject IRemootioService Remootio +@implements IDisposable + +
@(Remootio.CurrentGateState?.Description ?? "Connecting...")
+ + + +@code { + protected override void OnInitialized() => + Remootio.GateStateChanged += OnStateChanged; + + private async void OnStateChanged(object? sender, GateState state) => + await InvokeAsync(StateHasChanged); + + public void Dispose() => + Remootio.GateStateChanged -= OnStateChanged; +} +``` + +## Protocol Notes + +- The Remootio device listens on `ws://{DeviceIp}:8080/` +- All commands and responses after the `AUTH` frame are encrypted with **AES-128-CBC** (PKCS7 padding) using the `ApiSecretKey` (pre-auth) or `ApiSessionKey` (post-auth) +- Message integrity is verified with **HMAC-SHA256** over the raw `{"iv":"...","payload":"..."}` JSON string +- The library automatically handles the AUTH → challenge → QUERY authentication flow and re-authenticates on reconnect diff --git a/dotnet/myNOC.Remootio/RemootioApiCrypto.cs b/dotnet/myNOC.Remootio/RemootioApiCrypto.cs new file mode 100644 index 0000000..3be27ed --- /dev/null +++ b/dotnet/myNOC.Remootio/RemootioApiCrypto.cs @@ -0,0 +1,119 @@ +using System.Security.Cryptography; +using System.Text; + +namespace myNOC.Remootio; + +/// +/// Ports the crypto logic from remootioApiCrypto.ts. +/// Remootio uses AES-CBC encryption with PKCS7 padding and HMAC-SHA256 message authentication. +/// Keys are 64-char hex strings (256-bit). The session key is base64-encoded. +/// Decrypted payloads are Latin1-encoded JSON strings (matching crypto-js enc.Latin1). +/// +internal static class RemootioApiCrypto +{ + /// + /// Decrypts an incoming ENCRYPTED frame from the Remootio device. + /// + /// The raw JSON string of frame.data as received (used verbatim for MAC verification). + /// Base64-encoded IV from frame.data.iv. + /// Base64-encoded ciphertext from frame.data.payload. + /// Base64-encoded expected MAC from the frame. + /// 64-char hex API secret key. + /// 64-char hex API auth key. + /// Base64 session key (null if not yet authenticated). + /// The decrypted JSON string, or null if MAC fails or decryption errors. + public static string? Decrypt( + string rawDataJson, + string iv, + string payload, + string mac, + string apiSecretKey, + string apiAuthKey, + string? apiSessionKey) + { + byte[] authKey = Convert.FromHexString(apiAuthKey); + + // Verify HMAC-SHA256 over the exact raw data JSON received from the device + using var hmac = new HMACSHA256(authKey); + byte[] computedMacBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(rawDataJson)); + string computedMac = Convert.ToBase64String(computedMacBytes); + + if (computedMac != mac) + { + Console.WriteLine($"[RemootioApiCrypto] MAC mismatch. Computed: {computedMac}, Received: {mac}"); + return null; + } + + byte[] keyBytes = apiSessionKey is null + ? Convert.FromHexString(apiSecretKey) + : Convert.FromBase64String(apiSessionKey); + byte[] ivBytes = Convert.FromBase64String(iv); + byte[] cipherBytes = Convert.FromBase64String(payload); + + try + { + using var aes = Aes.Create(); + aes.Key = keyBytes; + aes.IV = ivBytes; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var decryptor = aes.CreateDecryptor(); + byte[] decrypted = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); + return Encoding.Latin1.GetString(decrypted); + } + catch (Exception ex) + { + Console.WriteLine($"[RemootioApiCrypto] Decryption failed: {ex.Message}"); + return null; + } + } + + /// + /// Constructs an outgoing ENCRYPTED frame JSON string to send to the Remootio device. + /// Only valid in an authenticated session (apiSessionKey must be set). + /// + /// Action JSON to encrypt (e.g. {"action":{"type":"QUERY","id":1}}). + /// 64-char hex API auth key. + /// Base64 session key received during authentication. + /// The full ENCRYPTED frame as a JSON string, or null on error. + public static string? Encrypt(string plainTextJson, string apiAuthKey, string apiSessionKey) + { + byte[] sessionKey = Convert.FromBase64String(apiSessionKey); + byte[] authKey = Convert.FromHexString(apiAuthKey); + + byte[] ivBytes = new byte[16]; + RandomNumberGenerator.Fill(ivBytes); + + byte[] payloadBytes = Encoding.Latin1.GetBytes(plainTextJson); + + try + { + using var aes = Aes.Create(); + aes.Key = sessionKey; + aes.IV = ivBytes; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var encryptor = aes.CreateEncryptor(); + byte[] encrypted = encryptor.TransformFinalBlock(payloadBytes, 0, payloadBytes.Length); + + string ivBase64 = Convert.ToBase64String(ivBytes); + string payloadBase64 = Convert.ToBase64String(encrypted); + + // Key order {"iv":...,"payload":...} must match what the device expects for MAC verification + string dataJson = $"{{\"iv\":\"{ivBase64}\",\"payload\":\"{payloadBase64}\"}}"; + + using var hmac = new HMACSHA256(authKey); + byte[] macBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(dataJson)); + string macBase64 = Convert.ToBase64String(macBytes); + + return $"{{\"type\":\"ENCRYPTED\",\"data\":{{\"iv\":\"{ivBase64}\",\"payload\":\"{payloadBase64}\"}},\"mac\":\"{macBase64}\"}}"; + } + catch (Exception ex) + { + Console.WriteLine($"[RemootioApiCrypto] Encryption failed: {ex.Message}"); + return null; + } + } +} diff --git a/dotnet/myNOC.Remootio/RemootioDevice.cs b/dotnet/myNOC.Remootio/RemootioDevice.cs new file mode 100644 index 0000000..d71d71b --- /dev/null +++ b/dotnet/myNOC.Remootio/RemootioDevice.cs @@ -0,0 +1,267 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; + +namespace myNOC.Remootio; + +/// +/// Low-level Remootio WebSocket client. Ports remootioDevice.ts from the remootio-api-client-node library. +/// Call RunAsync() to connect, authenticate, and process messages until disconnected. +/// SendOpen() / SendClose() may be called at any time from any thread. +/// +public sealed class RemootioDevice +{ + private readonly RemootioDeviceConfig _config; + private readonly ILogger _logger; + + private string? _apiSessionKey; + private int? _lastActionId; + private bool _waitingForAuthQueryResponse; + private Channel? _sendChannel; + + public event Action? ConnectionChanged; // (connected, authenticated) + public event Action? GateStateReceived; + + public RemootioDevice(RemootioDeviceConfig config, ILogger logger) + { + _config = config; + _logger = logger; + } + + /// + /// Connects to the device, authenticates, and processes messages. + /// Returns when the connection is closed or the cancellation token is cancelled. + /// + public async Task RunAsync(CancellationToken ct) + { + _apiSessionKey = null; + _lastActionId = null; + _waitingForAuthQueryResponse = false; + + _sendChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + + using var ws = new ClientWebSocket(); + var uri = new Uri($"ws://{_config.DeviceIp}:8080/"); + + try + { + await ws.ConnectAsync(uri, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning("Could not connect to Remootio at {Uri}: {Message}", uri, ex.Message); + _sendChannel = null; + return; + } + + _logger.LogInformation("Connected to Remootio at {Uri}", uri); + ConnectionChanged?.Invoke(true, false); + + // Kick off authentication immediately + Enqueue("{\"type\":\"AUTH\"}"); + + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); + + var receiveTask = ReceiveLoopAsync(ws, linked.Token); + var sendTask = SendLoopAsync(ws, linked.Token); + var pingTask = PingLoopAsync(linked.Token); + + await Task.WhenAny(receiveTask, sendTask); + linked.Cancel(); + + try { await Task.WhenAll(receiveTask, sendTask, pingTask); } + catch (OperationCanceledException) { } + catch (Exception ex) { _logger.LogDebug(ex, "Task cleanup on disconnect"); } + + if (ws.State == WebSocketState.Open) + { + try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None); } + catch { /* best effort */ } + } + + _sendChannel = null; + _logger.LogInformation("Disconnected from Remootio"); + ConnectionChanged?.Invoke(false, false); + } + + public void SendOpen() => SendAction("OPEN"); + public void SendClose() => SendAction("CLOSE"); + + private void SendAction(string actionType) + { + if (_lastActionId is null || _apiSessionKey is null) + { + _logger.LogWarning("Cannot send {Action} — not authenticated", actionType); + return; + } + + int nextId = (_lastActionId.Value + 1) % 0x7fffffff; + string payload = $"{{\"action\":{{\"type\":\"{actionType}\",\"id\":{nextId}}}}}"; + + string? frame = RemootioApiCrypto.Encrypt(payload, _config.ApiAuthKey, _apiSessionKey); + if (frame is not null) Enqueue(frame); + } + + private void SendQuery() + { + if (_lastActionId is null || _apiSessionKey is null) return; + + int nextId = (_lastActionId.Value + 1) % 0x7fffffff; + string payload = $"{{\"action\":{{\"type\":\"QUERY\",\"id\":{nextId}}}}}"; + + string? frame = RemootioApiCrypto.Encrypt(payload, _config.ApiAuthKey, _apiSessionKey); + if (frame is not null) Enqueue(frame); + } + + private void Enqueue(string json) => _sendChannel?.Writer.TryWrite(json); + + private async Task SendLoopAsync(ClientWebSocket ws, CancellationToken ct) + { + try + { + await foreach (var json in _sendChannel!.Reader.ReadAllAsync(ct)) + { + if (ws.State != WebSocketState.Open) break; + byte[] bytes = Encoding.UTF8.GetBytes(json); + await ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) { _logger.LogError(ex, "WebSocket send error"); } + } + + private async Task ReceiveLoopAsync(ClientWebSocket ws, CancellationToken ct) + { + var buffer = new byte[65536]; + try + { + while (ws.State == WebSocketState.Open) + { + int offset = 0; + ValueWebSocketReceiveResult result; + do + { + result = await ws.ReceiveAsync(buffer.AsMemory(offset), ct); + if (result.MessageType == WebSocketMessageType.Close) return; + offset += result.Count; + } + while (!result.EndOfMessage); + + string json = Encoding.UTF8.GetString(buffer, 0, offset); + ProcessMessage(json); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) { _logger.LogError(ex, "WebSocket receive error"); } + } + + private async Task PingLoopAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_config.SendPingMessageEveryXMs)); + try + { + while (await timer.WaitForNextTickAsync(ct)) + Enqueue("{\"type\":\"PING\"}"); + } + catch (OperationCanceledException) { } + } + + private void ProcessMessage(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (!root.TryGetProperty("type", out var typeEl)) return; + + switch (typeEl.GetString()) + { + case "PONG": + break; + + case "ENCRYPTED": + ProcessEncryptedFrame(root); + break; + + case "ERROR": + var msg = root.TryGetProperty("errorMessage", out var err) ? err.GetString() : "unknown"; + _logger.LogWarning("Remootio device error: {Error}", msg); + break; + + default: + _logger.LogDebug("Received frame: {Type}", typeEl.GetString()); + break; + } + } + catch (Exception ex) { _logger.LogError(ex, "Failed to process message"); } + } + + private void ProcessEncryptedFrame(JsonElement root) + { + var dataEl = root.GetProperty("data"); + string rawDataJson = dataEl.GetRawText(); // preserve original bytes for MAC check + string iv = dataEl.GetProperty("iv").GetString()!; + string payload = dataEl.GetProperty("payload").GetString()!; + string mac = root.GetProperty("mac").GetString()!; + + string? decrypted = RemootioApiCrypto.Decrypt( + rawDataJson, iv, payload, mac, + _config.ApiSecretKey, _config.ApiAuthKey, _apiSessionKey); + + if (decrypted is null) + { + _logger.LogWarning("Failed to decrypt Remootio frame (bad MAC or key)"); + return; + } + + try + { + using var doc = JsonDocument.Parse(decrypted); + var inner = doc.RootElement; + + if (inner.TryGetProperty("challenge", out var challenge)) + { + _apiSessionKey = challenge.GetProperty("sessionKey").GetString(); + _lastActionId = challenge.GetProperty("initialActionId").GetInt32(); + _waitingForAuthQueryResponse = true; + _logger.LogDebug("Auth challenge received — sending QUERY"); + SendQuery(); + } + else if (inner.TryGetProperty("response", out var response)) + { + if (response.TryGetProperty("id", out var idEl)) + { + int id = idEl.GetInt32(); + if (_lastActionId.HasValue && + (id > _lastActionId || (id == 0 && _lastActionId == 0x7fffffff))) + _lastActionId = id; + } + + if (response.GetProperty("type").GetString() == "QUERY" && _waitingForAuthQueryResponse) + { + _waitingForAuthQueryResponse = false; + _logger.LogInformation("Remootio authenticated"); + ConnectionChanged?.Invoke(true, true); + } + + if (response.TryGetProperty("state", out var stateEl)) + ProcessSensorState(stateEl.GetString()); + } + else if (inner.TryGetProperty("event", out var evt)) + { + if (evt.GetProperty("type").GetString() == "StateChange" && + evt.TryGetProperty("state", out var stateEl)) + ProcessSensorState(stateEl.GetString()); + } + } + catch (Exception ex) { _logger.LogError(ex, "Failed to parse decrypted frame"); } + } + + private void ProcessSensorState(string? sensorState) + { + if (sensorState is null) return; + GateStateReceived?.Invoke(new GateState { IsOpen = sensorState == "open" }); + } +} diff --git a/dotnet/myNOC.Remootio/RemootioDeviceConfig.cs b/dotnet/myNOC.Remootio/RemootioDeviceConfig.cs new file mode 100644 index 0000000..148ca5d --- /dev/null +++ b/dotnet/myNOC.Remootio/RemootioDeviceConfig.cs @@ -0,0 +1,11 @@ +namespace myNOC.Remootio; + +public sealed class RemootioDeviceConfig +{ + public string DeviceIp { get; set; } = string.Empty; + public string ApiSecretKey { get; set; } = string.Empty; + public string ApiAuthKey { get; set; } = string.Empty; + public int SendPingMessageEveryXMs { get; set; } = 60000; + public bool AutoReconnect { get; set; } = true; + public string GateImageUrl { get; set; } = string.Empty; +} diff --git a/dotnet/myNOC.Remootio/RemootioService.cs b/dotnet/myNOC.Remootio/RemootioService.cs new file mode 100644 index 0000000..6f4809f --- /dev/null +++ b/dotnet/myNOC.Remootio/RemootioService.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace myNOC.Remootio; + +/// +/// High-level Remootio service. Manages device connection lifecycle and exposes gate state +/// to consumers via events. Register as both IHostedService and IRemootioService so the +/// same singleton handles startup/shutdown and UI subscriptions: +/// +/// services.AddSingleton<RemootioService>(); +/// services.AddSingleton<IRemootioService>(sp => sp.GetRequiredService<RemootioService>()); +/// services.AddHostedService(sp => sp.GetRequiredService<RemootioService>()); +/// +/// +public sealed class RemootioService : IHostedService, IRemootioService +{ + private readonly RemootioDeviceConfig _config; + private readonly ILogger _logger; + private RemootioDevice? _device; + private CancellationTokenSource? _cts; + + public GateState? CurrentGateState { get; private set; } + public bool IsAuthenticated { get; private set; } + + public event EventHandler? GateStateChanged; + public event EventHandler? ConnectionChanged; + + public RemootioService(IOptions config, ILogger logger) + { + _config = config.Value; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + _device = new RemootioDevice(_config, _logger); + _device.GateStateReceived += OnGateStateReceived; + _device.ConnectionChanged += OnConnectionChanged; + + _ = RunConnectionLoopAsync(_cts.Token); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _cts?.Cancel(); + return Task.CompletedTask; + } + + public void OpenGate() => _device?.SendOpen(); + public void CloseGate() => _device?.SendClose(); + + private async Task RunConnectionLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + await _device!.RunAsync(ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled error from Remootio device"); + } + + if (!ct.IsCancellationRequested && _config.AutoReconnect) + { + _logger.LogInformation("Reconnecting to Remootio in 5 seconds..."); + try { await Task.Delay(5000, ct); } + catch (OperationCanceledException) { break; } + } + else break; + } + } + + private void OnGateStateReceived(GateState state) + { + CurrentGateState = state; + GateStateChanged?.Invoke(this, state); + } + + private void OnConnectionChanged(bool connected, bool authenticated) + { + IsAuthenticated = authenticated; + ConnectionChanged?.Invoke(this, connected); + } +} diff --git a/dotnet/myNOC.Remootio/myNOC.Remootio.csproj b/dotnet/myNOC.Remootio/myNOC.Remootio.csproj new file mode 100644 index 0000000..0b24622 --- /dev/null +++ b/dotnet/myNOC.Remootio/myNOC.Remootio.csproj @@ -0,0 +1,63 @@ + + + + net8.0;net10.0 + latest + enable + enable + snupkg + true + true + true + embedded + true + False + myNOC.Remootio + myNOC.Remootio + myNOC LLC + A .NET client library for the Remootio smart gate/garage door controller WebSocket API. Ports the remootio-api-client-node protocol (AES-CBC + HMAC-SHA256 encryption, auth flow, state events) to C#. + remootio;iot;gate;garage;websocket;blazor;smarthome + README.md + LICENSE.txt + Copyright © myNOC LLC 2026 + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + + + + true + pdbonly + true + + + + + + + + + + + <_Parameter1>myNOC.Tests.Remootio + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + diff --git a/dotnet/tests/GateMonitor.Tests.Blazor/GateMonitor.Tests.Blazor.csproj b/dotnet/tests/GateMonitor.Tests.Blazor/GateMonitor.Tests.Blazor.csproj new file mode 100644 index 0000000..036fdc0 --- /dev/null +++ b/dotnet/tests/GateMonitor.Tests.Blazor/GateMonitor.Tests.Blazor.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/dotnet/tests/GateMonitor.Tests.Blazor/HomeComponentTests.cs b/dotnet/tests/GateMonitor.Tests.Blazor/HomeComponentTests.cs new file mode 100644 index 0000000..dbde4d7 --- /dev/null +++ b/dotnet/tests/GateMonitor.Tests.Blazor/HomeComponentTests.cs @@ -0,0 +1,122 @@ +namespace GateMonitor.Tests.Blazor; + +/// +/// Tests for Home.razor — the gate status and control component. +/// bUnit is used for rendering; NSubstitute mocks IRemootioService. +/// GateImageUrl is intentionally left empty to prevent the image-refresh +/// timer from starting inside OnInitialized during tests. +/// +[TestClass] +public class HomeComponentTests +{ + private BunitContext _ctx = null!; + private IRemootioService _service = null!; + + [TestInitialize] + public void Init() + { + _ctx = new BunitContext(); + _service = Substitute.For(); + + _ctx.Services.AddSingleton( + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Remootio:GateImageUrl"] = string.Empty + }) + .Build()); + + _ctx.Services.AddSingleton(_service); + } + + [TestCleanup] + public void Cleanup() => _ctx.Dispose(); + + [TestMethod] + public void WhenNotAuthenticated_ShowsConnectingAlert() + { + _service.IsAuthenticated.Returns(false); + + var cut = _ctx.RenderComponent(); + + cut.Find(".alert-info"); // throws AngleSharp.Dom.DomException if not found + } + + [TestMethod] + public void WhenAuthenticated_DoesNotShowConnectingAlert() + { + _service.IsAuthenticated.Returns(true); + _service.CurrentGateState.Returns(new GateState { IsOpen = false }); + + var cut = _ctx.RenderComponent(); + + Assert.AreEqual(0, cut.FindAll(".alert-info").Count); + } + + [TestMethod] + public void WhenAuthenticated_AndGateOpen_CloseButtonEnabled_OpenButtonDisabled() + { + _service.IsAuthenticated.Returns(true); + _service.CurrentGateState.Returns(new GateState { IsOpen = true }); + + var cut = _ctx.RenderComponent(); + + var closeBtn = cut.Find("button.btn-danger"); + var openBtn = cut.Find("button.btn-success"); + + Assert.IsFalse(closeBtn.HasAttribute("disabled"), "Close button should be enabled when open"); + Assert.IsTrue(openBtn.HasAttribute("disabled"), "Open button should be disabled when already open"); + } + + [TestMethod] + public void WhenAuthenticated_AndGateClosed_OpenButtonEnabled_CloseButtonDisabled() + { + _service.IsAuthenticated.Returns(true); + _service.CurrentGateState.Returns(new GateState { IsOpen = false }); + + var cut = _ctx.RenderComponent(); + + var openBtn = cut.Find("button.btn-success"); + var closeBtn = cut.Find("button.btn-danger"); + + Assert.IsFalse(openBtn.HasAttribute("disabled"), "Open button should be enabled when closed"); + Assert.IsTrue(closeBtn.HasAttribute("disabled"), "Close button should be disabled when already closed"); + } + + [TestMethod] + public void WhenAuthenticated_GateStatusDisplaysDescription() + { + _service.IsAuthenticated.Returns(true); + _service.CurrentGateState.Returns(new GateState { IsOpen = false }); + + var cut = _ctx.RenderComponent(); + + Assert.IsTrue( + cut.Find(".gate-status").TextContent.Contains("Closed"), + "Gate status element should show 'Closed'"); + } + + [TestMethod] + public void ClickingOpenGate_InvokesServiceOpenGate() + { + _service.IsAuthenticated.Returns(true); + _service.CurrentGateState.Returns(new GateState { IsOpen = false }); + + var cut = _ctx.RenderComponent(); + cut.Find("button.btn-success").Click(); + + _service.Received(1).OpenGate(); + } + + [TestMethod] + public void ClickingCloseGate_InvokesServiceCloseGate() + { + _service.IsAuthenticated.Returns(true); + _service.CurrentGateState.Returns(new GateState { IsOpen = true }); + + var cut = _ctx.RenderComponent(); + cut.Find("button.btn-danger").Click(); + + _service.Received(1).CloseGate(); + } +} diff --git a/dotnet/tests/GateMonitor.Tests.Blazor/Usings.cs b/dotnet/tests/GateMonitor.Tests.Blazor/Usings.cs new file mode 100644 index 0000000..6f6da63 --- /dev/null +++ b/dotnet/tests/GateMonitor.Tests.Blazor/Usings.cs @@ -0,0 +1,8 @@ +global using Microsoft.VisualStudio.TestTools.UnitTesting; +global using Bunit; +global using BunitContext = Bunit.TestContext; // alias resolves ambiguity with MSTest.TestContext +global using myNOC.Remootio; +global using GateMonitor.Blazor.Components.Pages; +global using NSubstitute; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; diff --git a/dotnet/tests/myNOC.Tests.Remootio/GateStateTests.cs b/dotnet/tests/myNOC.Tests.Remootio/GateStateTests.cs new file mode 100644 index 0000000..f2d27ab --- /dev/null +++ b/dotnet/tests/myNOC.Tests.Remootio/GateStateTests.cs @@ -0,0 +1,30 @@ +namespace myNOC.Tests.Remootio; + +[TestClass] +public class GateStateTests +{ + [DataTestMethod] + [DataRow(true, "Open")] + [DataRow(false, "Closed")] + public void Description_ReflectsIsOpen(bool isOpen, string expected) + { + var state = new GateState { IsOpen = isOpen }; + Assert.AreEqual(expected, state.Description); + } + + [TestMethod] + public void IsOpen_True_DescriptionIsOpen() + { + var state = new GateState { IsOpen = true }; + Assert.IsTrue(state.IsOpen); + Assert.AreEqual("Open", state.Description); + } + + [TestMethod] + public void IsOpen_False_DescriptionIsClosed() + { + var state = new GateState { IsOpen = false }; + Assert.IsFalse(state.IsOpen); + Assert.AreEqual("Closed", state.Description); + } +} diff --git a/dotnet/tests/myNOC.Tests.Remootio/RemootioApiCryptoTests.cs b/dotnet/tests/myNOC.Tests.Remootio/RemootioApiCryptoTests.cs new file mode 100644 index 0000000..28e61bd --- /dev/null +++ b/dotnet/tests/myNOC.Tests.Remootio/RemootioApiCryptoTests.cs @@ -0,0 +1,122 @@ +using System.Security.Cryptography; +using System.Text.Json; + +namespace myNOC.Tests.Remootio; + +[TestClass] +public class RemootioApiCryptoTests +{ + // 64-char hex test keys (256-bit) — not real device keys + private const string TestSecretKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private const string TestAuthKey = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + + private static string RandomSessionKey() + { + var bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToBase64String(bytes); + } + + [TestMethod] + public void Encrypt_ThenDecrypt_WithSessionKey_RoundTrips() + { + string sessionKey = RandomSessionKey(); + const string payload = "{\"action\":{\"type\":\"QUERY\",\"id\":1}}"; + + string? frame = RemootioApiCrypto.Encrypt(payload, TestAuthKey, sessionKey); + + Assert.IsNotNull(frame); + + using var doc = JsonDocument.Parse(frame!); + var root = doc.RootElement; + var dataEl = root.GetProperty("data"); + string rawDataJson = dataEl.GetRawText(); + string iv = dataEl.GetProperty("iv").GetString()!; + string encPayload = dataEl.GetProperty("payload").GetString()!; + string mac = root.GetProperty("mac").GetString()!; + + string? decrypted = RemootioApiCrypto.Decrypt( + rawDataJson, iv, encPayload, mac, + TestSecretKey, TestAuthKey, sessionKey); + + Assert.AreEqual(payload, decrypted); + } + + [TestMethod] + public void Encrypt_ProducesValidEncryptedFrameStructure() + { + string sessionKey = RandomSessionKey(); + + string? frame = RemootioApiCrypto.Encrypt("{\"action\":{\"type\":\"OPEN\",\"id\":2}}", TestAuthKey, sessionKey); + + Assert.IsNotNull(frame); + using var doc = JsonDocument.Parse(frame!); + var root = doc.RootElement; + + Assert.AreEqual("ENCRYPTED", root.GetProperty("type").GetString()); + Assert.IsTrue(root.GetProperty("data").TryGetProperty("iv", out _), "Missing iv"); + Assert.IsTrue(root.GetProperty("data").TryGetProperty("payload", out _), "Missing payload"); + Assert.IsTrue(root.TryGetProperty("mac", out _), "Missing mac"); + } + + [TestMethod] + public void Decrypt_WithTamperedMac_ReturnsNull() + { + string sessionKey = RandomSessionKey(); + const string payload = "{\"action\":{\"type\":\"QUERY\",\"id\":1}}"; + + string? frame = RemootioApiCrypto.Encrypt(payload, TestAuthKey, sessionKey); + Assert.IsNotNull(frame); + + using var doc = JsonDocument.Parse(frame!); + var root = doc.RootElement; + var dataEl = root.GetProperty("data"); + string rawDataJson = dataEl.GetRawText(); + string iv = dataEl.GetProperty("iv").GetString()!; + string encPayload = dataEl.GetProperty("payload").GetString()!; + + string? result = RemootioApiCrypto.Decrypt( + rawDataJson, iv, encPayload, + mac: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + TestSecretKey, TestAuthKey, sessionKey); + + Assert.IsNull(result); + } + + [TestMethod] + public void Decrypt_WithInvalidBase64_ReturnsNull() + { + string? result = RemootioApiCrypto.Decrypt( + rawDataJson: "{\"iv\":\"not-base64!\",\"payload\":\"not-base64!\"}", + iv: "not-base64!", + payload: "not-base64!", + mac: "not-base64!", + apiSecretKey: TestSecretKey, + apiAuthKey: TestAuthKey, + apiSessionKey: null); + + Assert.IsNull(result); + } + + [TestMethod] + public void Encrypt_DifferentCallsProduceDifferentIVs() + { + string sessionKey = RandomSessionKey(); + const string payload = "{\"action\":{\"type\":\"CLOSE\",\"id\":3}}"; + + string? frame1 = RemootioApiCrypto.Encrypt(payload, TestAuthKey, sessionKey); + string? frame2 = RemootioApiCrypto.Encrypt(payload, TestAuthKey, sessionKey); + + Assert.IsNotNull(frame1); + Assert.IsNotNull(frame2); + + using var doc1 = JsonDocument.Parse(frame1!); + using var doc2 = JsonDocument.Parse(frame2!); + + string iv1 = doc1.RootElement.GetProperty("data").GetProperty("iv").GetString()!; + string iv2 = doc2.RootElement.GetProperty("data").GetProperty("iv").GetString()!; + + // Random IVs should differ (astronomically unlikely to collide) + Assert.AreNotEqual(iv1, iv2); + } +} diff --git a/dotnet/tests/myNOC.Tests.Remootio/Usings.cs b/dotnet/tests/myNOC.Tests.Remootio/Usings.cs new file mode 100644 index 0000000..e39ea43 --- /dev/null +++ b/dotnet/tests/myNOC.Tests.Remootio/Usings.cs @@ -0,0 +1,2 @@ +global using Microsoft.VisualStudio.TestTools.UnitTesting; +global using myNOC.Remootio; diff --git a/dotnet/tests/myNOC.Tests.Remootio/myNOC.Tests.Remootio.csproj b/dotnet/tests/myNOC.Tests.Remootio/myNOC.Tests.Remootio.csproj new file mode 100644 index 0000000..9e15fd7 --- /dev/null +++ b/dotnet/tests/myNOC.Tests.Remootio/myNOC.Tests.Remootio.csproj @@ -0,0 +1,26 @@ + + + + net8.0;net10.0 + latest + enable + enable + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + From f7cc39bb9594957db84a01f44c32f32c6b899006 Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Thu, 9 Jul 2026 00:23:33 -0400 Subject: [PATCH 03/16] Add CI workflow for .NET and Angular build/test Introduces pr-build-test.yml GitHub Actions workflow to automate build and test for both .NET and Angular projects. The workflow runs on PRs, pushes to main, and manual triggers, with separate jobs for .NET and Angular, and a final status check to ensure all jobs pass. --- .github/workflows/pr-build-test.yml | 88 +++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/workflows/pr-build-test.yml diff --git a/.github/workflows/pr-build-test.yml b/.github/workflows/pr-build-test.yml new file mode 100644 index 0000000..a09017d --- /dev/null +++ b/.github/workflows/pr-build-test.yml @@ -0,0 +1,88 @@ +name: PR Build and Test + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + workflow_dispatch: + +jobs: + dotnet-build-test: + name: .NET Build & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 10.x + + - name: Restore .NET dependencies + run: dotnet restore dotnet/GateMonitor.slnx + + - name: Build .NET solution + run: dotnet build dotnet/GateMonitor.slnx --configuration Release --no-restore + + - name: Run .NET tests + run: dotnet test dotnet/GateMonitor.slnx --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx" + + - name: Publish test results + uses: dorny/test-reporter@v1 + if: always() + with: + name: .NET Test Results + path: '**/test-results.trx' + reporter: dotnet-trx + fail-on-error: true + + angular-build-test: + name: Angular Build & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./angular + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: angular/package-lock.json + + - name: Install Angular dependencies + run: npm ci + + - name: Build Angular app + run: npm run build --if-present + + - name: Run Angular tests + run: npm test -- --watch=false --browsers=ChromeHeadless + + - name: Lint Angular code + run: npm run lint --if-present + continue-on-error: true + + status-check: + name: All Checks Passed + runs-on: ubuntu-latest + needs: [dotnet-build-test, angular-build-test] + if: always() + + steps: + - name: Check build status + run: | + if [ "${{ needs.dotnet-build-test.result }}" != "success" ] || [ "${{ needs.angular-build-test.result }}" != "success" ]; then + echo "One or more jobs failed" + exit 1 + fi + echo "All checks passed successfully!" From cbd6855072fdaef4e3dee9a46f46807f088c0059 Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Thu, 9 Jul 2026 00:33:25 -0400 Subject: [PATCH 04/16] Update documentation for Angular 22 and new folder structure - Updated README.md to reference Angular 22 instead of 15 - Updated Angular CLI installation instructions for folder structure - Created angular/README.md with Angular 22 upgrade notes - Updated .github/copilot-instructions.md with Angular 22 version - Updated CI workflow reference to pr-build-test.yml - Added angular/README.md to documentation list --- .github/copilot-instructions.md | 5 +- README.md | 15 ++-- angular/README.md | 153 ++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 angular/README.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a350c58..7a2e517 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -6,7 +6,7 @@ This repository contains two equivalent front-end dashboards for a Remootio-cont | Subtree | Stack | Description | |---------|-------|-------------| -| `angular/` | Angular 15 + RxJS | Original dashboard + remootio-angular NPM library | +| `angular/` | Angular 22 + RxJS | Original dashboard + remootio-angular NPM library | | `dotnet/GateMonitor.Blazor` | Blazor WASM (.NET 10) | Blazor equivalent dashboard | | `dotnet/myNOC.Remootio` | .NET 8/10 library | Remootio protocol → NuGet: myNOC.Remootio | | `dotnet/GateMonitor.AppHost` | .NET Aspire | Dev orchestrator for the Blazor app | @@ -51,12 +51,13 @@ Do not move low-level protocol logic into UI components in either codebase. - Angular: `npm test` in `angular/` - .NET: `dotnet test dotnet/GateMonitor.slnx` -- Both run automatically in `dotnet-build.yml` on PRs. +- Both run automatically in `pr-build-test.yml` on PRs. ## Documentation Expectations If behavior changes, update: - `README.md` (repo overview) +- `angular/README.md` (Angular dashboard) - `angular/projects/remootio-angular/README.md` (Angular library) - `dotnet/myNOC.Remootio/README.md` (.NET library) diff --git a/README.md b/README.md index baa6f41..b72a573 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The repo ships **two equivalent front-end implementations** and **one shared .NE | Project | Stack | Description | |---------|-------|-------------| -| `angular/` | Angular 15 + RxJS | Original dashboard — runs in the browser, connects to the Remootio device WebSocket directly | +| `angular/` | Angular 22 + RxJS | Original dashboard — runs in the browser, connects to the Remootio device WebSocket directly | | `dotnet/GateMonitor.Blazor` | Blazor WebAssembly (.NET 10) | Equivalent SPA — same direct WebSocket approach, orchestrated by Aspire | | `dotnet/myNOC.Remootio` | .NET 8 / 10 class library | Remootio protocol implementation — also published as a NuGet package | @@ -168,27 +168,28 @@ HTML to display the gate image. ## Run -To run the site you will need Angular 15.0.0 CLI +To run the site you will need Angular 22 CLI ```bash npm install -g @angular/cli ``` -Once that is installed you should run npm install in both the main project and **remootio-angular** project folder. +Once that is installed you should run npm install in the angular folder. ```bash +cd angular npm install -npm install --prefix .\projects\remootio-angular\ ``` -Once you have ran both the `npm install` commands you need to build the project first. +Once you have ran `npm install`, build the remootio-angular library: ```bash +cd angular ng build remootio-angular ``` -Once that the **remootio-angular** project is built you can build and run the main Angular project. +Then build and run the main Angular project: ```bash ng serve -o -``` \ No newline at end of file +``` diff --git a/angular/README.md b/angular/README.md new file mode 100644 index 0000000..a4ef90b --- /dev/null +++ b/angular/README.md @@ -0,0 +1,153 @@ +# GateMonitor Angular Dashboard + +Angular 22 dashboard for monitoring and controlling a Remootio-connected gate. + +## Features + +- Real-time gate state display (open/closed) +- Direct WebSocket connection to Remootio device from browser +- Live gate camera image refresh +- Safe open/close controls with state-aware button enabling +- Reusable `remootio-angular` NPM library for Remootio protocol + +## Project Structure + +``` +angular/ +├── src/ # Main dashboard app +│ ├── app/ +│ │ ├── pages/home/ # Home component with gate controls +│ │ ├── footer/ # Footer component +│ │ └── root/ # App root component +│ └── environments/ # Environment configuration +├── projects/ +│ └── remootio-angular/ # Reusable Angular library +│ ├── src/lib/ +│ │ ├── services/ # RemootioService, device client +│ │ └── models/ # Interfaces and types +│ └── README.md # Library-specific docs +└── angular.json # Angular workspace config +``` + +## Angular 22 Upgrade + +This project has been upgraded from Angular 15 to Angular 22, which includes: + +### Key Changes +- **@angular/build**: Replaced `@angular-devkit/build-angular` with new `@angular/build` package +- **Application builder**: Switched from `browser-esbuild` to `application` builder (new default) +- **TypeScript 6.0**: Updated with `moduleResolution: bundler` +- **Standalone components**: Added `standalone: false` to NgModule-declared components (v22 default changed) +- **Removed deprecated files**: + - `polyfills.ts` - handled automatically by builder + - `test.ts` - Karma builder integration improved + - `environment.prod.ts` - no longer needed with application builder +- **Material**: Removed legacy Material imports (MatLegacyCardModule, etc.) +- **Testing**: Updated to use `RouterModule.forRoot([])` instead of deprecated `RouterTestingModule` + +### Package Versions +- `@angular/*`: ^22.0.0 +- `typescript`: ~6.0.0 +- `rxjs`: ~7.8.0 +- `zone.js`: ~0.15.0 +- `ng-packagr`: ^22.0.0 + +## Getting Started + +### Prerequisites +- Node.js 18+ +- Angular CLI 22 + +### Install Angular CLI +```bash +npm install -g @angular/cli +``` + +### Install Dependencies +```bash +cd angular +npm install +``` + +### Build the Library +Before running the app, build the `remootio-angular` library: +```bash +ng build remootio-angular +``` + +### Run Development Server +```bash +ng serve +``` + +Navigate to `http://localhost:4200/` + +### Build for Production +```bash +ng build --configuration production +``` + +## Configuration + +Edit `src/app/pages/home/home.component.ts` and update the Remootio connection settings: + +```typescript +const deviceIp = '192.168.1.50'; +const apiSecretKey = '<64-char hex from Remootio app>'; +const apiAuthKey = '<64-char hex from Remootio app>'; +``` + +> **Security:** Do not commit real credentials. Use environment variables or a local config file (git-ignored). + +### Camera Image + +To display your gate camera image, update the `gateImage` URL in `home.component.ts`: + +```typescript +private gateImage: string = "http://192.168.1.51/snapshot.jpg"; +``` + +The image refreshes every second with a cache-busting timestamp. + +## Testing + +### Run Unit Tests +```bash +npm test +``` + +Runs Karma test runner with ChromeHeadless for CI compatibility. + +### Watch Mode (Local Development) +```bash +ng test +``` + +## remootio-angular Library + +The `projects/remootio-angular` folder contains a reusable Angular library that handles: +- WebSocket connection to Remootio device +- AES-CBC + HMAC-SHA256 encryption/decryption +- Authentication challenge/response +- Real-time gate state events +- Connection state management + +See [projects/remootio-angular/README.md](projects/remootio-angular/README.md) for library API documentation. + +## CI/CD + +The Angular build and tests run automatically in GitHub Actions: +- **Workflow**: `.github/workflows/pr-build-test.yml` +- **Trigger**: Pull requests to `main` +- **Steps**: + - Install dependencies (`npm ci`) + - Build app and library + - Run tests in headless Chrome + - Run linting + +## Learn More + +- [Angular Documentation](https://angular.dev) +- [RxJS Documentation](https://rxjs.dev) +- [Angular Material](https://material.angular.io) +- [Remootio API](https://github.com/remootio/remootio-api-documentation) From 6657a0f4438369ad6c56b254c81e0744b19614fe Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Thu, 9 Jul 2026 00:36:56 -0400 Subject: [PATCH 05/16] Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/dotnet-build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index a5c900d..2d9a85e 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -5,6 +5,9 @@ on: branches: - main +permissions: + contents: read + jobs: version: runs-on: ubuntu-latest From 148eb4b671e63e0c102d1aff3af0943038fecbe7 Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Thu, 9 Jul 2026 00:37:04 -0400 Subject: [PATCH 06/16] Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/pr-build-test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pr-build-test.yml b/.github/workflows/pr-build-test.yml index a09017d..8f6eda0 100644 --- a/.github/workflows/pr-build-test.yml +++ b/.github/workflows/pr-build-test.yml @@ -1,5 +1,9 @@ name: PR Build and Test +permissions: + contents: read + pull-requests: write + on: pull_request: branches: [ main ] From fe5471d7ea9e1f52ef97ba04bfa0de53e31cf32c Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Thu, 9 Jul 2026 00:37:12 -0400 Subject: [PATCH 07/16] Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/pr-build-test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pr-build-test.yml b/.github/workflows/pr-build-test.yml index 8f6eda0..801d7cb 100644 --- a/.github/workflows/pr-build-test.yml +++ b/.github/workflows/pr-build-test.yml @@ -11,6 +11,10 @@ on: branches: [ main ] workflow_dispatch: +permissions: + contents: read + checks: write + jobs: dotnet-build-test: name: .NET Build & Test From e88d6b0080b499ebcb4c914a52a40bc427c2eee8 Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Thu, 9 Jul 2026 00:40:41 -0400 Subject: [PATCH 08/16] Fix PR workflow and add branch protection guide Workflow fixes: - Merged duplicate permissions sections - Made test reporter non-blocking and PR-only - Updated Node.js from 18 to 20 for Angular 22 compatibility - Added continue-on-error for test reporter to prevent spurious failures Added: - .github/BRANCH_PROTECTION.md with setup instructions --- .github/BRANCH_PROTECTION.md | 135 ++++++++++++++++++++++++++++ .github/workflows/pr-build-test.yml | 12 ++- 2 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 .github/BRANCH_PROTECTION.md diff --git a/.github/BRANCH_PROTECTION.md b/.github/BRANCH_PROTECTION.md new file mode 100644 index 0000000..d37f854 --- /dev/null +++ b/.github/BRANCH_PROTECTION.md @@ -0,0 +1,135 @@ +# Branch Protection Setup Guide + +This guide explains how to configure GitHub branch protection rules to ensure PRs can't be merged until all checks pass. + +## Quick Setup + +1. **Go to your repository on GitHub** + - Navigate to: https://github.com/erenken/gateMonitor + +2. **Open Settings** + - Click on **Settings** tab + - In the left sidebar, click **Branches** (under "Code and automation") + +3. **Add Branch Protection Rule** + - Click **Add rule** or **Add branch protection rule** + +4. **Configure the Rule** + + **Branch name pattern:** + ``` + main + ``` + + **Check these options:** + - ☑️ **Require a pull request before merging** + - ☑️ Require approvals: `1` (if you want review) + - ☑️ Dismiss stale pull request approvals when new commits are pushed + + - ☑️ **Require status checks to pass before merging** + - ☑️ Require branches to be up to date before merging + - **Add required status checks:** + 1. Type: `.NET Build & Test` + 2. Type: `Angular Build & Test` + 3. Type: `All Checks Passed` + + > **Note**: These checks will only appear after you've run the workflow at least once. Push your current changes first, then come back to add these checks. + + - ☑️ **Require conversation resolution before merging** (optional but recommended) + + - ☑️ **Do not allow bypassing the above settings** (if you want strict enforcement) + +5. **Save the Rule** + - Scroll to the bottom and click **Create** or **Save changes** + +## What This Does + +With branch protection enabled: +- ❌ **Can't merge** if `.NET Build & Test` fails +- ❌ **Can't merge** if `Angular Build & Test` fails +- ❌ **Can't merge** if `All Checks Passed` fails +- ✅ **Can merge** only when all checks are green + +## Workflow Checks Explained + +### .NET Build & Test +- Builds the .NET solution (`dotnet/GateMonitor.slnx`) +- Runs all unit tests +- Publishes test results + +### Angular Build & Test +- Installs npm dependencies +- Builds the Angular app and library +- Runs Karma/Jasmine tests in headless Chrome +- Runs ESLint (non-blocking) + +### All Checks Passed +- Final status check that verifies both jobs succeeded +- This is the single check you can use for branch protection if you prefer + +## Testing Your Setup + +1. **Push changes** to trigger the workflow: + ```bash + git push origin work/vibeItUp + ``` + +2. **Create a PR** to `main`: + - Go to GitHub and create a pull request + - You'll see the checks running + +3. **Add required checks** (after first run): + - Go back to Settings → Branches → Edit rule + - The status checks will now be available in the dropdown + +## Troubleshooting + +### Check not appearing in the dropdown? +- Make sure the workflow has run at least once +- The check name must match exactly (case-sensitive) +- Wait a few minutes for GitHub to index the checks + +### Checks failing? +- Click "Details" next to the failing check +- Review the logs to see what failed +- Common issues: + - Missing dependencies + - Test failures + - Build errors + - Linting issues + +### Need to bypass temporarily? +- If you're an admin, you can temporarily disable the rule +- Or add "Allow administrators to bypass" in the settings +- **Not recommended** for production branches + +## GitHub CLI Alternative + +If you prefer command-line setup: + +```bash +# Install GitHub CLI: https://cli.github.com/ + +# Create branch protection rule +gh api repos/erenken/gateMonitor/branches/main/protection \ + --method PUT \ + --field required_status_checks[strict]=true \ + --field required_status_checks[contexts][]=".NET Build & Test" \ + --field required_status_checks[contexts][]="Angular Build & Test" \ + --field required_status_checks[contexts][]="All Checks Passed" \ + --field enforce_admins=true \ + --field required_pull_request_reviews[required_approving_review_count]=0 +``` + +## Additional Security (Optional) + +Consider also enabling: +- **Require signed commits** - Ensures commit authenticity +- **Require deployments to succeed** - If you have deployment checks +- **Restrict who can push** - Limit direct pushes to certain teams/users +- **Require linear history** - Enforces rebase or squash merges + +## Learn More + +- [GitHub Branch Protection Documentation](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches) +- [Status Checks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks) diff --git a/.github/workflows/pr-build-test.yml b/.github/workflows/pr-build-test.yml index 801d7cb..72524e2 100644 --- a/.github/workflows/pr-build-test.yml +++ b/.github/workflows/pr-build-test.yml @@ -3,6 +3,7 @@ name: PR Build and Test permissions: contents: read pull-requests: write + checks: write on: pull_request: @@ -11,10 +12,6 @@ on: branches: [ main ] workflow_dispatch: -permissions: - contents: read - checks: write - jobs: dotnet-build-test: name: .NET Build & Test @@ -42,12 +39,13 @@ jobs: - name: Publish test results uses: dorny/test-reporter@v1 - if: always() + if: always() && github.event_name == 'pull_request' with: name: .NET Test Results path: '**/test-results.trx' reporter: dotnet-trx - fail-on-error: true + fail-on-error: false + continue-on-error: true angular-build-test: name: Angular Build & Test @@ -63,7 +61,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '18' + node-version: '20' cache: 'npm' cache-dependency-path: angular/package-lock.json From 1e4009976809426777e46d01756b7de5b5f16efa Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Thu, 9 Jul 2026 00:49:55 -0400 Subject: [PATCH 09/16] Fix shallow clone issue for GitVersion - Added fetch-depth: 0 to both checkout actions - This fetches full git history required by GitVersion - Fixes error: 'Repository is a shallow clone. Git repositories must contain the full history' See: https://gitversion.net/docs/reference/requirements#unshallow --- .github/workflows/pr-build-test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-build-test.yml b/.github/workflows/pr-build-test.yml index 72524e2..5eaa085 100644 --- a/.github/workflows/pr-build-test.yml +++ b/.github/workflows/pr-build-test.yml @@ -20,6 +20,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -57,11 +59,13 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: angular/package-lock.json From bd3958fa502195c52d15dd57d0b48c0b2f4d7d7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 04:50:02 +0000 Subject: [PATCH 10/16] Fix Node.js version in CI workflows for Angular CLI 22 compatibility --- .github/workflows/dotnet-build.yml | 2 +- .github/workflows/pr-build-test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index 2d9a85e..86105e0 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -67,7 +67,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Install dependencies run: npm ci diff --git a/.github/workflows/pr-build-test.yml b/.github/workflows/pr-build-test.yml index 72524e2..f9d2c91 100644 --- a/.github/workflows/pr-build-test.yml +++ b/.github/workflows/pr-build-test.yml @@ -61,7 +61,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' cache-dependency-path: angular/package-lock.json From 2a0d459db6ada76d227fe3b26830139a835b8c45 Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Thu, 9 Jul 2026 00:52:48 -0400 Subject: [PATCH 11/16] Set fetch-depth: 0 for all checkout steps in release CI Ensures full Git history is available in deploy-dotnet, deploy-angular, and tag jobs by configuring actions/checkout@v4 with fetch-depth: 0. This supports versioning, changelog generation, and tagging operations that require complete commit history. --- .github/workflows/dotnet-release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/dotnet-release.yml b/.github/workflows/dotnet-release.yml index 47ddad4..dd4e334 100644 --- a/.github/workflows/dotnet-release.yml +++ b/.github/workflows/dotnet-release.yml @@ -38,6 +38,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -66,6 +68,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 @@ -96,6 +100,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Create GitHub Release and Tag uses: softprops/action-gh-release@v2 From f3b965907b40ba798c71355c6da3cedb5a036ac8 Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Thu, 9 Jul 2026 01:00:10 -0400 Subject: [PATCH 12/16] Fix Angular workflows to build library before app pr-build-test.yml: - Added step to build remootio-angular library before main app - Ensures library is compiled before app that depends on it dotnet-build.yml: - Added fetch-depth: 0 to all checkout actions for GitVersion Fixes build order: library -> app -> tests --- .github/workflows/dotnet-build.yml | 4 ++++ .github/workflows/pr-build-test.yml | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index c90997a..336f1d7 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -37,6 +37,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -63,6 +65,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/pr-build-test.yml b/.github/workflows/pr-build-test.yml index 5eaa085..b8936b7 100644 --- a/.github/workflows/pr-build-test.yml +++ b/.github/workflows/pr-build-test.yml @@ -72,6 +72,9 @@ jobs: - name: Install Angular dependencies run: npm ci + - name: Build remootio-angular library + run: npm run buildService + - name: Build Angular app run: npm run build --if-present From 0e2ca7080f7aef8bbf6a881bc7bb8e7fdcd8645e Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Thu, 9 Jul 2026 01:05:04 -0400 Subject: [PATCH 13/16] Fix HomeComponent test by mocking RemootioAngularService - Added mock RemootioAngularService with spy objects - Provides gateState\$ observable and isAuthenticated property - Fixes test failure: missing required service dependency HomeComponent requires RemootioAngularService in constructor, test was failing because TestBed didn't provide it. --- .../src/app/pages/home/home.component.spec.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/angular/src/app/pages/home/home.component.spec.ts b/angular/src/app/pages/home/home.component.spec.ts index 2c5a172..b0b2dda 100644 --- a/angular/src/app/pages/home/home.component.spec.ts +++ b/angular/src/app/pages/home/home.component.spec.ts @@ -1,14 +1,30 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { RemootioAngularService } from 'remootio-angular'; import { HomeComponent } from './home.component'; describe('HomeComponent', () => { let component: HomeComponent; let fixture: ComponentFixture; + let mockRemootioService: jasmine.SpyObj; beforeEach(async () => { + // Create a mock RemootioAngularService + mockRemootioService = jasmine.createSpyObj('RemootioAngularService', [ + 'connect', + 'closeGate', + 'openGate' + ], { + gateState$: of({ isOpen: false, description: 'Closed' }), + isAuthenticated: false + }); + await TestBed.configureTestingModule({ - declarations: [ HomeComponent ] + declarations: [ HomeComponent ], + providers: [ + { provide: RemootioAngularService, useValue: mockRemootioService } + ] }) .compileComponents(); }); From 451ba01b62018c21d6697d7b1dea0933cfcc1148 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:06:10 +0000 Subject: [PATCH 14/16] test: fix Angular component specs for CI --- angular/src/app/footer/footer.component.spec.ts | 2 ++ angular/src/app/pages/home/home.component.spec.ts | 10 +++++++--- angular/src/app/root/app.component.spec.ts | 6 +++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/angular/src/app/footer/footer.component.spec.ts b/angular/src/app/footer/footer.component.spec.ts index a3c4af9..03bdb55 100644 --- a/angular/src/app/footer/footer.component.spec.ts +++ b/angular/src/app/footer/footer.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDividerModule } from '@angular/material/divider'; import { FooterComponent } from './footer.component'; @@ -8,6 +9,7 @@ describe('FooterComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ + imports: [MatDividerModule], declarations: [ FooterComponent ] }) .compileComponents(); diff --git a/angular/src/app/pages/home/home.component.spec.ts b/angular/src/app/pages/home/home.component.spec.ts index b0b2dda..662c6ba 100644 --- a/angular/src/app/pages/home/home.component.spec.ts +++ b/angular/src/app/pages/home/home.component.spec.ts @@ -1,6 +1,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { RemootioAngularService } from 'remootio-angular'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { HomeComponent } from './home.component'; @@ -10,7 +14,6 @@ describe('HomeComponent', () => { let mockRemootioService: jasmine.SpyObj; beforeEach(async () => { - // Create a mock RemootioAngularService mockRemootioService = jasmine.createSpyObj('RemootioAngularService', [ 'connect', 'closeGate', @@ -21,10 +24,11 @@ describe('HomeComponent', () => { }); await TestBed.configureTestingModule({ - declarations: [ HomeComponent ], + imports: [CommonModule, MatCardModule, MatButtonModule, NoopAnimationsModule], providers: [ { provide: RemootioAngularService, useValue: mockRemootioService } - ] + ], + declarations: [ HomeComponent ] }) .compileComponents(); }); diff --git a/angular/src/app/root/app.component.spec.ts b/angular/src/app/root/app.component.spec.ts index 6db02e7..430fa59 100644 --- a/angular/src/app/root/app.component.spec.ts +++ b/angular/src/app/root/app.component.spec.ts @@ -1,15 +1,19 @@ import { TestBed } from '@angular/core/testing'; +import { MatDividerModule } from '@angular/material/divider'; import { RouterModule } from '@angular/router'; import { AppComponent } from './app.component'; +import { FooterComponent } from '../footer/footer.component'; describe('AppComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ + MatDividerModule, RouterModule.forRoot([]) ], declarations: [ - AppComponent + AppComponent, + FooterComponent ], }).compileComponents(); }); From ed96cd875d75b8ef4caf32e7f7ab78b0d9a68425 Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Thu, 9 Jul 2026 01:06:54 -0400 Subject: [PATCH 15/16] Fix HomeComponent test with Material modules for PR #13 HomeComponent test fix: - Added MatCardModule and MatButtonModule imports - Added CommonModule for Angular directives (*ngIf, async pipe) - Template uses mat-card and mat-fab which need these modules Keep Node 24 for latest features and compatibility. Fixes test failures in GitHub Actions --- angular/src/app/pages/home/home.component.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/angular/src/app/pages/home/home.component.spec.ts b/angular/src/app/pages/home/home.component.spec.ts index b0b2dda..e0a0425 100644 --- a/angular/src/app/pages/home/home.component.spec.ts +++ b/angular/src/app/pages/home/home.component.spec.ts @@ -1,6 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { RemootioAngularService } from 'remootio-angular'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { CommonModule } from '@angular/common'; import { HomeComponent } from './home.component'; @@ -22,6 +25,11 @@ describe('HomeComponent', () => { await TestBed.configureTestingModule({ declarations: [ HomeComponent ], + imports: [ + CommonModule, + MatCardModule, + MatButtonModule + ], providers: [ { provide: RemootioAngularService, useValue: mockRemootioService } ] From c3b1178101162d8bc5d942168ce92db2b14c4183 Mon Sep 17 00:00:00 2001 From: Eric Renken Date: Thu, 9 Jul 2026 01:11:58 -0400 Subject: [PATCH 16/16] Remove duplicate declarations property in HomeComponent test - Fixed TS1117 error: duplicate 'declarations' key - TestBed.configureTestingModule now has clean config - Keeps Material modules and service mock intact --- angular/src/app/pages/home/home.component.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/angular/src/app/pages/home/home.component.spec.ts b/angular/src/app/pages/home/home.component.spec.ts index 0311ac2..dfb37cf 100644 --- a/angular/src/app/pages/home/home.component.spec.ts +++ b/angular/src/app/pages/home/home.component.spec.ts @@ -31,8 +31,7 @@ describe('HomeComponent', () => { ], providers: [ { provide: RemootioAngularService, useValue: mockRemootioService } - ], - declarations: [ HomeComponent ] + ] }) .compileComponents(); });