Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58b8a446b1 | |||
| a83e9f93de | |||
| 68738e486d | |||
| 2701b8a483 | |||
| 645a7e3722 | |||
| 88339c51eb | |||
| a7e5e13ac8 | |||
| 751030dcef | |||
| e5ae228738 | |||
| b5be4c6914 | |||
| db5c4e59a4 | |||
| 720583de0a | |||
| b862bf6f0f | |||
| 3e9c245bb3 | |||
| 68e7e81312 | |||
| a1aa5023a2 | |||
| 2a208e81b2 | |||
| 8a7d01ac9e | |||
| 824a27520d | |||
| f2bb2d984b | |||
| 2c16a9485b | |||
| 1eb0383834 | |||
| cf055c8928 | |||
| 99a9950cee |
@@ -0,0 +1,13 @@
|
||||
**/bin
|
||||
**/obj
|
||||
**/.vs
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
docs
|
||||
tests
|
||||
backend.tests
|
||||
*.md
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
+12
@@ -3,3 +3,15 @@ __pycache__/
|
||||
.venv/
|
||||
*.wav
|
||||
.openwakeword-cache/
|
||||
|
||||
# .NET build outputs (don't catch root-level bin/ scripts)
|
||||
backend/bin/
|
||||
backend/obj/
|
||||
backend.tests/bin/
|
||||
backend.tests/obj/
|
||||
|
||||
# SQLite dev database (created on first run)
|
||||
backend/*.db
|
||||
backend/*.db-shm
|
||||
backend/*.db-wal
|
||||
deploy.json
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "backend", "backend\backend.csproj", "{98CFCD4D-61C0-4FCA-BB6A-C6F68D5F4E40}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "backend.tests", "backend.tests\backend.tests.csproj", "{63423BEA-4ECF-45CF-B0E8-611AC65AE463}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{98CFCD4D-61C0-4FCA-BB6A-C6F68D5F4E40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{98CFCD4D-61C0-4FCA-BB6A-C6F68D5F4E40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{98CFCD4D-61C0-4FCA-BB6A-C6F68D5F4E40}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{98CFCD4D-61C0-4FCA-BB6A-C6F68D5F4E40}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{98CFCD4D-61C0-4FCA-BB6A-C6F68D5F4E40}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{98CFCD4D-61C0-4FCA-BB6A-C6F68D5F4E40}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{98CFCD4D-61C0-4FCA-BB6A-C6F68D5F4E40}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{98CFCD4D-61C0-4FCA-BB6A-C6F68D5F4E40}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{98CFCD4D-61C0-4FCA-BB6A-C6F68D5F4E40}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{98CFCD4D-61C0-4FCA-BB6A-C6F68D5F4E40}.Release|x64.Build.0 = Release|Any CPU
|
||||
{98CFCD4D-61C0-4FCA-BB6A-C6F68D5F4E40}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{98CFCD4D-61C0-4FCA-BB6A-C6F68D5F4E40}.Release|x86.Build.0 = Release|Any CPU
|
||||
{63423BEA-4ECF-45CF-B0E8-611AC65AE463}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{63423BEA-4ECF-45CF-B0E8-611AC65AE463}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{63423BEA-4ECF-45CF-B0E8-611AC65AE463}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{63423BEA-4ECF-45CF-B0E8-611AC65AE463}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{63423BEA-4ECF-45CF-B0E8-611AC65AE463}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{63423BEA-4ECF-45CF-B0E8-611AC65AE463}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{63423BEA-4ECF-45CF-B0E8-611AC65AE463}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{63423BEA-4ECF-45CF-B0E8-611AC65AE463}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{63423BEA-4ECF-45CF-B0E8-611AC65AE463}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{63423BEA-4ECF-45CF-B0E8-611AC65AE463}.Release|x64.Build.0 = Release|Any CPU
|
||||
{63423BEA-4ECF-45CF-B0E8-611AC65AE463}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{63423BEA-4ECF-45CF-B0E8-611AC65AE463}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---- Backend build ----
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
COPY backend/backend.csproj backend/
|
||||
RUN dotnet restore backend/backend.csproj
|
||||
COPY backend/ backend/
|
||||
RUN dotnet publish backend/backend.csproj -c Release -o /publish
|
||||
|
||||
# ---- Client bundle ----
|
||||
FROM alpine:3.20 AS bundle
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache tar gzip
|
||||
COPY client/ client/
|
||||
COPY requirements.txt requirements-nodeps.txt ./
|
||||
RUN tar czf /client.tar.gz client/ requirements.txt requirements-nodeps.txt
|
||||
|
||||
# ---- Final ----
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /publish/ ./
|
||||
RUN mkdir -p ./wwwroot && mkdir -p /data /keys
|
||||
COPY --from=bundle /client.tar.gz ./wwwroot/client.tar.gz
|
||||
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
ENV ConnectionStrings__Default="Data Source=/data/assistant.db"
|
||||
ENV Install__ClientTarballPath="/app/wwwroot/client.tar.gz"
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["dotnet", "backend.dll"]
|
||||
@@ -0,0 +1,94 @@
|
||||
# Smart Assistant
|
||||
|
||||
Voice assistant on a Raspberry Pi talking to the OpenAI Realtime API via an
|
||||
ASP.NET backend deployed on Coolify.
|
||||
|
||||
See `docs/superpowers/specs/2026-06-11-smart-assistant-design.md` for the full
|
||||
design and `docs/superpowers/plans/2026-06-11-smart-assistant-backend-foundation.md`
|
||||
for the Plan 1 implementation steps that produced this code.
|
||||
|
||||
## Repo layout
|
||||
|
||||
- `backend/` — ASP.NET 9 backend (Identity, Devices, Pairing, Install).
|
||||
- `backend.tests/` — xUnit integration + unit tests via `WebApplicationFactory`.
|
||||
- `client/` — Python client. **Plan 1 ships placeholders only**; the full
|
||||
wakeword + Realtime client lands in Plan 3.
|
||||
- `Dockerfile` + `docker-compose.yml` — multi-stage build; produces one image
|
||||
with the published ASP.NET app + a bundled `client.tar.gz` in
|
||||
`wwwroot/client.tar.gz`.
|
||||
- `deploy.json` — Coolify config (gitignored; contains the OpenAI key).
|
||||
- `docs/superpowers/` — specs and plans.
|
||||
|
||||
## Local development
|
||||
|
||||
```sh
|
||||
dotnet test # all backend tests
|
||||
dotnet run --project backend # starts on http://localhost:5252
|
||||
```
|
||||
|
||||
Hit `http://localhost:5252/health` to verify it's up.
|
||||
|
||||
## Smoke test against a live deploy
|
||||
|
||||
The full pairing flow can be exercised with `curl` — no UI needed yet.
|
||||
|
||||
```sh
|
||||
DOMAIN="https://assistant.volcanic.tes.gd" # or your domain
|
||||
COOKIE=$(mktemp)
|
||||
|
||||
# Register the first user (becomes Admin automatically) and sign in
|
||||
curl -sf -c "$COOKIE" -X POST "$DOMAIN/api/auth/register" \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"email":"you@example.com","password":"Passw0rd!"}'
|
||||
curl -sf -c "$COOKIE" -b "$COOKIE" -X POST "$DOMAIN/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"email":"you@example.com","password":"Passw0rd!"}'
|
||||
|
||||
# Generate a pairing code (auth required)
|
||||
CODE=$(curl -sf -c "$COOKIE" -b "$COOKIE" -X POST "$DOMAIN/api/pair-code" \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"name":"kitchen"}' \
|
||||
| python3 -c 'import sys,json; print(json.load(sys.stdin)["code"])')
|
||||
echo "code=$CODE"
|
||||
|
||||
# Pair as a device (public endpoint) and capture the token
|
||||
curl -sf -X POST "$DOMAIN/api/pair" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"code\":\"$CODE\",\"hostname\":\"smoke\",\"client_version\":\"0.1\"}" \
|
||||
| python3 -m json.tool
|
||||
|
||||
# Verify the install script and client bundle are served
|
||||
curl -sf "$DOMAIN/install.sh" | head -5
|
||||
curl -sIf "$DOMAIN/client.tar.gz"
|
||||
```
|
||||
|
||||
Expected: `{"ok":true}` on `/health`, JSON with `device_id`/`device_token`/`backend_ws`
|
||||
from `/api/pair`, the bash shebang from `/install.sh`, and a `200 OK` with
|
||||
`Content-Type: application/gzip` for the bundle.
|
||||
|
||||
> **Plan 1 ships a placeholder Python bundle** — running the bundled
|
||||
> `client.main` will exit with the placeholder message. Plan 3 replaces it
|
||||
> with the real wakeword + Realtime client.
|
||||
|
||||
## Deploying
|
||||
|
||||
Use the Coolify v4 API. The `deploy.json` file (gitignored) carries the
|
||||
project + server UUIDs and the OpenAI key.
|
||||
|
||||
```sh
|
||||
APP=$(python3 -c "import json; print(json.load(open('deploy.json'))['app']['uuid'])")
|
||||
curl -sf -X POST "$COOLIFY_URL/api/v1/deploy?uuid=$APP" \
|
||||
-H "Authorization: Bearer $COOLIFY_KEY"
|
||||
```
|
||||
|
||||
Fire-and-forget; the API returns a `deployment_uuid` immediately. Don't poll
|
||||
(per the global Coolify rule in `CLAUDE.md`).
|
||||
|
||||
## What's next
|
||||
|
||||
- **Plan 2** — backend device hub + OpenAI Realtime relay + tools registry
|
||||
(the `end_session`, `get_current_time`, `set_volume` tools the spec describes).
|
||||
- **Plan 3** — full Python client on the Pi (wakeword, VAD, audio plumbing,
|
||||
state machine, install + pair CLIs).
|
||||
- **Plan 4** — React management UI for the admin dashboard and conversation
|
||||
history viewer.
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace backend.tests;
|
||||
|
||||
public class ApiFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"assistant-test-{Guid.NewGuid():N}.db");
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Test");
|
||||
builder.ConfigureAppConfiguration((_, cfg) =>
|
||||
{
|
||||
cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:Default"] = $"Data Source={_dbPath}",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
SqliteConnection.ClearAllPools();
|
||||
foreach (var suffix in new[] { "", "-shm", "-wal", "-journal" })
|
||||
{
|
||||
var p = _dbPath + suffix;
|
||||
try { if (File.Exists(p)) File.Delete(p); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace backend.tests;
|
||||
|
||||
public class AuthTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Register_then_login_then_me_returns_user()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var reg = await client.PostAsJsonAsync("/api/auth/register",
|
||||
new { email = "first@example.com", password = "Passw0rd!" });
|
||||
reg.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var login = await client.PostAsJsonAsync("/api/auth/login",
|
||||
new { email = "first@example.com", password = "Passw0rd!" });
|
||||
login.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var me = await client.GetAsync("/api/auth/me");
|
||||
me.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
(await me.Content.ReadAsStringAsync()).Should().Contain("first@example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Me_without_login_returns_401()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var client = factory.CreateClient();
|
||||
var res = await client.GetAsync("/api/auth/me");
|
||||
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task First_user_is_admin()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var client = factory.CreateClient();
|
||||
await client.PostAsJsonAsync("/api/auth/register",
|
||||
new { email = "admin@example.com", password = "Passw0rd!" });
|
||||
await client.PostAsJsonAsync("/api/auth/login",
|
||||
new { email = "admin@example.com", password = "Passw0rd!" });
|
||||
|
||||
var me = await client.GetAsync("/api/auth/me");
|
||||
(await me.Content.ReadAsStringAsync()).Should().Contain("\"isAdmin\":true");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using backend.Devices;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace backend.tests;
|
||||
|
||||
public class DeviceTokenServiceTests
|
||||
{
|
||||
private readonly DeviceTokenService _svc = new();
|
||||
|
||||
[Fact]
|
||||
public void Generate_returns_url_safe_token_of_expected_length()
|
||||
{
|
||||
var (plaintext, hash) = _svc.Generate();
|
||||
plaintext.Should().MatchRegex("^[A-Za-z0-9_-]+$");
|
||||
plaintext.Length.Should().BeGreaterThanOrEqualTo(40);
|
||||
hash.Should().NotBeNullOrEmpty();
|
||||
hash.Should().NotBe(plaintext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_is_deterministic_for_same_input()
|
||||
{
|
||||
var (plaintext, hash) = _svc.Generate();
|
||||
_svc.Hash(plaintext).Should().Be(hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_accepts_matching_plaintext_and_rejects_others()
|
||||
{
|
||||
var (plaintext, hash) = _svc.Generate();
|
||||
_svc.Verify(plaintext, hash).Should().BeTrue();
|
||||
_svc.Verify(plaintext + "x", hash).Should().BeFalse();
|
||||
_svc.Verify("totally-different", hash).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace backend.tests;
|
||||
|
||||
public class DevicesEndpointsTests
|
||||
{
|
||||
private static async Task<(HttpClient signedIn, string code)> SetupWithPairCode(ApiFactory factory, string email = "owner@example.com")
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await client.PostAsJsonAsync("/api/auth/register",
|
||||
new { email, password = "Passw0rd!" });
|
||||
await client.PostAsJsonAsync("/api/auth/login",
|
||||
new { email, password = "Passw0rd!" });
|
||||
var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
|
||||
var body = await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||
return (client, body!["code"].ToString()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task User_sees_only_their_own_devices()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var (alice, codeA) = await SetupWithPairCode(factory, "alice@example.com");
|
||||
await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||
new { code = codeA, hostname = "rpi", client_version = "0.1" });
|
||||
|
||||
var (bob, codeB) = await SetupWithPairCode(factory, "bob@example.com");
|
||||
await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||
new { code = codeB, hostname = "rpi", client_version = "0.1" });
|
||||
|
||||
var aliceList = await alice.GetStringAsync("/api/devices");
|
||||
var bobList = await bob.GetStringAsync("/api/devices");
|
||||
|
||||
aliceList.Should().Contain("kitchen");
|
||||
bobList.Should().Contain("kitchen");
|
||||
aliceList.Should().NotBe(bobList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_device_updates_name()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var (client, code) = await SetupWithPairCode(factory);
|
||||
await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||
new { code, hostname = "rpi", client_version = "0.1" });
|
||||
|
||||
var listJson = await client.GetFromJsonAsync<List<Dictionary<string, object>>>("/api/devices");
|
||||
var id = listJson![0]["id"].ToString();
|
||||
|
||||
var patchRes = await client.PatchAsJsonAsync($"/api/devices/{id}", new { name = "living-room" });
|
||||
patchRes.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var after = await client.GetStringAsync($"/api/devices/{id}");
|
||||
after.Should().Contain("living-room");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_device_sets_is_revoked_true()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var (client, code) = await SetupWithPairCode(factory);
|
||||
await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||
new { code, hostname = "rpi", client_version = "0.1" });
|
||||
|
||||
var listJson = await client.GetFromJsonAsync<List<Dictionary<string, object>>>("/api/devices");
|
||||
var id = listJson![0]["id"].ToString();
|
||||
|
||||
var del = await client.DeleteAsync($"/api/devices/{id}");
|
||||
del.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var after = await client.GetStringAsync($"/api/devices/{id}");
|
||||
after.Should().Contain("\"isRevoked\":true");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace backend.tests;
|
||||
|
||||
public class HealthTests(ApiFactory factory) : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Health_returns_ok()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var res = await client.GetAsync("/health");
|
||||
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
(await res.Content.ReadAsStringAsync()).Should().Contain("\"ok\":true");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace backend.tests;
|
||||
|
||||
public class InstallEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Install_sh_returns_shell_script_with_request_host()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var client = factory.CreateClient();
|
||||
var res = await client.GetAsync("/install.sh");
|
||||
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
res.Content.Headers.ContentType!.MediaType.Should().Be("text/x-shellscript");
|
||||
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
body.Should().StartWith("#!/usr/bin/env bash");
|
||||
body.Should().Contain("BACKEND_URL=\"http://localhost\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_tarball_returns_octet_stream()
|
||||
{
|
||||
var tmp = Path.Combine(Path.GetTempPath(), $"client-test-{Guid.NewGuid():N}.tar.gz");
|
||||
File.WriteAllBytes(tmp, new byte[] { 0x1f, 0x8b }); // tiny gzip-ish marker
|
||||
|
||||
try
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var client = factory.WithWebHostBuilder(b =>
|
||||
{
|
||||
b.ConfigureAppConfiguration((_, cfg) =>
|
||||
{
|
||||
cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Install:ClientTarballPath"] = tmp,
|
||||
});
|
||||
});
|
||||
}).CreateClient();
|
||||
|
||||
var res = await client.GetAsync("/client.tar.gz");
|
||||
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
res.Content.Headers.ContentType!.MediaType.Should().Be("application/gzip");
|
||||
(await res.Content.ReadAsByteArrayAsync()).Length.Should().Be(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tmp)) File.Delete(tmp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using backend.Install;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace backend.tests;
|
||||
|
||||
public class InstallScriptBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_inlines_the_backend_url_into_the_script()
|
||||
{
|
||||
var script = new InstallScriptBuilder().Build("https://example.test");
|
||||
script.Should().Contain("BACKEND_URL=\"https://example.test\"");
|
||||
script.Should().Contain("#!/usr/bin/env bash");
|
||||
script.Should().Contain("$BACKEND_URL/client.tar.gz");
|
||||
script.Should().Contain("client.pair");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace backend.tests;
|
||||
|
||||
public class PairingEndpointsTests
|
||||
{
|
||||
private static async Task<HttpClient> SignedInClient(ApiFactory factory, string email = "owner@example.com")
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await client.PostAsJsonAsync("/api/auth/register",
|
||||
new { email, password = "Passw0rd!" });
|
||||
await client.PostAsJsonAsync("/api/auth/login",
|
||||
new { email, password = "Passw0rd!" });
|
||||
return client;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_user_cannot_request_a_pairing_code()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var client = factory.CreateClient();
|
||||
var res = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
|
||||
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pair_code_then_pair_returns_token_and_device_id()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var client = await SignedInClient(factory);
|
||||
var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
|
||||
codeRes.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var codeBody = await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||
var code = codeBody!["code"].ToString()!;
|
||||
|
||||
var pairRes = await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||
new { code, hostname = "rpi", client_version = "0.1" });
|
||||
pairRes.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await pairRes.Content.ReadAsStringAsync();
|
||||
body.Should().Contain("device_id");
|
||||
body.Should().Contain("device_token");
|
||||
body.Should().Contain("backend_ws");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pair_with_invalid_code_returns_404()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var res = await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||
new { code = "ZZZZZZZZ", hostname = "rpi", client_version = "0.1" });
|
||||
res.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pair_with_consumed_code_returns_404()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var client = await SignedInClient(factory);
|
||||
var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
|
||||
var code = (await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>())!["code"].ToString()!;
|
||||
|
||||
var first = await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||
new { code, hostname = "rpi", client_version = "0.1" });
|
||||
first.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var second = await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||
new { code, hostname = "rpi", client_version = "0.1" });
|
||||
second.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using backend.Pairing;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace backend.tests;
|
||||
|
||||
public class PairingServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Generated_codes_use_only_safe_alphabet_and_are_8_chars()
|
||||
{
|
||||
var svc = new PairingService();
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
var code = svc.GenerateCode();
|
||||
code.Length.Should().Be(8);
|
||||
code.Should().MatchRegex("^[A-HJ-KM-NP-Z2-9]+$");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generated_codes_are_not_all_identical()
|
||||
{
|
||||
var svc = new PairingService();
|
||||
var codes = Enumerable.Range(0, 20).Select(_ => svc.GenerateCode()).ToHashSet();
|
||||
codes.Count.Should().BeGreaterThan(15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.10.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.8" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\backend\backend.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace backend.Auth;
|
||||
|
||||
public class ApplicationUser : IdentityUser<Guid>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace backend.Auth;
|
||||
|
||||
public record RegisterRequest(string Email, string Password);
|
||||
public record LoginRequest(string Email, string Password);
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var grp = app.MapGroup("/api/auth");
|
||||
|
||||
grp.MapPost("/register", async (
|
||||
[FromBody] RegisterRequest req,
|
||||
UserManager<ApplicationUser> users) =>
|
||||
{
|
||||
var user = new ApplicationUser { UserName = req.Email, Email = req.Email };
|
||||
var create = await users.CreateAsync(user, req.Password);
|
||||
if (!create.Succeeded)
|
||||
return Results.BadRequest(new { errors = create.Errors.Select(e => e.Description) });
|
||||
|
||||
var anyAdmin = await users.GetUsersInRoleAsync(Roles.Admin);
|
||||
var role = anyAdmin.Count == 0 ? Roles.Admin : Roles.User;
|
||||
await users.AddToRoleAsync(user, role);
|
||||
return Results.Ok(new { id = user.Id, email = user.Email, role });
|
||||
});
|
||||
|
||||
grp.MapPost("/login", async (
|
||||
[FromBody] LoginRequest req,
|
||||
SignInManager<ApplicationUser> signIn) =>
|
||||
{
|
||||
var res = await signIn.PasswordSignInAsync(req.Email, req.Password,
|
||||
isPersistent: true, lockoutOnFailure: false);
|
||||
return res.Succeeded ? Results.Ok() : Results.Unauthorized();
|
||||
});
|
||||
|
||||
grp.MapPost("/logout", async (SignInManager<ApplicationUser> signIn) =>
|
||||
{
|
||||
await signIn.SignOutAsync();
|
||||
return Results.Ok();
|
||||
}).RequireAuthorization();
|
||||
|
||||
grp.MapGet("/me", async (
|
||||
UserManager<ApplicationUser> users,
|
||||
HttpContext http) =>
|
||||
{
|
||||
var user = await users.GetUserAsync(http.User);
|
||||
if (user is null) return Results.Unauthorized();
|
||||
var rolesList = await users.GetRolesAsync(user);
|
||||
return Results.Ok(new
|
||||
{
|
||||
id = user.Id,
|
||||
email = user.Email,
|
||||
isAdmin = rolesList.Contains(Roles.Admin),
|
||||
});
|
||||
}).RequireAuthorization();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using backend.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace backend.Auth;
|
||||
|
||||
public static class IdentitySetup
|
||||
{
|
||||
public static IServiceCollection AddAppIdentity(this IServiceCollection services)
|
||||
{
|
||||
services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(opt =>
|
||||
{
|
||||
opt.Password.RequiredLength = 8;
|
||||
opt.Password.RequireNonAlphanumeric = false;
|
||||
opt.User.RequireUniqueEmail = true;
|
||||
opt.SignIn.RequireConfirmedAccount = false;
|
||||
})
|
||||
.AddEntityFrameworkStores<AppDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
services.ConfigureApplicationCookie(opt =>
|
||||
{
|
||||
opt.Cookie.HttpOnly = true;
|
||||
opt.Cookie.SameSite = SameSiteMode.Lax;
|
||||
opt.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
opt.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
opt.SlidingExpiration = true;
|
||||
opt.Events.OnRedirectToLogin = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = 401;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
opt.Events.OnRedirectToAccessDenied = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = 403;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static async Task SeedRolesAsync(IServiceProvider sp)
|
||||
{
|
||||
var roleMgr = sp.GetRequiredService<RoleManager<IdentityRole<Guid>>>();
|
||||
foreach (var name in Roles.All)
|
||||
{
|
||||
if (!await roleMgr.RoleExistsAsync(name))
|
||||
await roleMgr.CreateAsync(new IdentityRole<Guid>(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace backend.Auth;
|
||||
|
||||
public static class Roles
|
||||
{
|
||||
public const string Admin = "Admin";
|
||||
public const string User = "User";
|
||||
|
||||
public static readonly string[] All = { Admin, User };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using backend.Auth;
|
||||
using backend.Devices;
|
||||
using backend.Pairing;
|
||||
using backend.Settings;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace backend.Data;
|
||||
|
||||
public class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
: IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>(options)
|
||||
{
|
||||
public DbSet<Device> Devices => Set<Device>();
|
||||
public DbSet<DeviceConfig> DeviceConfigs => Set<DeviceConfig>();
|
||||
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
|
||||
public DbSet<PairingCode> PairingCodes => Set<PairingCode>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder b)
|
||||
{
|
||||
base.OnModelCreating(b);
|
||||
|
||||
b.Entity<Device>()
|
||||
.HasOne<ApplicationUser>()
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.OwnerUserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
b.Entity<Device>().HasIndex(d => d.TokenHash).IsUnique();
|
||||
b.Entity<Device>()
|
||||
.HasOne(d => d.Config)
|
||||
.WithOne()
|
||||
.HasForeignKey<DeviceConfig>(c => c.DeviceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
b.Entity<DeviceConfig>().HasKey(c => c.DeviceId);
|
||||
b.Entity<SystemSettings>().HasKey(s => s.Id);
|
||||
b.Entity<SystemSettings>().Property(s => s.Id).ValueGeneratedNever();
|
||||
b.Entity<PairingCode>().HasKey(p => p.Code);
|
||||
b.Entity<PairingCode>().HasIndex(p => p.ExpiresAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace backend.Devices;
|
||||
|
||||
public class Device
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid OwnerUserId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public DateTimeOffset PairedAt { get; set; }
|
||||
public DateTimeOffset? LastSeenAt { get; set; }
|
||||
public string TokenHash { get; set; } = "";
|
||||
public bool IsRevoked { get; set; }
|
||||
public DeviceConfig? Config { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace backend.Devices;
|
||||
|
||||
public class DeviceConfig
|
||||
{
|
||||
public Guid DeviceId { get; set; }
|
||||
public string SystemPrompt { get; set; } = "";
|
||||
public string Voice { get; set; } = "";
|
||||
public string Model { get; set; } = "";
|
||||
public int IdleTimeoutSeconds { get; set; } = 30;
|
||||
public string EnabledToolsJson { get; set; } = "[\"end_session\",\"get_current_time\",\"set_volume\"]";
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace backend.Devices;
|
||||
|
||||
public class DeviceTokenService
|
||||
{
|
||||
public (string Plaintext, string Hash) Generate()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
var plaintext = Base64UrlEncode(bytes);
|
||||
return (plaintext, Hash(plaintext));
|
||||
}
|
||||
|
||||
public string Hash(string plaintext)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(plaintext), hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
public bool Verify(string plaintext, string expectedHash)
|
||||
{
|
||||
var actual = Hash(plaintext);
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(actual),
|
||||
Encoding.UTF8.GetBytes(expectedHash));
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
|
||||
=> Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Security.Claims;
|
||||
using backend.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace backend.Devices;
|
||||
|
||||
public record RenameRequest(string Name);
|
||||
|
||||
public static class DevicesEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapDevicesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var grp = app.MapGroup("/api/devices").RequireAuthorization();
|
||||
|
||||
grp.MapGet("/", async (AppDbContext db, HttpContext http, CancellationToken ct) =>
|
||||
{
|
||||
var uid = GetUserId(http);
|
||||
var devs = await db.Devices
|
||||
.Where(d => d.OwnerUserId == uid)
|
||||
.Select(d => new
|
||||
{
|
||||
id = d.Id,
|
||||
name = d.Name,
|
||||
pairedAt = d.PairedAt,
|
||||
lastSeenAt = d.LastSeenAt,
|
||||
isRevoked = d.IsRevoked,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
return Results.Ok(devs);
|
||||
});
|
||||
|
||||
grp.MapGet("/{id:guid}", async (Guid id, AppDbContext db, HttpContext http, CancellationToken ct) =>
|
||||
{
|
||||
var uid = GetUserId(http);
|
||||
var d = await db.Devices.Include(x => x.Config)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == uid, ct);
|
||||
return d is null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(new
|
||||
{
|
||||
id = d.Id,
|
||||
name = d.Name,
|
||||
pairedAt = d.PairedAt,
|
||||
lastSeenAt = d.LastSeenAt,
|
||||
isRevoked = d.IsRevoked,
|
||||
config = d.Config,
|
||||
});
|
||||
});
|
||||
|
||||
grp.MapPatch("/{id:guid}", async (
|
||||
Guid id, [FromBody] RenameRequest req,
|
||||
AppDbContext db, HttpContext http, CancellationToken ct) =>
|
||||
{
|
||||
var uid = GetUserId(http);
|
||||
var d = await db.Devices.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == uid, ct);
|
||||
if (d is null) return Results.NotFound();
|
||||
if (string.IsNullOrWhiteSpace(req.Name))
|
||||
return Results.BadRequest(new { error = "name is required" });
|
||||
d.Name = req.Name.Trim();
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
grp.MapDelete("/{id:guid}", async (Guid id, AppDbContext db, HttpContext http, CancellationToken ct) =>
|
||||
{
|
||||
var uid = GetUserId(http);
|
||||
var d = await db.Devices.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == uid, ct);
|
||||
if (d is null) return Results.NotFound();
|
||||
d.IsRevoked = true;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static Guid GetUserId(HttpContext http)
|
||||
{
|
||||
var s = http.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return Guid.TryParse(s, out var g) ? g : Guid.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace backend.Install;
|
||||
|
||||
public static class InstallEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapInstallEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/install.sh", (HttpContext http, InstallScriptBuilder builder) =>
|
||||
{
|
||||
var scheme = http.Request.Scheme;
|
||||
var host = http.Request.Host.ToString();
|
||||
var script = builder.Build($"{scheme}://{host}");
|
||||
return Results.Text(script, "text/x-shellscript");
|
||||
});
|
||||
|
||||
app.MapGet("/client.tar.gz", (IConfiguration cfg, IWebHostEnvironment env) =>
|
||||
{
|
||||
var path = cfg["Install:ClientTarballPath"]
|
||||
?? Path.Combine(env.WebRootPath ?? "wwwroot", "client.tar.gz");
|
||||
if (!File.Exists(path))
|
||||
return Results.NotFound(new { error = "client.tar.gz not found at " + path });
|
||||
return Results.File(path, "application/gzip", "client.tar.gz");
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace backend.Install;
|
||||
|
||||
public class InstallScriptBuilder
|
||||
{
|
||||
public string Build(string backendUrl)
|
||||
{
|
||||
return $$"""
|
||||
#!/usr/bin/env bash
|
||||
# Smart Assistant installer (idempotent). Re-run to upgrade.
|
||||
set -euo pipefail
|
||||
|
||||
BACKEND_URL="{{backendUrl}}"
|
||||
ASSISTANT_HOME="$HOME/assistant"
|
||||
RESET=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--reset) RESET=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Installing system deps (sudo password may be prompted)..."
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends python3-venv libportaudio2 alsa-utils
|
||||
|
||||
mkdir -p "$ASSISTANT_HOME/code" "$ASSISTANT_HOME/state"
|
||||
|
||||
if [ ! -d "$ASSISTANT_HOME/.venv" ]; then
|
||||
python3 -m venv "$ASSISTANT_HOME/.venv"
|
||||
fi
|
||||
|
||||
echo "Downloading client bundle from $BACKEND_URL ..."
|
||||
curl -fsSL "$BACKEND_URL/client.tar.gz" | tar -xz -C "$ASSISTANT_HOME/code"
|
||||
|
||||
"$ASSISTANT_HOME/.venv/bin/pip" install --quiet -r "$ASSISTANT_HOME/code/requirements.txt"
|
||||
"$ASSISTANT_HOME/.venv/bin/pip" install --quiet --no-deps -r "$ASSISTANT_HOME/code/requirements-nodeps.txt"
|
||||
|
||||
if [ "$RESET" -eq 1 ]; then
|
||||
rm -f "$ASSISTANT_HOME/state/config.json"
|
||||
fi
|
||||
|
||||
if [ ! -f "$ASSISTANT_HOME/state/config.json" ]; then
|
||||
echo
|
||||
read -r -p "Pairing code: " CODE
|
||||
"$ASSISTANT_HOME/.venv/bin/python" -m client.pair --backend "$BACKEND_URL" --code "$CODE"
|
||||
fi
|
||||
|
||||
mkdir -p "$HOME/.config/systemd/user"
|
||||
cat > "$HOME/.config/systemd/user/assistant.service" <<UNIT
|
||||
[Unit]
|
||||
Description=Smart Assistant Pi client
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Environment=PA_ALSA_PLUGHW=1
|
||||
ExecStart=$ASSISTANT_HOME/.venv/bin/python -m client.main
|
||||
WorkingDirectory=$ASSISTANT_HOME/code
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
UNIT
|
||||
|
||||
loginctl enable-linger "$USER" 2>/dev/null || true
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now assistant
|
||||
echo "Done. systemctl --user status assistant"
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using backend.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260611181007_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.17");
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using backend.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260611182529_AddIdentity")]
|
||||
partial class AddIdentity
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.17");
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Auth.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIdentity : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetRoles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUsers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
|
||||
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
|
||||
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetRoleClaims",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
RoleId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "AspNetRoles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserClaims",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserLogins",
|
||||
columns: table => new
|
||||
{
|
||||
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ProviderKey = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserRoles",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
RoleId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "AspNetRoles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserTokens",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Value = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetRoleClaims_RoleId",
|
||||
table: "AspNetRoleClaims",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "RoleNameIndex",
|
||||
table: "AspNetRoles",
|
||||
column: "NormalizedName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserClaims_UserId",
|
||||
table: "AspNetUserClaims",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserLogins_UserId",
|
||||
table: "AspNetUserLogins",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserRoles_RoleId",
|
||||
table: "AspNetUserRoles",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "EmailIndex",
|
||||
table: "AspNetUsers",
|
||||
column: "NormalizedEmail");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UserNameIndex",
|
||||
table: "AspNetUsers",
|
||||
column: "NormalizedUserName",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetRoleClaims");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserClaims");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserLogins");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserRoles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetRoles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using backend.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260611184053_AddDevicesAndSettings")]
|
||||
partial class AddDevicesAndSettings
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.17");
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Auth.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsRevoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("PairedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Devices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("DeviceId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EnabledToolsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("IdleTimeoutSeconds")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("DeviceId");
|
||||
|
||||
b.ToTable("DeviceConfigs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Settings.SystemSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DefaultIdleTimeoutSeconds")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DefaultSystemPrompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DefaultVoice")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SystemSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
|
||||
{
|
||||
b.HasOne("backend.Devices.Device", null)
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("backend.Devices.DeviceConfig", "DeviceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDevicesAndSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Devices",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
OwnerUserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
PairedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
LastSeenAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
|
||||
TokenHash = table.Column<string>(type: "TEXT", nullable: false),
|
||||
IsRevoked = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Devices", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SystemSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
DefaultSystemPrompt = table.Column<string>(type: "TEXT", nullable: false),
|
||||
DefaultVoice = table.Column<string>(type: "TEXT", nullable: false),
|
||||
DefaultModel = table.Column<string>(type: "TEXT", nullable: false),
|
||||
DefaultIdleTimeoutSeconds = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SystemSettings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DeviceConfigs",
|
||||
columns: table => new
|
||||
{
|
||||
DeviceId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
SystemPrompt = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Voice = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Model = table.Column<string>(type: "TEXT", nullable: false),
|
||||
IdleTimeoutSeconds = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
EnabledToolsJson = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DeviceConfigs", x => x.DeviceId);
|
||||
table.ForeignKey(
|
||||
name: "FK_DeviceConfigs_Devices_DeviceId",
|
||||
column: x => x.DeviceId,
|
||||
principalTable: "Devices",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Devices_OwnerUserId",
|
||||
table: "Devices",
|
||||
column: "OwnerUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Devices_TokenHash",
|
||||
table: "Devices",
|
||||
column: "TokenHash",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DeviceConfigs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SystemSettings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Devices");
|
||||
}
|
||||
}
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using backend.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260611184536_AddDeviceOwnerFkAndPinSettingsId")]
|
||||
partial class AddDeviceOwnerFkAndPinSettingsId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.17");
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Auth.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsRevoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("PairedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Devices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("DeviceId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EnabledToolsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("IdleTimeoutSeconds")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("DeviceId");
|
||||
|
||||
b.ToTable("DeviceConfigs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Settings.SystemSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DefaultIdleTimeoutSeconds")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DefaultSystemPrompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DefaultVoice")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SystemSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
|
||||
{
|
||||
b.HasOne("backend.Devices.Device", null)
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("backend.Devices.DeviceConfig", "DeviceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDeviceOwnerFkAndPinSettingsId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "Id",
|
||||
table: "SystemSettings",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "INTEGER")
|
||||
.OldAnnotation("Sqlite:Autoincrement", true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Devices_AspNetUsers_OwnerUserId",
|
||||
table: "Devices",
|
||||
column: "OwnerUserId",
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Devices_AspNetUsers_OwnerUserId",
|
||||
table: "Devices");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "Id",
|
||||
table: "SystemSettings",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "INTEGER")
|
||||
.Annotation("Sqlite:Autoincrement", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using backend.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260611185048_AddPairingCodes")]
|
||||
partial class AddPairingCodes
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.17");
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Auth.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsRevoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("PairedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Devices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("DeviceId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EnabledToolsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("IdleTimeoutSeconds")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("DeviceId");
|
||||
|
||||
b.ToTable("DeviceConfigs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Pairing.PairingCode", b =>
|
||||
{
|
||||
b.Property<string>("Code")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Code");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.ToTable("PairingCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Settings.SystemSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DefaultIdleTimeoutSeconds")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DefaultSystemPrompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DefaultVoice")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SystemSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
|
||||
{
|
||||
b.HasOne("backend.Devices.Device", null)
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("backend.Devices.DeviceConfig", "DeviceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPairingCodes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PairingCodes",
|
||||
columns: table => new
|
||||
{
|
||||
Code = table.Column<string>(type: "TEXT", nullable: false),
|
||||
OwnerUserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
DeviceName = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ExpiresAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
ConsumedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PairingCodes", x => x.Code);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PairingCodes_ExpiresAt",
|
||||
table: "PairingCodes",
|
||||
column: "ExpiresAt");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PairingCodes");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using backend.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.17");
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Auth.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsRevoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("PairedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Devices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("DeviceId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EnabledToolsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("IdleTimeoutSeconds")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("DeviceId");
|
||||
|
||||
b.ToTable("DeviceConfigs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Pairing.PairingCode", b =>
|
||||
{
|
||||
b.Property<string>("Code")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Code");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.ToTable("PairingCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Settings.SystemSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DefaultIdleTimeoutSeconds")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DefaultSystemPrompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DefaultVoice")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SystemSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||
{
|
||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
|
||||
{
|
||||
b.HasOne("backend.Devices.Device", null)
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("backend.Devices.DeviceConfig", "DeviceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace backend.Pairing;
|
||||
|
||||
public class PairingCode
|
||||
{
|
||||
public string Code { get; set; } = ""; // 8 chars, primary key
|
||||
public Guid OwnerUserId { get; set; }
|
||||
public string DeviceName { get; set; } = "";
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset? ConsumedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace backend.Pairing;
|
||||
|
||||
public record IssueCodeRequest(string Name);
|
||||
public record PairRequest(string Code, string Hostname, string Client_version);
|
||||
|
||||
public static class PairingEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPairingEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/pair-code", async (
|
||||
[FromBody] IssueCodeRequest req,
|
||||
PairingService svc,
|
||||
HttpContext http,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var uidStr = http.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!Guid.TryParse(uidStr, out var uid)) return Results.Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(req.Name))
|
||||
return Results.BadRequest(new { error = "name is required" });
|
||||
var pc = await svc.IssueAsync(uid, req.Name.Trim(), ct);
|
||||
return Results.Ok(new { code = pc.Code, expires_at = pc.ExpiresAt });
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapPost("/api/pair", async (
|
||||
[FromBody] PairRequest req,
|
||||
PairingService svc,
|
||||
HttpContext http,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var result = await svc.ConsumeAsync(req.Code, ct);
|
||||
if (result is null) return Results.NotFound(new { error = "code invalid or expired" });
|
||||
var scheme = http.Request.Scheme == "https" ? "wss" : "ws";
|
||||
var host = http.Request.Host.ToString();
|
||||
return Results.Ok(new
|
||||
{
|
||||
device_id = result.DeviceId,
|
||||
device_token = result.DeviceToken,
|
||||
backend_ws = $"{scheme}://{host}/device",
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Security.Cryptography;
|
||||
using backend.Data;
|
||||
using backend.Devices;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace backend.Pairing;
|
||||
|
||||
public record PairResult(Guid DeviceId, string DeviceToken);
|
||||
|
||||
public class PairingService
|
||||
{
|
||||
private const string Alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O, 0, I, 1, L
|
||||
private const int CodeLength = 8;
|
||||
public static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly AppDbContext? _db;
|
||||
private readonly DeviceTokenService? _tokens;
|
||||
|
||||
public PairingService() { }
|
||||
|
||||
public PairingService(AppDbContext db, DeviceTokenService tokens)
|
||||
{
|
||||
_db = db;
|
||||
_tokens = tokens;
|
||||
}
|
||||
|
||||
public string GenerateCode()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[CodeLength];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
Span<char> chars = stackalloc char[CodeLength];
|
||||
for (var i = 0; i < CodeLength; i++)
|
||||
chars[i] = Alphabet[bytes[i] % Alphabet.Length];
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
public async Task<PairingCode> IssueAsync(Guid ownerUserId, string deviceName, CancellationToken ct)
|
||||
{
|
||||
if (_db is null) throw new InvalidOperationException("PairingService not constructed with DB context.");
|
||||
var entity = new PairingCode
|
||||
{
|
||||
Code = GenerateCode(),
|
||||
OwnerUserId = ownerUserId,
|
||||
DeviceName = deviceName,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeTtl),
|
||||
};
|
||||
_db.PairingCodes.Add(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return entity;
|
||||
}
|
||||
|
||||
public async Task<PairResult?> ConsumeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
if (_db is null || _tokens is null)
|
||||
throw new InvalidOperationException("PairingService not constructed with DB context + token service.");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var pc = await _db.PairingCodes.FirstOrDefaultAsync(p => p.Code == code, ct);
|
||||
if (pc is null || pc.ConsumedAt is not null || pc.ExpiresAt < now)
|
||||
return null;
|
||||
|
||||
var (plaintext, hash) = _tokens.Generate();
|
||||
var defaults = await _db.SystemSettings.FirstAsync(ct);
|
||||
|
||||
var device = new Device
|
||||
{
|
||||
OwnerUserId = pc.OwnerUserId,
|
||||
Name = pc.DeviceName,
|
||||
PairedAt = now,
|
||||
TokenHash = hash,
|
||||
};
|
||||
device.Config = new DeviceConfig
|
||||
{
|
||||
DeviceId = device.Id,
|
||||
SystemPrompt = defaults.DefaultSystemPrompt,
|
||||
Voice = defaults.DefaultVoice,
|
||||
Model = defaults.DefaultModel,
|
||||
IdleTimeoutSeconds = defaults.DefaultIdleTimeoutSeconds,
|
||||
};
|
||||
_db.Devices.Add(device);
|
||||
pc.ConsumedAt = now;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new PairResult(device.Id, plaintext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using backend.Auth;
|
||||
using backend.Data;
|
||||
using backend.Devices;
|
||||
using backend.Install;
|
||||
using backend.Pairing;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var keysDir = Environment.GetEnvironmentVariable("DataProtection__KeysDir") ?? "/keys";
|
||||
if (Directory.Exists(keysDir))
|
||||
{
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(keysDir))
|
||||
.SetApplicationName("smart-assistant");
|
||||
}
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(opt =>
|
||||
{
|
||||
opt.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
opt.KnownNetworks.Clear();
|
||||
opt.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(opt =>
|
||||
opt.UseSqlite(builder.Configuration.GetConnectionString("Default")));
|
||||
|
||||
builder.Services.AddAppIdentity();
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddScoped<backend.Devices.DeviceTokenService>();
|
||||
builder.Services.AddScoped<backend.Pairing.PairingService>();
|
||||
builder.Services.AddSingleton<backend.Install.InstallScriptBuilder>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var sp = scope.ServiceProvider;
|
||||
var db = sp.GetRequiredService<AppDbContext>();
|
||||
db.Database.Migrate();
|
||||
await IdentitySetup.SeedRolesAsync(sp);
|
||||
|
||||
if (db.SystemSettings.Find(1) is null)
|
||||
{
|
||||
db.SystemSettings.Add(new backend.Settings.SystemSettings { Id = 1 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapAuthEndpoints();
|
||||
app.MapPairingEndpoints();
|
||||
app.MapDevicesEndpoints();
|
||||
app.MapInstallEndpoints();
|
||||
|
||||
app.MapGet("/health", () => Results.Ok(new { ok = true }));
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program { }
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5252",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7061;http://localhost:5252",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace backend.Settings;
|
||||
|
||||
public class SystemSettings
|
||||
{
|
||||
public int Id { get; set; } = 1;
|
||||
public string DefaultSystemPrompt { get; set; } =
|
||||
"You are a helpful voice assistant. Keep responses concise.";
|
||||
public string DefaultVoice { get; set; } = "alloy";
|
||||
public string DefaultModel { get; set; } = "gpt-4o-realtime-preview";
|
||||
public int DefaultIdleTimeoutSeconds { get; set; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"Default": "Data Source=./assistant.db"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.*" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.*" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.*">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.*" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Smart Assistant client entry point. Implemented in Plan 3."""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
raise SystemExit("client.main: placeholder; full implementation comes in Plan 3.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Smart Assistant pairing CLI. Implemented in Plan 3."""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
raise SystemExit("client.pair: placeholder; full implementation comes in Plan 3.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
expose:
|
||||
- "8080"
|
||||
environment:
|
||||
- ASPNETCORE_URLS=http://+:8080
|
||||
- 'ConnectionStrings__Default=Data Source=/data/assistant.db'
|
||||
- Install__ClientTarballPath=/app/wwwroot/client.tar.gz
|
||||
- SERVICE_FQDN_APP_8080
|
||||
volumes:
|
||||
- assistant-data:/data
|
||||
- assistant-keys:/keys
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
assistant-data:
|
||||
assistant-keys:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,516 @@
|
||||
# Smart Assistant — design
|
||||
|
||||
**Date:** 2026-06-11
|
||||
**Status:** approved (brainstorming complete)
|
||||
**Scope:** main build of the voice assistant, replacing the throwaway prototypes in `tests/`. Two deliverables: a Python client on the Raspberry Pi, and an ASP.NET backend (with embedded React management UI) deployed to Coolify. Brings up everything needed to wake on a wakeword, hold a multi-turn conversation through the OpenAI Realtime API, persist a text history per device, run a small built-in tool registry, and let users manage their devices through a browser.
|
||||
|
||||
## Context
|
||||
|
||||
The prototype phase (`docs/superpowers/specs/2026-06-11-voice-assistant-prototypes-design.md`, results in `findings.md`) proved that recording/playback and openwakeword both work on the target Pi. The main assistant is built on top of those findings.
|
||||
|
||||
Key carry-forwards from the prototypes:
|
||||
|
||||
- USB Speaker Phone (ALSA card 3, Anhui LISTENAI) is the only mic/speaker. 48 kHz native; `PA_ALSA_PLUGHW=1` set before importing `sounddevice` lets us open at any rate.
|
||||
- `python-sounddevice` + `numpy` are the audio stack.
|
||||
- `openwakeword` runs at ~0.6 s load, well under 50% of one core; the `alexa` stock model is reused for v1 (custom-trained wakeword is a separate follow-up task).
|
||||
- `openwakeword==0.6.0` needs `--no-deps` install on Python 3.13 because its `tflite-runtime` pin has no wheel; the prototype's two-pass install pattern is kept.
|
||||
- Speaker hears its own output → detector must be gated while playing audio, and uplink to OpenAI must be muted while the assistant is speaking.
|
||||
- TTS/beep must not block the input stream — callback-driven InputStream + persistent OutputStream is the pattern.
|
||||
|
||||
## Goals
|
||||
|
||||
1. A fresh Raspberry Pi can be paired to a user account by running one curl-piped install command and entering a short code.
|
||||
2. The Pi wakes on the wakeword, opens a Realtime conversation, supports multi-turn until the user says "bye" or goes idle, and plays optional `wake.wav` / `sleep.wav` cues.
|
||||
3. A signed-in user sees only their own devices and conversations in the management UI.
|
||||
4. An admin user can manage system-wide defaults and other users.
|
||||
5. Per-device configuration: name, system prompt override, voice, model, idle timeout, enabled-tools toggles. Changes take effect on the next session.
|
||||
6. Text-only conversation history per device, viewable in the UI.
|
||||
7. Built-in C# tool registry with `end_session`, `get_current_time`, `set_volume` shipped in v1.
|
||||
8. Backend deployed to Coolify as one container with one domain; deploys triggered fire-and-forget via the Coolify v4 API.
|
||||
9. The install script is the update mechanism: re-running it upgrades the client in place without re-pairing.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- Long-term memory across sessions.
|
||||
- MCP-based external tools (only built-in C# tools).
|
||||
- Audio recordings of conversations (text only).
|
||||
- Live conversation tailing or audio meters in the UI.
|
||||
- Per-device upload of `wake.wav`/`sleep.wav` via UI (filesystem only — v1.1).
|
||||
- Custom-trained wakeword (uses the stock `alexa` model for v1).
|
||||
- Barge-in (interrupting the assistant mid-reply).
|
||||
- Multi-language (English only).
|
||||
- Offline / local-only fallback when the network is down.
|
||||
- OIDC sign-in (local ASP.NET Identity only).
|
||||
- `.deb` packaging for the client.
|
||||
- Load / performance testing.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Topology
|
||||
|
||||
```
|
||||
┌─────────────────────┐ pair code / token ┌──────────────────────────────────────────┐
|
||||
│ Browser (admin UI) │ ──────HTTPS (REST)────────▶ │ │
|
||||
│ React SPA │ │ ASP.NET backend (Coolify container) │
|
||||
└─────────────────────┘ │ │
|
||||
│ ┌────────────┐ ┌────────────────────┐ │
|
||||
┌─────────────────────┐ WSS audio + control │ │ REST/UI │ │ Device hub │ │
|
||||
│ Pi client (Python) │ ◀───────────────────────────▶│ │ + Identity│ │ (per-device WS) │ │
|
||||
│ wakeword + audio │ │ └────────────┘ └─────────┬──────────┘ │
|
||||
└─────────────────────┘ │ │ │
|
||||
│ ┌────────────┐ ┌────────▼──────────┐ │
|
||||
│ │ Tools │ │ Realtime relay │ │
|
||||
│ │ registry │◀─┤ (per-session WS │ │
|
||||
│ └────────────┘ │ upstream) │ │
|
||||
│ └─────────┬──────────┘ │
|
||||
│ SQLite (named volume) │ │
|
||||
└────────────────────────────┼────────────┘
|
||||
│ WSS
|
||||
▼
|
||||
OpenAI Realtime API
|
||||
```
|
||||
|
||||
One container; React SPA served by the same ASP.NET process. SQLite file lives on a Coolify named volume. The OpenAI API key is held server-side; clients never see it.
|
||||
|
||||
### Why backend relay (not direct Pi ↔ OpenAI)
|
||||
|
||||
- One API key, server-side only.
|
||||
- All user-side and assistant-side transcripts land in our database directly off the upstream WS events (no separate STT pass).
|
||||
- Tool definitions and changes don't require a client redeploy.
|
||||
- Backend can push audio to a Pi on its own (future feature) over the same WS.
|
||||
|
||||
### Backend
|
||||
|
||||
Single ASP.NET 9 project (`backend/`) organised into modules:
|
||||
|
||||
```
|
||||
backend/
|
||||
├── Program.cs # composition root, EF migrate, route + hub mapping
|
||||
├── Auth/ # ASP.NET Identity, roles (Admin, User), invite flow
|
||||
├── Devices/ # device CRUD, pairing-code issuance, token mgmt
|
||||
├── DeviceHub/ # WebSocket handler at /device, per-device session state
|
||||
├── Realtime/ # RealtimeRelay: per-conversation upstream WS to OpenAI
|
||||
├── Tools/ # ITool interface, registry, built-in tools
|
||||
├── Conversations/ # persistence of turns, transcripts, tool calls
|
||||
├── Install/ # serves install.sh and client.tar.gz
|
||||
├── Data/ # EF Core DbContext, migrations, SQLite
|
||||
└── wwwroot/ # built React SPA (Vite output) + client.tar.gz
|
||||
```
|
||||
|
||||
Key services (DI):
|
||||
|
||||
- `DeviceRegistry` (singleton) — in-memory map `deviceId → ActiveDevice`, tracks online state and last frame timestamp.
|
||||
- `RealtimeSessionFactory` (singleton) — given a `DeviceContext`, opens an upstream WSS to OpenAI and returns a `RealtimeSession` instance bound to one conversation.
|
||||
- `ToolRegistry` (singleton) — static list of `ITool`s registered at startup, filterable by per-device enabled set.
|
||||
- `ConversationLog` (scoped) — append-only writer for turns; flushes per event so a crash mid-conversation still preserves what was said.
|
||||
- `OpenAIKeyProvider` (singleton) — reads `OPENAI_API_KEY` from env.
|
||||
|
||||
### Pi client
|
||||
|
||||
Python package (`client/`), deployed to `~/assistant/code/` on the Pi:
|
||||
|
||||
```
|
||||
client/
|
||||
├── main.py # entry: sets PA_ALSA_PLUGHW BEFORE importing sounddevice
|
||||
├── audio.py # USB device discovery, persistent in/out streams
|
||||
├── wakeword.py # openwakeword wrapper + gating flag
|
||||
├── session.py # backend WS client: binary audio + JSON control
|
||||
├── playback.py # queue-fed playback, wake/sleep wav hooks
|
||||
├── state.py # state machine
|
||||
├── config.py # loads ~/assistant/state/config.json
|
||||
├── pair.py # one-shot: prompt for code, hit /api/pair, write config.json
|
||||
└── log.py # structured logging to journald
|
||||
```
|
||||
|
||||
State persists at `~/assistant/state/`:
|
||||
- `config.json` (mode 0600) — `device_id`, `device_token`, `backend_ws`
|
||||
- `wake.wav` / `sleep.wav` — optional cues, played if present
|
||||
- (room for future per-device assets; never rsynced/overwritten by deploys)
|
||||
|
||||
`~/assistant/code/` is the only directory that the install script replaces on update; `~/assistant/state/` is untouched.
|
||||
|
||||
### State machine
|
||||
|
||||
```
|
||||
wakeword fires session_started
|
||||
┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ ▼ │ ▼
|
||||
[IDLE] ─────────► [WAKE_PENDING] ─────► [LISTENING] ───────► [ASSISTANT_SPEAKING]
|
||||
▲ │ ▲ │
|
||||
│ │ │ assistant done │
|
||||
│ └───┴──────────────────┘
|
||||
│ session_ended
|
||||
└─────────────────────────────── (idle timeout OR end_session tool)
|
||||
plays sleep.wav, re-enables wakeword
|
||||
```
|
||||
|
||||
- **IDLE** — wakeword detector enabled, no upstream audio. Persistent backend WS open with heartbeats.
|
||||
- **WAKE_PENDING** — wakeword fired; play `wake.wav` (non-blocking); send `wake`; wait for `session_started`. Wakeword disabled.
|
||||
- **LISTENING** — mic frames stream to backend; server-VAD on OpenAI decides turn boundaries; we don't compute local VAD.
|
||||
- **ASSISTANT_SPEAKING** — uplink muted; inbound frames flow to playback queue.
|
||||
- On `assistant_done` from backend → LISTENING again (uplink unmuted).
|
||||
- On `session_ended` from backend → wait for current audio to drain, play `sleep.wav`, send `playback_done(for=sleep)` → IDLE.
|
||||
|
||||
### Audio plumbing
|
||||
|
||||
- **Wire format**: PCM16 little-endian, 24 kHz mono, exactly 1920 samples (3840 bytes, 80 ms) per binary frame. Matches OpenAI Realtime's default `pcm16`. No transcoding in the backend.
|
||||
- **Capture**: single InputStream, callback-driven, blocksize 1920, sample rate 24 000 Hz. Callback only enqueues — never blocks.
|
||||
- **Playback**: single OutputStream opened at startup, persistent. Playback worker pulls from an `asyncio.Queue` and writes — never `sd.play()/sd.wait()` (which would block the InputStream the way the prototype did).
|
||||
- **Wakeword feed**: each captured 1920-sample frame is downsampled to 1280 samples (80 ms at 16 kHz) via `scipy.signal.resample_poly(frame, up=2, down=3)` and fed to `model.predict()`.
|
||||
- `PA_ALSA_PLUGHW=1` is set at the very top of `main.py` before any `sounddevice` import.
|
||||
|
||||
### Threading model
|
||||
|
||||
- Main thread runs an asyncio loop owning the backend WS.
|
||||
- PortAudio invokes the InputStream callback on its own thread; the callback writes mic frames to a bounded `queue.Queue`. On overflow, oldest frame is dropped and a warning is logged.
|
||||
- A bridge thread reads `queue.Queue`, runs wakeword.predict() when state is IDLE/WAKE_PENDING, and forwards bytes to the asyncio loop via `loop.call_soon_threadsafe` for upstream sends when state is LISTENING.
|
||||
- A playback worker (asyncio task) drains an `asyncio.Queue` and writes to the OutputStream in a thread executor.
|
||||
|
||||
### Reconnect & heartbeat
|
||||
|
||||
- Pi → backend WS reconnects with exponential backoff 1 → 30 s.
|
||||
- While disconnected, state stays IDLE; wakewords are ignored.
|
||||
- An in-flight conversation does **not** survive a network drop — backend marks conversation `EndReason="error"`.
|
||||
- Client sends `{"type":"ping"}` every 15 s when IDLE; backend uses it to update `Devices.LastSeenAt` and drive the UI online indicator.
|
||||
|
||||
## Wire protocol (Pi ↔ backend)
|
||||
|
||||
One persistent WebSocket at `wss://<backend>/device`. Authenticated via `Authorization: Bearer <device-token>` on the upgrade request; connection rejected if the token is revoked or unknown.
|
||||
|
||||
### Binary frames
|
||||
|
||||
Raw PCM16 LE, 24 kHz mono, exactly 1920 samples (3840 bytes). No header. Direction inferred from sender. Backend drops binary frames received outside an active session. Wrong-sized frames are dropped silently with a log entry (protects against runaways without surfacing an error).
|
||||
|
||||
### Text frames (JSON envelopes)
|
||||
|
||||
Client → backend:
|
||||
|
||||
| `type` | Fields | Notes |
|
||||
| ----------------- | ----------------------------------- | ----------------------------------------------------------- |
|
||||
| `hello` | `client_version`, `device_id` | Sent on connect; backend replies `hello_ack` with the device's config snapshot. |
|
||||
| `ping` | — | Every 15 s while IDLE. Backend replies `pong`. |
|
||||
| `wake` | `at` (client ms timestamp) | Triggers backend to open upstream Realtime WS. |
|
||||
| `cancel` | — | User-initiated abort (e.g. SIGINT). Backend closes session. |
|
||||
| `playback_done` | `for` (`wake` \| `sleep` \| `assistant_turn`) | Lets backend know when audio finished playing on the Pi (needed to time `session_ended` after `sleep.wav`). |
|
||||
| `tool_result` | `call_id`, `ok`, `result?`, `error?` | Reply to a Pi-side tool call (e.g. `set_volume`). |
|
||||
|
||||
Backend → client:
|
||||
|
||||
| `type` | Fields | Notes |
|
||||
| ------------------ | ----------------------------------- | ---------------------------------------------------------- |
|
||||
| `hello_ack` | `config` (voice/model/prompt/tools/idle_timeout) | Client uses this to set local state. |
|
||||
| `pong` | — | |
|
||||
| `config_updated` | `config` | Pushed when admin saves changes in the UI; takes effect next session. |
|
||||
| `session_started` | `conversation_id` | Realtime upstream is ready; client may start uplink. |
|
||||
| `assistant_done` | — | Assistant turn ended; client unmutes uplink. |
|
||||
| `session_ended` | `reason` (`tool` \| `idle` \| `error`) | Client plays `sleep.wav` (if present), then `playback_done(for=sleep)`. |
|
||||
| `tool_call` | `call_id`, `name`, `arguments` | Invoked for tools whose execution requires the Pi. |
|
||||
| `play_audio` | `kind` (`session` \| `notification`) | Marker; subsequent binary frames are payload. Ended by `play_audio_end`. |
|
||||
| `play_audio_end` | — | End-of-payload marker for `play_audio(kind=notification)`. |
|
||||
| `error` | `code`, `message`, `fatal?` | Logged on client; non-fatal unless `fatal=true`. |
|
||||
|
||||
Uplink muting is purely a client-side decision based on state. Backend doesn't enforce it.
|
||||
|
||||
## Conversation lifecycle
|
||||
|
||||
```
|
||||
PI BACKEND OPENAI REALTIME
|
||||
─────────────────────────────────────────────────────────────────────────────────
|
||||
state=IDLE
|
||||
mic frames → wakeword.predict
|
||||
wakeword fires
|
||||
play wake.wav (non-blocking)
|
||||
send {type:"wake"} ──►
|
||||
open WSS to OpenAI ──►
|
||||
send session.update ──►
|
||||
(voice, instructions,
|
||||
input_audio_transcription,
|
||||
tools=[end_session,
|
||||
get_current_time,
|
||||
set_volume])
|
||||
◄── session.updated
|
||||
insert Conversations row
|
||||
send {type:"session_started",
|
||||
conversation_id} ──►
|
||||
state=LISTENING
|
||||
mic frames (binary) ──────────────► forward as input_audio_buffer.append ─►
|
||||
◄── input_audio_buffer.speech_started
|
||||
◄── input_audio_buffer.speech_stopped
|
||||
◄── response.created
|
||||
◄── response.audio.delta (x N)
|
||||
binary frames ◄─────────────── forward to PI as binary
|
||||
binary frames ──► playback queue
|
||||
state=ASSISTANT_SPEAKING
|
||||
(uplink muted)
|
||||
◄── response.audio_transcript.done
|
||||
(persist assistant Turn)
|
||||
◄── response.done
|
||||
send {type:"assistant_done"} ──►
|
||||
state=LISTENING
|
||||
◄── conversation.item.input_audio_transcription.completed
|
||||
(persist user Turn)
|
||||
...follow-up turn(s)...
|
||||
|
||||
Path A — model calls end_session
|
||||
◄── response.function_call_arguments.done
|
||||
(name="end_session")
|
||||
◄── response.audio.delta (the "bye" reply)
|
||||
forward audio to PI ──────────►
|
||||
wait for response.done
|
||||
◄── response.done
|
||||
send {type:"assistant_done"} ──►
|
||||
send {type:"session_ended",
|
||||
reason="tool"} ──►
|
||||
close upstream WSS
|
||||
persist Conversations.EndReason
|
||||
state=IDLE-PENDING
|
||||
wait for current audio buffer drain
|
||||
play sleep.wav
|
||||
send {type:"playback_done",
|
||||
for:"sleep"} ──►
|
||||
state=IDLE
|
||||
(wakeword re-enabled)
|
||||
|
||||
Path B — idle timeout
|
||||
no mic activity for IdleTimeoutSeconds while LISTENING
|
||||
send {type:"session_ended",
|
||||
reason="idle"} ──►
|
||||
close upstream WSS
|
||||
(same sleep.wav + transition as Path A)
|
||||
```
|
||||
|
||||
### `end_session` sequencing
|
||||
|
||||
The tool call arrives *during* the assistant's response. The relay must:
|
||||
|
||||
1. Forward all audio deltas until `response.done`.
|
||||
2. Then send `assistant_done`, then `session_ended(reason=tool)`.
|
||||
3. NOT execute the tool mid-response — `end_session` has no real side effect; the relay records "the model asked to end" and closes after the audio finishes. The tool result returned to OpenAI is `{"ok": true}` (for cleanliness; the upstream is about to close).
|
||||
|
||||
## Tools registry
|
||||
|
||||
C# interface, registered as singletons at startup:
|
||||
|
||||
```csharp
|
||||
public interface ITool
|
||||
{
|
||||
string Name { get; } // OpenAI tool name
|
||||
string Description { get; } // shown to the model
|
||||
JsonSchema ParameterSchema { get; } // OpenAI-compatible
|
||||
Task<ToolResult> ExecuteAsync(
|
||||
ToolInvocation invocation,
|
||||
DeviceContext device,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
public record ToolInvocation(string CallId, JsonElement Arguments);
|
||||
public record ToolResult(JsonElement Output); // Output is JSON sent back to model
|
||||
public record DeviceContext(Guid DeviceId, Guid ConversationId, IDeviceChannel Channel);
|
||||
```
|
||||
|
||||
`IDeviceChannel` lets a tool send `tool_call`-style messages to the Pi and await `tool_result`. Tools that don't need the Pi simply don't use it.
|
||||
|
||||
### v1 built-in tools
|
||||
|
||||
1. **`end_session`** — no parameters. Sets a "model wants to end" flag on the active `RealtimeSession`; the relay's `response.done` handler closes the upstream WS and emits `session_ended(reason=tool)`. Returns `{"ok": true}`.
|
||||
|
||||
2. **`get_current_time`** — no parameters. Returns `{"now": "<ISO-8601 UTC>"}` from `DateTimeOffset.UtcNow`. Smoke test for the tool plane.
|
||||
|
||||
3. **`set_volume`** — `level: integer 0..100`. Backend sends `tool_call(name=set_volume, arguments={level})` to the Pi; Pi runs `amixer set 'PCM' N%` (or device-specific mixer) and replies with `tool_result(call_id, ok, error?)`. Backend forwards back to OpenAI as the function call output.
|
||||
|
||||
The per-device enabled set is enforced by filtering the tool list in `session.update`. Disabled tools are simply not advertised to the model.
|
||||
|
||||
## Data model (SQLite via EF Core)
|
||||
|
||||
```
|
||||
AspNetUsers, AspNetRoles, ... # Identity standard
|
||||
Devices
|
||||
Id (Guid) OwnerUserId Name PairedAt LastSeenAt TokenHash IsRevoked
|
||||
DeviceConfigs # 1:1 with Devices
|
||||
DeviceId SystemPrompt Voice Model IdleTimeoutSeconds EnabledToolsJson
|
||||
PairingCodes
|
||||
Code (8 chars) OwnerUserId ExpiresAt ConsumedAt
|
||||
Conversations
|
||||
Id DeviceId StartedAt EndedAt EndReason ("tool" | "idle" | "error")
|
||||
Turns
|
||||
Id ConversationId Index Role ("user" | "assistant" | "tool") Text ToolName ToolArgsJson ToolResultJson CreatedAt
|
||||
SystemSettings # singleton row
|
||||
DefaultVoice DefaultModel DefaultSystemPrompt
|
||||
```
|
||||
|
||||
- `TokenHash` = SHA-256 of the device token; plaintext is shown once during pairing and never persisted.
|
||||
- `EnabledToolsJson` is a small JSON array of tool names; no relational table needed.
|
||||
- `Turns.Index` is monotonic per-conversation for ordering.
|
||||
- `Database.Migrate()` runs on startup; SQLite file at `/data/assistant.db` on a Coolify named volume.
|
||||
|
||||
## Pairing flow
|
||||
|
||||
UI side:
|
||||
|
||||
1. User signs in, clicks **Add device**, enters a friendly name.
|
||||
2. Backend generates an 8-char alphanumeric code from a 32-char alphabet (no `0/O/1/l/I`); stores `PairingCodes(Code, OwnerUserId, ExpiresAt = NOW + 10 min)`.
|
||||
3. UI shows the code and the one-line installer:
|
||||
```
|
||||
curl -fsSL https://<backend>/install.sh | bash
|
||||
```
|
||||
4. Code stays visible on the device page until consumed or expired; regenerate at any time.
|
||||
|
||||
Pi side, first run:
|
||||
|
||||
1. Installer runs.
|
||||
2. If `~/assistant/state/config.json` exists with a valid `device_token`, **skip pairing** and proceed to update path.
|
||||
3. Otherwise prompt `Pairing code: `.
|
||||
4. `POST https://<backend>/api/pair` with `{ "code", "hostname", "client_version" }`.
|
||||
5. Backend validates code (exists, unexpired, unconsumed), creates `Devices(OwnerUserId = code.OwnerUserId, Name = <user-set-name>, TokenHash = sha256(newToken))`, marks code `ConsumedAt = NOW`, creates default `DeviceConfigs` row from `SystemSettings`, returns `{ "device_id", "device_token", "backend_ws" }`.
|
||||
6. Installer writes `~/assistant/state/config.json` (mode 0600).
|
||||
|
||||
Security:
|
||||
- Pairing codes are one-shot, 10-min TTL, 8 chars from a 32-char alphabet (~40 bits — fine for the TTL window).
|
||||
- Device tokens are 32 bytes random, base64url-encoded, stored only as SHA-256 hash server-side.
|
||||
|
||||
## Install script
|
||||
|
||||
`install.sh` is served by the backend at `GET /install.sh` and is fully idempotent.
|
||||
|
||||
```
|
||||
1. Resolve BACKEND_URL from the script's own served-from origin (no hardcoding).
|
||||
2. apt-get install -y python3-venv libportaudio2 alsa-utils
|
||||
(sudo prompt OK; passwordless-sudo per findings.md §E is the recommended setup).
|
||||
3. mkdir -p ~/assistant/{code,state}
|
||||
4. python3 -m venv ~/assistant/.venv (if missing)
|
||||
5. curl -fsSL $BACKEND_URL/client.tar.gz | tar -xz -C ~/assistant/code
|
||||
6. ~/assistant/.venv/bin/pip install -r ~/assistant/code/requirements.txt
|
||||
7. ~/assistant/.venv/bin/pip install --no-deps -r ~/assistant/code/requirements-nodeps.txt
|
||||
(openwakeword install split per findings.md §3).
|
||||
8. If config.json missing → run `~/assistant/.venv/bin/python -m client.pair`,
|
||||
which prompts for the code and writes config.json.
|
||||
9. Install systemd user unit at ~/.config/systemd/user/assistant.service.
|
||||
10. systemctl --user daemon-reload
|
||||
11. systemctl --user enable --now assistant
|
||||
12. loginctl enable-linger pi (so the unit runs without an active login).
|
||||
13. Print status; if pairing happened, print "Paired to <backend> as device <name>".
|
||||
```
|
||||
|
||||
The systemd user unit runs `~/assistant/.venv/bin/python -m client.main` with:
|
||||
- `Restart=on-failure`
|
||||
- `RestartSec=5`
|
||||
- `Environment=PA_ALSA_PLUGHW=1`
|
||||
|
||||
### Updates
|
||||
|
||||
Re-running the same one-liner upgrades the client. Steps 5–7 fetch and reinstall the latest bundle; step 11 restarts the unit. Pairing is skipped because `config.json` already exists.
|
||||
|
||||
`curl ... | bash -s -- --reset` deletes `config.json` and forces a fresh pairing.
|
||||
|
||||
### Backend install endpoints
|
||||
|
||||
- `GET /install.sh` — returns the shell script with `BACKEND_URL` baked from the request's `X-Forwarded-Host` / `Host`. Public, no auth.
|
||||
- `GET /client.tar.gz` — returns the current client bundle (built at deploy time into `wwwroot/`). Public, no auth — the code is not a secret; the device token gates the device WS.
|
||||
- `POST /api/pair` — accepts pairing code, returns device token. No prior auth (code is the credential).
|
||||
|
||||
The bundle is built during the Docker image build: a stage runs `tar czf client.tar.gz -C . client/ requirements.txt requirements-nodeps.txt` and copies the result into `wwwroot/`.
|
||||
|
||||
## Error handling
|
||||
|
||||
| Scenario | Detection | Backend action | Client action |
|
||||
| --- | --- | --- | --- |
|
||||
| Pi loses network | WS close / heartbeat miss | Close upstream Realtime WS if active, log conversation `EndReason="error"`, mark device offline | Exponential backoff reconnect 1→30 s, stay IDLE, no wakeword |
|
||||
| Backend restarts | Pi WS closes from server side | (n/a — process gone) | Same backoff reconnect; on reconnect send fresh `hello` |
|
||||
| OpenAI WS drops mid-conversation | upstream `on_close` | Send `error(code=upstream)` + `session_ended(reason=error)`, persist partial transcript | Play `sleep.wav`, return to IDLE |
|
||||
| OpenAI rate limit / 429 on connect | upstream open fails | Don't open session; send `error(code=upstream, message=...)` instead of `session_started` | Play `sleep.wav`, IDLE |
|
||||
| Tool execution exception (server-side) | C# try/catch around `ITool.ExecuteAsync` | Send tool result `{"error": "..."}` back to OpenAI so the model can recover; log | (no client involvement) |
|
||||
| Pi-side tool fails (e.g. `set_volume` amixer error) | `tool_result.ok=false` | Forward `{"error": "..."}` to OpenAI | Log and continue |
|
||||
| Device token revoked while connected | Admin clicks revoke | Set `IsRevoked=true`, close all WS for that device with `error(code=auth_revoked, fatal=true)` | Stop the process (systemd will restart, fail to auth, sleep 60 s, retry — visible in logs) |
|
||||
| SQLite write failure | EF exception | Bubble up to REST as 500; on the hub side, drop the offending log event, keep session running | (n/a) |
|
||||
| Two simultaneous wakeword fires | `wake` while not IDLE | Backend ignores second `wake` (idempotent within active session) | Client also gates locally |
|
||||
| Audio buffer overflow on Pi | PortAudio callback queue full | (n/a) | Drop oldest frame, log a warning |
|
||||
|
||||
## Management UI (v1)
|
||||
|
||||
React SPA, served from the backend's `wwwroot/`. Pages:
|
||||
|
||||
- **Sign in / sign up** (ASP.NET Identity).
|
||||
- **Devices** — list of the signed-in user's devices: name, online indicator, last-seen.
|
||||
- **Device detail** — rename; generate pairing code (with countdown); revoke; per-device config form: system prompt override, voice, model, idle timeout slider, enabled-tools checkboxes; conversation history viewer (paginated, text only).
|
||||
- **Admin** (admin role only) — user list, invite/disable users, global model/voice/prompt defaults.
|
||||
|
||||
Out of v1: live conversation tail, audio meters, audio recordings, memory UI, per-device `wake.wav`/`sleep.wav` upload (filesystem only for v1; v1.1 adds upload).
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Backend unit tests** (xUnit): `ToolRegistry` filtering by enabled set; `PairingService` (code generation, expiry, single-use); `ConversationLog` (turn ordering, persistence); auth/role enforcement on the admin endpoints.
|
||||
- **Backend integration tests** (`WebApplicationFactory`): full pair flow end-to-end against in-memory SQLite; device-hub WS with a fake OpenAI upstream (test double that emits scripted Realtime events).
|
||||
- **Client unit tests** (pytest): state machine transitions (pure-Python, no audio); resample correctness against golden samples; config loading. Audio I/O and wakeword are not unit-tested — already covered by the prototypes and by manual end-to-end.
|
||||
- **End-to-end manual smoke test** (in repo `README.md`): pair a fresh Pi, say wakeword, ask "what time is it", say "bye", verify conversation row + sleep.wav heard. Run after any change that touches the conversation flow.
|
||||
- No load / perf testing.
|
||||
|
||||
## Deployment (Coolify)
|
||||
|
||||
Single Docker image, multi-stage Dockerfile (`preparing-dotnet-react-app-for-coolify` skill is the reference for the layout):
|
||||
|
||||
1. **Frontend build** stage — Node, `npm ci && npm run build` against `frontend/`.
|
||||
2. **Backend build** stage — .NET SDK, `dotnet publish backend/ -c Release -o /publish`.
|
||||
3. **Client bundle** stage — `tar czf /publish/wwwroot/client.tar.gz -C . client/ requirements.txt requirements-nodeps.txt`.
|
||||
4. **Final** stage — `mcr.microsoft.com/dotnet/aspnet:9.0`, copies `/publish`, copies SPA `dist/` into `wwwroot/`, sets `ASPNETCORE_URLS=http://+:8080`, exposes 8080.
|
||||
|
||||
Coolify app (provisioned via `deploying-to-coolify-via-api`):
|
||||
|
||||
- Source: this repo on Gitea.
|
||||
- Expose 8080 (Traefik fronts it).
|
||||
- `SERVICE_FQDN_BACKEND` → backend domain.
|
||||
- Env vars:
|
||||
- `OPENAI_API_KEY` — secret.
|
||||
- `ConnectionStrings__Default=Data Source=/data/assistant.db`
|
||||
- `ASPNETCORE_URLS=http://+:8080`
|
||||
- Named volumes:
|
||||
- `/data` — SQLite file.
|
||||
- `/keys` — ASP.NET data-protection keys (cookies survive container restarts).
|
||||
- Deploys triggered fire-and-forget via the Coolify v4 API (per global CLAUDE.md rule).
|
||||
|
||||
## Repo layout (final)
|
||||
|
||||
```
|
||||
Assistant/
|
||||
├── CLAUDE.md
|
||||
├── README.md # smoke-test instructions
|
||||
├── findings.md # prototype-phase findings (kept)
|
||||
├── tests/ # prototype throwaways (kept for reference)
|
||||
│ ├── 01-record-play/
|
||||
│ └── 02-wakeword/
|
||||
├── docs/superpowers/
|
||||
│ ├── specs/
|
||||
│ │ ├── 2026-06-11-voice-assistant-prototypes-design.md
|
||||
│ │ └── 2026-06-11-smart-assistant-design.md # this design
|
||||
│ └── plans/
|
||||
│ └── (one or more implementation plans, written next)
|
||||
├── backend/ # ASP.NET solution
|
||||
│ ├── backend.csproj
|
||||
│ ├── Program.cs
|
||||
│ ├── Auth/ Devices/ DeviceHub/ Realtime/ Tools/ Conversations/ Install/ Data/
|
||||
│ └── wwwroot/ # filled at build: SPA dist + client.tar.gz
|
||||
├── frontend/ # React + Vite source
|
||||
│ ├── package.json
|
||||
│ └── src/
|
||||
├── client/ # Python package
|
||||
│ ├── requirements.txt # sounddevice, numpy, scipy, onnxruntime, websockets, ...
|
||||
│ ├── requirements-nodeps.txt # openwakeword
|
||||
│ ├── main.py audio.py wakeword.py session.py playback.py state.py config.py pair.py log.py
|
||||
│ └── systemd/assistant.service
|
||||
├── Dockerfile
|
||||
└── deploy.json # Coolify config (project/server uuids, domain)
|
||||
```
|
||||
|
||||
## Open items deferred to implementation plans
|
||||
|
||||
- Exact OpenAI Realtime model + voice default (`SystemSettings.DefaultModel`/`DefaultVoice`) — pick at implementation time from currently-available choices.
|
||||
- Whether wake.wav / sleep.wav playback uses the persistent OutputStream (preferred — same path as session audio) or a separate one-shot — settled in the client plan.
|
||||
- ASP.NET data-protection key seeding (initial bootstrap on first run).
|
||||
- React UI component library — Tailwind only, or Tailwind + a small headless component lib (Radix / shadcn).
|
||||
- Exact mixer control name for `set_volume` on the LISTENAI device — confirm with `amixer scontrols` on the Pi during implementation.
|
||||
|
||||
## Reference paths
|
||||
|
||||
- Prototype spec: `docs/superpowers/specs/2026-06-11-voice-assistant-prototypes-design.md`
|
||||
- Prototype findings: `findings.md`
|
||||
- Pi access: `pi@192.168.50.115`, password `assistant` (see `CLAUDE.md`)
|
||||
- Coolify skills: `deploying-to-coolify-via-api`, `preparing-dotnet-react-app-for-coolify`
|
||||
@@ -0,0 +1 @@
|
||||
openwakeword==0.6.0
|
||||
@@ -0,0 +1,8 @@
|
||||
sounddevice==0.5.1
|
||||
numpy>=2.0
|
||||
scipy>=1.13
|
||||
onnxruntime>=1.18
|
||||
scikit-learn
|
||||
tqdm
|
||||
requests
|
||||
websockets>=12.0
|
||||
Reference in New Issue
Block a user