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/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..7a2e517 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,50 +1,74 @@ # 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 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 | ## 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 `pr-build-test.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/README.md` (Angular dashboard) +- `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..336f1d7 --- /dev/null +++ b/.github/workflows/dotnet-build.yml @@ -0,0 +1,90 @@ +name: Build + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +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 + with: + fetch-depth: 0 + + - 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 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + + - 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..f7a44dd --- /dev/null +++ b/.github/workflows/dotnet-release.yml @@ -0,0 +1,115 @@ +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 + with: + fetch-depth: 0 + + - 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 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + 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 + with: + fetch-depth: 0 + + - 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/pr-build-test.yml b/.github/workflows/pr-build-test.yml new file mode 100644 index 0000000..b8936b7 --- /dev/null +++ b/.github/workflows/pr-build-test.yml @@ -0,0 +1,101 @@ +name: PR Build and Test + +permissions: + contents: read + pull-requests: write + checks: write + +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 + with: + fetch-depth: 0 + + - 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() && github.event_name == 'pull_request' + with: + name: .NET Test Results + path: '**/test-results.trx' + reporter: dotnet-trx + fail-on-error: false + continue-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 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: angular/package-lock.json + + - 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 + + - 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!" 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..b72a573 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 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 | -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 +``` + +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 ``` -You can get this information from the Remootio application on your mobile device. It is located under Settings...Websocket API. +### 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. @@ -65,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) 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 100% rename from package-lock.json rename to angular/package-lock.json diff --git a/package.json b/angular/package.json similarity index 100% rename from package.json rename to angular/package.json 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/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 86% rename from src/app/footer/footer.component.spec.ts rename to angular/src/app/footer/footer.component.spec.ts index a3c4af9..03bdb55 100644 --- a/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/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/angular/src/app/pages/home/home.component.spec.ts b/angular/src/app/pages/home/home.component.spec.ts new file mode 100644 index 0000000..dfb37cf --- /dev/null +++ b/angular/src/app/pages/home/home.component.spec.ts @@ -0,0 +1,48 @@ +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'; + +describe('HomeComponent', () => { + let component: HomeComponent; + let fixture: ComponentFixture; + let mockRemootioService: jasmine.SpyObj; + + beforeEach(async () => { + mockRemootioService = jasmine.createSpyObj('RemootioAngularService', [ + 'connect', + 'closeGate', + 'openGate' + ], { + gateState$: of({ isOpen: false, description: 'Closed' }), + isAuthenticated: false + }); + + await TestBed.configureTestingModule({ + declarations: [ HomeComponent ], + imports: [ + CommonModule, + MatCardModule, + MatButtonModule + ], + providers: [ + { provide: RemootioAngularService, useValue: mockRemootioService } + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HomeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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 73% rename from src/app/root/app.component.spec.ts rename to angular/src/app/root/app.component.spec.ts index 6db02e7..430fa59 100644 --- a/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(); }); 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.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/styles.scss b/angular/src/styles.scss similarity index 100% rename from src/styles.scss rename to angular/src/styles.scss 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 0000000..8422b59 Binary files /dev/null and b/dotnet/GateMonitor.Blazor/wwwroot/favicon.png differ diff --git a/dotnet/GateMonitor.Blazor/wwwroot/index.html b/dotnet/GateMonitor.Blazor/wwwroot/index.html new file mode 100644 index 0000000..d95e1cd --- /dev/null +++ b/dotnet/GateMonitor.Blazor/wwwroot/index.html @@ -0,0 +1,17 @@ + + + + + + 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 + + + + + + + + + diff --git a/src/app/pages/home/home.component.spec.ts b/src/app/pages/home/home.component.spec.ts deleted file mode 100644 index 2c5a172..0000000 --- a/src/app/pages/home/home.component.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { HomeComponent } from './home.component'; - -describe('HomeComponent', () => { - let component: HomeComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [ HomeComponent ] - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(HomeComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -});