From 9f61e31043a99c3a5612a8048afdb52920381dfd Mon Sep 17 00:00:00 2001
From: Adam Reed <16042069+iamadamreed@users.noreply.github.com>
Date: Mon, 20 Jul 2026 01:27:32 -0500
Subject: [PATCH 1/7] Replace Linear config with GitHub work tracking in
CLAUDE.md
---
CLAUDE.md | 17 +++--------------
1 file changed, 3 insertions(+), 14 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index cb3413c..eb380bf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,22 +2,11 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-## Linear Configuration
+## Work Tracking
+
+Work is tracked in GitHub (issues and pull requests) on [iamadamreed/Unifi.NET](https://github.com/iamadamreed/Unifi.NET). Use the `gh` CLI for GitHub operations.
```yaml
-LINEAR_PROJECT_ID: "62b16559-5a98-4a38-8980-bcfc96cc72f1"
-LINEAR_PROJECT_NAME: "Unifi.NET"
-LINEAR_TEAM_ID: "7fe71b21-cd2a-4776-98ef-8814b2921b8c"
-LINEAR_TEAM_NAME: "Spire Recovery Solutions"
-
-# Status IDs
-LINEAR_STATUS_ON_DECK: "2ec690d5-e27e-45dc-bf26-9201f3b2e276"
-LINEAR_STATUS_IN_PROGRESS: "d171b2d5-87f8-4c4e-b7d0-a13dd5bc6689"
-LINEAR_STATUS_BLOCKED: "8a88d533-e078-425a-b1f1-ad93cad81ef2"
-LINEAR_STATUS_IN_REVIEW: "6f1d6fc0-4e3c-42da-903a-85fcc3ea83e9"
-LINEAR_STATUS_DONE: "cf75dc3e-2542-4918-b2da-980fac469533"
-
-# Branch Configuration
DEFAULT_BRANCH: "main"
```
From 2299c8cdf1f98c5ac2317ffd010d868ee21d867f Mon Sep 17 00:00:00 2001
From: Adam Reed <16042069+iamadamreed@users.noreply.github.com>
Date: Mon, 20 Jul 2026 01:35:31 -0500
Subject: [PATCH 2/7] Migrate solution to .NET 10
- TargetFramework net9.0 -> net10.0 across all projects
- global.json SDK 9.0.100 -> 10.0.100
- Microsoft.* packages 9.0.0 -> 10.0.0, Test.Sdk 17.14.1, NetAnalyzers 10.0.0
- Drop explicit System.Text.Json reference (framework-provided, NU1510)
- Pin Microsoft.OpenApi 2.11.0 (transitive 2.0.0 has GHSA-v5pm-xwqc-g5wc)
- Update CI workflow and cross-build Dockerfile to .NET 10
---
.github/workflows/publish.yml | 2 +-
Directory.Build.props | 2 +-
Directory.Packages.props | 40 +++++++++----------
Dockerfile.windows-cross | 2 +-
.../Unifi.NET.Access.Tests.csproj | 2 +-
Unifi.NET.Access/Unifi.NET.Access.csproj | 1 -
Unifi.NET.AccessViewer/Directory.Build.props | 2 +-
.../Unifi.NET.AccessViewer.Client.csproj | 2 +-
.../Unifi.NET.AccessViewer.Server.csproj | 4 +-
.../Unifi.NET.AccessViewer.Shared.csproj | 2 +-
.../Unifi.NET.Common.Tests.csproj | 2 +-
Unifi.NET.Common/Unifi.NET.Common.csproj | 1 -
Unifi.NET.Network/Unifi.NET.Network.csproj | 2 +-
Unifi.NET.Protect/Unifi.NET.Protect.csproj | 2 +-
Unifi.NET.Samples/Unifi.NET.Samples.csproj | 2 +-
.../Unifi.NET.SiteManager.csproj | 2 +-
global.json | 2 +-
17 files changed, 36 insertions(+), 36 deletions(-)
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index f9c729b..32cea9c 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -23,7 +23,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
- dotnet-version: '9.x'
+ dotnet-version: '10.x'
- name: Set Branch-Specific Variables
run: |
diff --git a/Directory.Build.props b/Directory.Build.props
index 415a25c..fcb17da 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,6 +1,6 @@
- net9.0
+ net10.0
latest
enable
enable
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3305adb..b898974 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,27 +6,27 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
-
+
@@ -34,6 +34,6 @@
-
+
\ No newline at end of file
diff --git a/Dockerfile.windows-cross b/Dockerfile.windows-cross
index f5d1247..47f9dd6 100644
--- a/Dockerfile.windows-cross
+++ b/Dockerfile.windows-cross
@@ -1,5 +1,5 @@
# Use Linux .NET SDK to cross-compile for Windows
-FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
diff --git a/Unifi.NET.Access.Tests/Unifi.NET.Access.Tests.csproj b/Unifi.NET.Access.Tests/Unifi.NET.Access.Tests.csproj
index 371b6ff..0122f96 100644
--- a/Unifi.NET.Access.Tests/Unifi.NET.Access.Tests.csproj
+++ b/Unifi.NET.Access.Tests/Unifi.NET.Access.Tests.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net10.0
enable
enable
false
diff --git a/Unifi.NET.Access/Unifi.NET.Access.csproj b/Unifi.NET.Access/Unifi.NET.Access.csproj
index 6fc5e45..693e154 100644
--- a/Unifi.NET.Access/Unifi.NET.Access.csproj
+++ b/Unifi.NET.Access/Unifi.NET.Access.csproj
@@ -14,7 +14,6 @@
-
diff --git a/Unifi.NET.AccessViewer/Directory.Build.props b/Unifi.NET.AccessViewer/Directory.Build.props
index 2e660e8..231095f 100644
--- a/Unifi.NET.AccessViewer/Directory.Build.props
+++ b/Unifi.NET.AccessViewer/Directory.Build.props
@@ -1,7 +1,7 @@
- false
+ false
false
true
diff --git a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Client/Unifi.NET.AccessViewer.Client.csproj b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Client/Unifi.NET.AccessViewer.Client.csproj
index 13347cd..9e0b15e 100644
--- a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Client/Unifi.NET.AccessViewer.Client.csproj
+++ b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Client/Unifi.NET.AccessViewer.Client.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net10.0
enable
enable
diff --git a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Unifi.NET.AccessViewer.Server.csproj b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Unifi.NET.AccessViewer.Server.csproj
index eb5ac8d..48ce937 100644
--- a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Unifi.NET.AccessViewer.Server.csproj
+++ b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Unifi.NET.AccessViewer.Server.csproj
@@ -1,13 +1,15 @@
- net9.0
+ net10.0
enable
enable
+
+
diff --git a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Shared/Unifi.NET.AccessViewer.Shared.csproj b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Shared/Unifi.NET.AccessViewer.Shared.csproj
index 125f4c9..b760144 100644
--- a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Shared/Unifi.NET.AccessViewer.Shared.csproj
+++ b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Shared/Unifi.NET.AccessViewer.Shared.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net10.0
enable
enable
diff --git a/Unifi.NET.Common.Tests/Unifi.NET.Common.Tests.csproj b/Unifi.NET.Common.Tests/Unifi.NET.Common.Tests.csproj
index e8d1d44..9868570 100644
--- a/Unifi.NET.Common.Tests/Unifi.NET.Common.Tests.csproj
+++ b/Unifi.NET.Common.Tests/Unifi.NET.Common.Tests.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net10.0
enable
enable
false
diff --git a/Unifi.NET.Common/Unifi.NET.Common.csproj b/Unifi.NET.Common/Unifi.NET.Common.csproj
index 2959523..6a4293a 100644
--- a/Unifi.NET.Common/Unifi.NET.Common.csproj
+++ b/Unifi.NET.Common/Unifi.NET.Common.csproj
@@ -9,7 +9,6 @@
-
diff --git a/Unifi.NET.Network/Unifi.NET.Network.csproj b/Unifi.NET.Network/Unifi.NET.Network.csproj
index 125f4c9..b760144 100644
--- a/Unifi.NET.Network/Unifi.NET.Network.csproj
+++ b/Unifi.NET.Network/Unifi.NET.Network.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net10.0
enable
enable
diff --git a/Unifi.NET.Protect/Unifi.NET.Protect.csproj b/Unifi.NET.Protect/Unifi.NET.Protect.csproj
index 125f4c9..b760144 100644
--- a/Unifi.NET.Protect/Unifi.NET.Protect.csproj
+++ b/Unifi.NET.Protect/Unifi.NET.Protect.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net10.0
enable
enable
diff --git a/Unifi.NET.Samples/Unifi.NET.Samples.csproj b/Unifi.NET.Samples/Unifi.NET.Samples.csproj
index 0fc4dd6..ade972f 100644
--- a/Unifi.NET.Samples/Unifi.NET.Samples.csproj
+++ b/Unifi.NET.Samples/Unifi.NET.Samples.csproj
@@ -2,7 +2,7 @@
Exe
- net9.0
+ net10.0
enable
enable
true
diff --git a/Unifi.NET.SiteManager/Unifi.NET.SiteManager.csproj b/Unifi.NET.SiteManager/Unifi.NET.SiteManager.csproj
index 125f4c9..b760144 100644
--- a/Unifi.NET.SiteManager/Unifi.NET.SiteManager.csproj
+++ b/Unifi.NET.SiteManager/Unifi.NET.SiteManager.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net10.0
enable
enable
diff --git a/global.json b/global.json
index d5bf446..f72210c 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "9.0.100",
+ "version": "10.0.100",
"rollForward": "latestFeature"
}
}
\ No newline at end of file
From a70b6ceaf4856fd601d519c415c7f5a96ccce1b5 Mon Sep 17 00:00:00 2001
From: Adam Reed <16042069+iamadamreed@users.noreply.github.com>
Date: Mon, 20 Jul 2026 01:45:51 -0500
Subject: [PATCH 3/7] Modernize Access SDK for .NET 10
- System.Threading.Lock for the combined-resolver lazy init
- Seal internal JsonSerializerContext classes
- Collection expressions for empty-list/array returns
---
.../Contexts/AccessPolicyJsonContext.cs | 2 +-
.../Serialization/Contexts/CoreJsonContext.cs | 2 +-
.../Serialization/Contexts/CredentialJsonContext.cs | 2 +-
.../Serialization/Contexts/DeviceJsonContext.cs | 2 +-
.../Serialization/Contexts/DoorJsonContext.cs | 2 +-
.../Serialization/Contexts/SystemLogJsonContext.cs | 2 +-
.../Serialization/Contexts/UserGroupJsonContext.cs | 2 +-
.../Serialization/Contexts/UserJsonContext.cs | 2 +-
.../Serialization/UnifiAccessJsonContext.cs | 2 +-
Unifi.NET.Access/Services/AccessPolicyService.cs | 2 +-
Unifi.NET.Access/Services/CredentialService.cs | 6 +++---
Unifi.NET.Access/Services/DeviceService.cs | 4 ++--
Unifi.NET.Access/Services/DoorService.cs | 2 +-
Unifi.NET.Access/Services/SystemLogService.cs | 2 +-
Unifi.NET.Access/Services/UserGroupService.cs | 12 ++++++------
Unifi.NET.Access/Services/UserService.cs | 10 +++++-----
16 files changed, 28 insertions(+), 28 deletions(-)
diff --git a/Unifi.NET.Access/Serialization/Contexts/AccessPolicyJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/AccessPolicyJsonContext.cs
index 95f0085..b8df053 100644
--- a/Unifi.NET.Access/Serialization/Contexts/AccessPolicyJsonContext.cs
+++ b/Unifi.NET.Access/Serialization/Contexts/AccessPolicyJsonContext.cs
@@ -20,6 +20,6 @@ namespace Unifi.NET.Access.Serialization.Contexts;
// API wrapper types
[JsonSerializable(typeof(UnifiApiResponse))]
[JsonSerializable(typeof(UnifiApiResponse>))]
-internal partial class AccessPolicyJsonContext : JsonSerializerContext
+internal sealed partial class AccessPolicyJsonContext : JsonSerializerContext
{
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Serialization/Contexts/CoreJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/CoreJsonContext.cs
index 8c6d52d..0b3e316 100644
--- a/Unifi.NET.Access/Serialization/Contexts/CoreJsonContext.cs
+++ b/Unifi.NET.Access/Serialization/Contexts/CoreJsonContext.cs
@@ -16,6 +16,6 @@ namespace Unifi.NET.Access.Serialization.Contexts;
[JsonSerializable(typeof(Dictionary))]
[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(PaginationInfo))]
-internal partial class CoreJsonContext : JsonSerializerContext
+internal sealed partial class CoreJsonContext : JsonSerializerContext
{
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Serialization/Contexts/CredentialJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/CredentialJsonContext.cs
index f78f33d..6c882b8 100644
--- a/Unifi.NET.Access/Serialization/Contexts/CredentialJsonContext.cs
+++ b/Unifi.NET.Access/Serialization/Contexts/CredentialJsonContext.cs
@@ -34,6 +34,6 @@ namespace Unifi.NET.Access.Serialization.Contexts;
[JsonSerializable(typeof(UnifiApiResponse))]
[JsonSerializable(typeof(UnifiApiResponse>))]
[JsonSerializable(typeof(UnifiApiResponse>))]
-internal partial class CredentialJsonContext : JsonSerializerContext
+internal sealed partial class CredentialJsonContext : JsonSerializerContext
{
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Serialization/Contexts/DeviceJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/DeviceJsonContext.cs
index 1cf1140..31c7a72 100644
--- a/Unifi.NET.Access/Serialization/Contexts/DeviceJsonContext.cs
+++ b/Unifi.NET.Access/Serialization/Contexts/DeviceJsonContext.cs
@@ -18,6 +18,6 @@ namespace Unifi.NET.Access.Serialization.Contexts;
[JsonSerializable(typeof(List>))]
// API wrapper types
[JsonSerializable(typeof(UnifiApiResponse>>))]
-internal partial class DeviceJsonContext : JsonSerializerContext
+internal sealed partial class DeviceJsonContext : JsonSerializerContext
{
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Serialization/Contexts/DoorJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/DoorJsonContext.cs
index f913435..9c59ca7 100644
--- a/Unifi.NET.Access/Serialization/Contexts/DoorJsonContext.cs
+++ b/Unifi.NET.Access/Serialization/Contexts/DoorJsonContext.cs
@@ -24,6 +24,6 @@ namespace Unifi.NET.Access.Serialization.Contexts;
[JsonSerializable(typeof(UnifiApiResponse>))]
[JsonSerializable(typeof(UnifiApiResponse))]
[JsonSerializable(typeof(UnifiApiResponse))]
-internal partial class DoorJsonContext : JsonSerializerContext
+internal sealed partial class DoorJsonContext : JsonSerializerContext
{
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Serialization/Contexts/SystemLogJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/SystemLogJsonContext.cs
index ad866a3..5260f35 100644
--- a/Unifi.NET.Access/Serialization/Contexts/SystemLogJsonContext.cs
+++ b/Unifi.NET.Access/Serialization/Contexts/SystemLogJsonContext.cs
@@ -25,6 +25,6 @@ namespace Unifi.NET.Access.Serialization.Contexts;
[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(UnifiApiResponse))]
[JsonSerializable(typeof(UnifiApiResponse))]
-internal partial class SystemLogJsonContext : JsonSerializerContext
+internal sealed partial class SystemLogJsonContext : JsonSerializerContext
{
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Serialization/Contexts/UserGroupJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/UserGroupJsonContext.cs
index 99b9ff2..e6022f8 100644
--- a/Unifi.NET.Access/Serialization/Contexts/UserGroupJsonContext.cs
+++ b/Unifi.NET.Access/Serialization/Contexts/UserGroupJsonContext.cs
@@ -26,6 +26,6 @@ namespace Unifi.NET.Access.Serialization.Contexts;
[JsonSerializable(typeof(UnifiApiResponse))]
[JsonSerializable(typeof(UnifiApiResponse>))]
[JsonSerializable(typeof(UnifiApiResponse>))]
-internal partial class UserGroupJsonContext : JsonSerializerContext
+internal sealed partial class UserGroupJsonContext : JsonSerializerContext
{
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Serialization/Contexts/UserJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/UserJsonContext.cs
index 40b489a..cb3cbec 100644
--- a/Unifi.NET.Access/Serialization/Contexts/UserJsonContext.cs
+++ b/Unifi.NET.Access/Serialization/Contexts/UserJsonContext.cs
@@ -24,6 +24,6 @@ namespace Unifi.NET.Access.Serialization.Contexts;
// API wrapper types
[JsonSerializable(typeof(UnifiApiResponse))]
[JsonSerializable(typeof(UnifiApiResponse>))]
-internal partial class UserJsonContext : JsonSerializerContext
+internal sealed partial class UserJsonContext : JsonSerializerContext
{
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Serialization/UnifiAccessJsonContext.cs b/Unifi.NET.Access/Serialization/UnifiAccessJsonContext.cs
index 27c316b..3be1f7a 100644
--- a/Unifi.NET.Access/Serialization/UnifiAccessJsonContext.cs
+++ b/Unifi.NET.Access/Serialization/UnifiAccessJsonContext.cs
@@ -12,7 +12,7 @@ namespace Unifi.NET.Access.Serialization;
public static class UnifiAccessJsonContext
{
private static IJsonTypeInfoResolver? _combined;
- private static readonly object _lock = new();
+ private static readonly Lock _lock = new();
///
/// Gets the combined type info resolver for all UniFi Access types.
diff --git a/Unifi.NET.Access/Services/AccessPolicyService.cs b/Unifi.NET.Access/Services/AccessPolicyService.cs
index e724bf6..f96622f 100644
--- a/Unifi.NET.Access/Services/AccessPolicyService.cs
+++ b/Unifi.NET.Access/Services/AccessPolicyService.cs
@@ -51,6 +51,6 @@ public async Task GetAccessPolicyAsync(string policyId, Ca
public async Task> GetAccessPoliciesAsync(CancellationToken cancellationToken = default)
{
var policies = await GetAsync>("/api/v1/developer/access_policies", cancellationToken);
- return policies ?? new List();
+ return policies ?? [];
}
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Services/CredentialService.cs b/Unifi.NET.Access/Services/CredentialService.cs
index 4fd34e6..19b7608 100644
--- a/Unifi.NET.Access/Services/CredentialService.cs
+++ b/Unifi.NET.Access/Services/CredentialService.cs
@@ -84,7 +84,7 @@ public async Task>> GetNfcCardsAsync(int
{
return new PaginatedResponse>
{
- Items = new List(),
+ Items = [],
Page = pageNum ?? 1,
PageSize = pageSize ?? 25,
Total = 0
@@ -108,7 +108,7 @@ public async Task>> GetNfcCardsAsync(int
return new PaginatedResponse>
{
- Items = apiResponse.Data ?? new List(),
+ Items = apiResponse.Data ?? [],
Page = apiResponse.Pagination?.PageNum ?? pageNum ?? 1,
PageSize = apiResponse.Pagination?.PageSize ?? pageSize ?? 25,
Total = apiResponse.Pagination?.Total ?? 0
@@ -157,6 +157,6 @@ public async Task> ImportNfcCardsAsync(Impor
var jsonTypeInfo = (JsonTypeInfo>>)_jsonOptions.GetTypeInfo(typeof(UnifiApiResponse>));
var apiResponse = JsonSerializer.Deserialize(response.Content ?? "{}", jsonTypeInfo);
- return apiResponse?.Data ?? new List();
+ return apiResponse?.Data ?? [];
}
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Services/DeviceService.cs b/Unifi.NET.Access/Services/DeviceService.cs
index 16accfe..4aada1a 100644
--- a/Unifi.NET.Access/Services/DeviceService.cs
+++ b/Unifi.NET.Access/Services/DeviceService.cs
@@ -28,7 +28,7 @@ public async Task> GetDevicesAsync(CancellationToken
{
return devices.SelectMany(list => list ?? new List());
}
-
- return new List();
+
+ return [];
}
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Services/DoorService.cs b/Unifi.NET.Access/Services/DoorService.cs
index f8ea2ab..52a4dc0 100644
--- a/Unifi.NET.Access/Services/DoorService.cs
+++ b/Unifi.NET.Access/Services/DoorService.cs
@@ -28,7 +28,7 @@ public async Task GetDoorAsync(string doorId, CancellationToken ca
public async Task> GetDoorsAsync(CancellationToken cancellationToken = default)
{
var doors = await GetAsync>("/api/v1/developer/doors", cancellationToken);
- return doors ?? new List();
+ return doors ?? [];
}
///
diff --git a/Unifi.NET.Access/Services/SystemLogService.cs b/Unifi.NET.Access/Services/SystemLogService.cs
index 99a51c8..4f0ab7a 100644
--- a/Unifi.NET.Access/Services/SystemLogService.cs
+++ b/Unifi.NET.Access/Services/SystemLogService.cs
@@ -122,7 +122,7 @@ public async Task ExportSystemLogsAsync(
throw new UnifiAccessException($"Failed to export system logs: {response.StatusCode}", "CODE_SYSTEM_ERROR", (int?)response.StatusCode);
}
- return response.RawBytes ?? Array.Empty();
+ return response.RawBytes ?? [];
}
///
diff --git a/Unifi.NET.Access/Services/UserGroupService.cs b/Unifi.NET.Access/Services/UserGroupService.cs
index af634d6..35bf409 100644
--- a/Unifi.NET.Access/Services/UserGroupService.cs
+++ b/Unifi.NET.Access/Services/UserGroupService.cs
@@ -63,7 +63,7 @@ public async Task>> GetUserGroupsAsync
{
return new PaginatedResponse>
{
- Items = new List(),
+ Items = [],
Page = pageNum ?? 1,
PageSize = pageSize ?? 25,
Total = 0
@@ -87,7 +87,7 @@ public async Task>> GetUserGroupsAsync
return new PaginatedResponse>
{
- Items = apiResponse.Data ?? new List(),
+ Items = apiResponse.Data ?? [],
Page = apiResponse.Pagination?.PageNum ?? pageNum ?? 1,
PageSize = apiResponse.Pagination?.PageSize ?? pageSize ?? 25,
Total = apiResponse.Pagination?.Total ?? 0
@@ -176,7 +176,7 @@ public async Task>> GetUsersInGroupAsync(st
{
return new PaginatedResponse>
{
- Items = new List(),
+ Items = [],
Page = pageNum ?? 1,
PageSize = pageSize ?? 25,
Total = 0
@@ -200,7 +200,7 @@ public async Task>> GetUsersInGroupAsync(st
return new PaginatedResponse>
{
- Items = apiResponse.Data ?? new List(),
+ Items = apiResponse.Data ?? [],
Page = apiResponse.Pagination?.PageNum ?? pageNum ?? 1,
PageSize = apiResponse.Pagination?.PageSize ?? pageSize ?? 25,
Total = apiResponse.Pagination?.Total ?? 0
@@ -212,7 +212,7 @@ public async Task> SearchUserGroupsAsync(string k
{
ArgumentException.ThrowIfNullOrEmpty(keyword);
var groups = await GetAsync>($"/api/v1/developer/user_groups/search?keyword={Uri.EscapeDataString(keyword)}", cancellationToken);
- return groups ?? new List();
+ return groups ?? [];
}
///
@@ -235,6 +235,6 @@ public async Task> ImportUserGroupsAsync(I
var jsonTypeInfo = (JsonTypeInfo>>)_jsonOptions.GetTypeInfo(typeof(UnifiApiResponse>));
var apiResponse = JsonSerializer.Deserialize(response.Content ?? "{}", jsonTypeInfo);
- return apiResponse?.Data ?? new List();
+ return apiResponse?.Data ?? [];
}
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Services/UserService.cs b/Unifi.NET.Access/Services/UserService.cs
index b60ca13..d53918d 100644
--- a/Unifi.NET.Access/Services/UserService.cs
+++ b/Unifi.NET.Access/Services/UserService.cs
@@ -62,7 +62,7 @@ public async Task> GetUsersAsync(CancellationToken can
if (!countResponse.IsSuccessful || string.IsNullOrEmpty(countResponse.Content))
{
- return new List();
+ return [];
}
// Parse to get total count
@@ -73,7 +73,7 @@ public async Task> GetUsersAsync(CancellationToken can
{
// Fallback to old method if pagination is not available
var users = await GetAsync>("/api/v1/developer/users?expand[]=access_policy", cancellationToken);
- return users ?? new List();
+ return users ?? [];
}
var total = paginatedResponse.Pagination.Total;
@@ -132,7 +132,7 @@ public async Task>> GetUsersAsync(int pageN
{
return new PaginatedResponse>
{
- Items = new List(),
+ Items = [],
Page = pageNum,
PageSize = pageSize,
Total = 0
@@ -156,7 +156,7 @@ public async Task>> GetUsersAsync(int pageN
return new PaginatedResponse>
{
- Items = apiResponse.Data ?? new List(),
+ Items = apiResponse.Data ?? [],
Page = apiResponse.Pagination?.PageNum ?? pageNum,
PageSize = apiResponse.Pagination?.PageSize ?? pageSize,
Total = apiResponse.Pagination?.Total ?? 0
@@ -215,7 +215,7 @@ public async Task> GetUserAccessPoliciesAsync(
var user = await GetUserAsync(userId, cancellationToken);
if (user.AccessPolicies == null)
{
- return new List();
+ return [];
}
// Convert AccessPolicyInfo to AccessPolicyResponse
From d99cfc9917906caab899c36c8138432e5fa0c004 Mon Sep 17 00:00:00 2001
From: Adam Reed <16042069+iamadamreed@users.noreply.github.com>
Date: Mon, 20 Jul 2026 01:45:51 -0500
Subject: [PATCH 4/7] Modernize AccessViewer minimal APIs
- Group endpoints under /api route group
- TypedResults with explicit Results<...> signatures for accurate OpenAPI schemas
---
.../Unifi.NET.AccessViewer.Server/Program.cs | 25 +++++++++++--------
1 file changed, 14 insertions(+), 11 deletions(-)
diff --git a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Program.cs b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Program.cs
index fbaf526..d7e4a7e 100644
--- a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Program.cs
+++ b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Program.cs
@@ -1,3 +1,4 @@
+using Microsoft.AspNetCore.Http.HttpResults;
using Unifi.NET.Access;
using Unifi.NET.Access.Configuration;
using Unifi.NET.Access.Models.SystemLogs;
@@ -55,11 +56,13 @@
app.UseHttpsRedirection();
// Map API endpoints
-app.MapGet("/api/users/search", async (string? employeeNumber, IUnifiAccessClient client) =>
+var api = app.MapGroup("/api");
+
+api.MapGet("/users/search", async Task, BadRequest, NotFound, ProblemHttpResult>> (string? employeeNumber, IUnifiAccessClient client) =>
{
if (string.IsNullOrWhiteSpace(employeeNumber))
{
- return Results.BadRequest("Employee number is required");
+ return TypedResults.BadRequest("Employee number is required");
}
try
@@ -67,13 +70,13 @@
// Use search API for much faster response
var users = await client.Users.SearchUsersAsync(employeeNumber);
var user = users.FirstOrDefault(u => u.EmployeeNumber == employeeNumber);
-
+
if (user == null)
{
- return Results.NotFound($"No user found with employee number: {employeeNumber}");
+ return TypedResults.NotFound($"No user found with employee number: {employeeNumber}");
}
- return Results.Ok(new UserSearchResult
+ return TypedResults.Ok(new UserSearchResult
{
Id = user.Id,
FirstName = user.FirstName,
@@ -85,12 +88,12 @@
}
catch (Exception ex)
{
- return Results.Problem($"Error searching users: {ex.Message}");
+ return TypedResults.Problem($"Error searching users: {ex.Message}");
}
})
.WithName("SearchUser");
-app.MapPost("/api/access-logs", async (AccessLogRequest request, IUnifiAccessClient client) =>
+api.MapPost("/access-logs", async Task, ProblemHttpResult>> (AccessLogRequest request, IUnifiAccessClient client) =>
{
try
{
@@ -103,8 +106,8 @@
};
var response = await client.SystemLogs.GetSystemLogsAsync(
- logRequest,
- request.PageNum ?? 1,
+ logRequest,
+ request.PageNum ?? 1,
request.PageSize ?? 50);
var logs = response.Hits.Select(hit => new AccessLogEntry
@@ -121,7 +124,7 @@
BuildingName = hit.Source.Target?.FirstOrDefault(t => t.Type == "building")?.DisplayName
}).ToList();
- return Results.Ok(new AccessLogResponse
+ return TypedResults.Ok(new AccessLogResponse
{
Logs = logs,
TotalCount = response.Total,
@@ -130,7 +133,7 @@
}
catch (Exception ex)
{
- return Results.Problem($"Error fetching access logs: {ex.Message}");
+ return TypedResults.Problem($"Error fetching access logs: {ex.Message}");
}
})
.WithName("GetAccessLogs");
From 1a3ca3a2a1dc4cc87b198796e9c4ee4dd6bf3757 Mon Sep 17 00:00:00 2001
From: Adam Reed <16042069+iamadamreed@users.noreply.github.com>
Date: Mon, 20 Jul 2026 01:45:51 -0500
Subject: [PATCH 5/7] Update test tooling and add real test coverage
- Microsoft.NET.Test.Sdk 18.8.1, xunit 2.9.3, coverlet.collector 10.0.1
- Replace placeholder tests with 19 tests: exception hierarchy, UnifiApiResponse semantics, JSON source-gen context round-trips
---
Directory.Packages.props | 6 +-
.../UnifiAccessJsonContextTests.cs | 183 ++++++++++++++++++
Unifi.NET.Access.Tests/UnitTest1.cs | 10 -
.../Exceptions/UnifiExceptionTests.cs | 97 ++++++++++
.../Models/UnifiApiResponseTests.cs | 69 +++++++
Unifi.NET.Common.Tests/UnitTest1.cs | 10 -
6 files changed, 352 insertions(+), 23 deletions(-)
create mode 100644 Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs
delete mode 100644 Unifi.NET.Access.Tests/UnitTest1.cs
create mode 100644 Unifi.NET.Common.Tests/Exceptions/UnifiExceptionTests.cs
create mode 100644 Unifi.NET.Common.Tests/Models/UnifiApiResponseTests.cs
delete mode 100644 Unifi.NET.Common.Tests/UnitTest1.cs
diff --git a/Directory.Packages.props b/Directory.Packages.props
index b898974..661bd90 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -26,12 +26,12 @@
-
-
+
+
-
+
diff --git a/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs b/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs
new file mode 100644
index 0000000..a41a8c7
--- /dev/null
+++ b/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs
@@ -0,0 +1,183 @@
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+using FluentAssertions;
+using Unifi.NET.Access.Models;
+using Unifi.NET.Access.Models.Doors;
+using Unifi.NET.Access.Models.Users;
+using Unifi.NET.Access.Serialization;
+
+namespace Unifi.NET.Access.Tests.Serialization;
+
+///
+/// Verifies that representative models round-trip through the combined
+/// resolver, proving Native AOT
+/// source-generated serialization coverage across service-specific contexts.
+///
+public class UnifiAccessJsonContextTests
+{
+ private readonly JsonSerializerOptions _options = UnifiAccessJsonContext.CreateOptions();
+
+ [Fact]
+ public void CreateOptions_UsesCombinedResolverFromAllContexts()
+ {
+ // Act & Assert
+ _options.TypeInfoResolver.Should().BeSameAs(UnifiAccessJsonContext.Combined);
+ }
+
+ [Fact]
+ public void UserResponse_RoundTripsThroughCombinedResolver()
+ {
+ // Arrange - snake_case wire format as returned by the UniFi Access API,
+ // exercising UserJsonContext (registered in the combined resolver).
+ const string json = """
+ {
+ "id": "usr-123",
+ "first_name": "Ada",
+ "last_name": "Lovelace",
+ "full_name": "Ada Lovelace",
+ "user_email": "ada@example.com",
+ "status": "ACTIVE",
+ "access_policy_ids": ["policy-1", "policy-2"]
+ }
+ """;
+
+ var typeInfo = (JsonTypeInfo)_options.GetTypeInfo(typeof(UserResponse));
+
+ // Act
+ var user = JsonSerializer.Deserialize(json, typeInfo);
+ user.Should().NotBeNull();
+ var reserialized = JsonSerializer.Serialize(user!, typeInfo);
+ var roundTripped = JsonSerializer.Deserialize(reserialized, typeInfo);
+
+ // Assert
+ user!.Id.Should().Be("usr-123");
+ user.FirstName.Should().Be("Ada");
+ user.LastName.Should().Be("Lovelace");
+ user.UserEmail.Should().Be("ada@example.com");
+ user.Status.Should().Be("ACTIVE");
+ user.AccessPolicyIds.Should().Equal("policy-1", "policy-2");
+
+ roundTripped.Should().BeEquivalentTo(user);
+ }
+
+ [Fact]
+ public void UnifiApiResponse_OfUserResponse_RoundTripsThroughCombinedResolver()
+ {
+ // Arrange - full API envelope, exercising the UnifiApiResponse
+ // wrapper type registered by UserJsonContext.
+ const string json = """
+ {
+ "code": "SUCCESS",
+ "msg": "success",
+ "data": {
+ "id": "usr-456",
+ "first_name": "Grace",
+ "last_name": "Hopper"
+ }
+ }
+ """;
+
+ var typeInfo = (JsonTypeInfo>)
+ _options.GetTypeInfo(typeof(UnifiApiResponse));
+
+ // Act
+ var apiResponse = JsonSerializer.Deserialize(json, typeInfo);
+
+ // Assert
+ apiResponse.Should().NotBeNull();
+ apiResponse!.Code.Should().Be("SUCCESS");
+ apiResponse.Data.Should().NotBeNull();
+ apiResponse.Data!.FirstName.Should().Be("Grace");
+ apiResponse.Data.LastName.Should().Be("Hopper");
+ }
+
+ [Fact]
+ public void DoorResponse_RoundTripsThroughCombinedResolver()
+ {
+ // Arrange - exercises DoorJsonContext (a different service-specific
+ // context) via the same combined resolver used above for UserResponse.
+ const string json = """
+ {
+ "id": "door-789",
+ "name": "Front Door",
+ "full_name": "Main Building / Front Door",
+ "type": "door",
+ "is_bind_hub": true,
+ "door_lock_relay_status": "lock",
+ "door_position_status": "close"
+ }
+ """;
+
+ var typeInfo = (JsonTypeInfo)_options.GetTypeInfo(typeof(DoorResponse));
+
+ // Act
+ var door = JsonSerializer.Deserialize(json, typeInfo);
+ door.Should().NotBeNull();
+ var reserialized = JsonSerializer.Serialize(door!, typeInfo);
+ var roundTripped = JsonSerializer.Deserialize(reserialized, typeInfo);
+
+ // Assert
+ door!.Id.Should().Be("door-789");
+ door.Name.Should().Be("Front Door");
+ door.IsBindHub.Should().BeTrue();
+ door.DoorLockRelayStatus.Should().Be("lock");
+ door.DoorPositionStatus.Should().Be("close");
+
+ roundTripped.Should().BeEquivalentTo(door);
+ }
+
+ [Fact]
+ public void UnifiApiResponse_OfDoorResponseList_RoundTripsThroughCombinedResolver()
+ {
+ // Arrange - exercises the List-wrapped envelope type,
+ // as returned by the "list doors" endpoint.
+ const string json = """
+ {
+ "code": "SUCCESS",
+ "msg": "success",
+ "data": [
+ { "id": "door-1", "name": "Lobby", "full_name": "Lobby", "type": "door", "is_bind_hub": false },
+ { "id": "door-2", "name": "Garage", "full_name": "Garage", "type": "door", "is_bind_hub": true }
+ ]
+ }
+ """;
+
+ var typeInfo = (JsonTypeInfo>>)
+ _options.GetTypeInfo(typeof(UnifiApiResponse>));
+
+ // Act
+ var apiResponse = JsonSerializer.Deserialize(json, typeInfo);
+
+ // Assert
+ apiResponse.Should().NotBeNull();
+ apiResponse!.Data.Should().HaveCount(2);
+ apiResponse.Data![0].Id.Should().Be("door-1");
+ apiResponse.Data[1].IsBindHub.Should().BeTrue();
+ }
+
+ [Fact]
+ public void UserResponse_Serialize_OmitsNullOptionalPropertiesUsingCamelCaseNames()
+ {
+ // Arrange - the source-generated contexts configure camelCase naming
+ // and omit nulls on write; verify that policy holds through the
+ // combined resolver too.
+ var user = new UserResponse
+ {
+ Id = "usr-1",
+ FirstName = "Jane",
+ LastName = "Doe"
+ };
+
+ var typeInfo = (JsonTypeInfo)_options.GetTypeInfo(typeof(UserResponse));
+
+ // Act
+ var json = JsonSerializer.Serialize(user, typeInfo);
+
+ // Assert - property names use the explicit [JsonPropertyName] (snake_case)
+ // values, and unset nullable members are absent from the payload.
+ json.Should().Contain("\"id\":\"usr-1\"");
+ json.Should().Contain("\"first_name\":\"Jane\"");
+ json.Should().NotContain("full_name");
+ json.Should().NotContain("nfc_cards");
+ }
+}
diff --git a/Unifi.NET.Access.Tests/UnitTest1.cs b/Unifi.NET.Access.Tests/UnitTest1.cs
deleted file mode 100644
index ab505df..0000000
--- a/Unifi.NET.Access.Tests/UnitTest1.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace Unifi.NET.Access.Tests;
-
-public class UnitTest1
-{
- [Fact]
- public void Test1()
- {
-
- }
-}
diff --git a/Unifi.NET.Common.Tests/Exceptions/UnifiExceptionTests.cs b/Unifi.NET.Common.Tests/Exceptions/UnifiExceptionTests.cs
new file mode 100644
index 0000000..544ad5f
--- /dev/null
+++ b/Unifi.NET.Common.Tests/Exceptions/UnifiExceptionTests.cs
@@ -0,0 +1,97 @@
+using FluentAssertions;
+using Unifi.NET.Common.Exceptions;
+
+namespace Unifi.NET.Common.Tests.Exceptions;
+
+public class UnifiExceptionTests
+{
+ [Fact]
+ public void Constructor_WithMessageOnly_SetsMessageAndLeavesErrorInfoNull()
+ {
+ // Arrange & Act
+ var exception = new UnifiException("Something went wrong");
+
+ // Assert
+ exception.Message.Should().Be("Something went wrong");
+ exception.ErrorCode.Should().BeNull();
+ exception.StatusCode.Should().BeNull();
+ exception.InnerException.Should().BeNull();
+ }
+
+ [Fact]
+ public void Constructor_WithInnerException_PreservesInnerException()
+ {
+ // Arrange
+ var inner = new InvalidOperationException("root cause");
+
+ // Act
+ var exception = new UnifiException("wrapped failure", inner);
+
+ // Assert
+ exception.Message.Should().Be("wrapped failure");
+ exception.InnerException.Should().BeSameAs(inner);
+ exception.ErrorCode.Should().BeNull();
+ exception.StatusCode.Should().BeNull();
+ }
+
+ [Fact]
+ public void Constructor_WithErrorCodeAndStatusCode_PopulatesErrorInfo()
+ {
+ // Arrange & Act
+ var exception = new UnifiException("Invalid parameters", "CODE_PARAMS_INVALID", 400);
+
+ // Assert
+ exception.Message.Should().Be("Invalid parameters");
+ exception.ErrorCode.Should().Be("CODE_PARAMS_INVALID");
+ exception.StatusCode.Should().Be(400);
+ }
+
+ [Fact]
+ public void UnifiAuthenticationException_IsAUnifiException()
+ {
+ // Arrange & Act
+ var exception = new UnifiAuthenticationException("Auth failed", "CODE_AUTH_FAILED", 401);
+
+ // Assert
+ exception.Should().BeAssignableTo();
+ exception.ErrorCode.Should().Be("CODE_AUTH_FAILED");
+ exception.StatusCode.Should().Be(401);
+ exception.Message.Should().Be("Auth failed");
+ }
+
+ [Fact]
+ public void UnifiNotFoundException_IsAUnifiException()
+ {
+ // Arrange & Act
+ var exception = new UnifiNotFoundException("Resource not found", "CODE_RESOURCE_NOT_FOUND", 404);
+
+ // Assert
+ exception.Should().BeAssignableTo();
+ exception.ErrorCode.Should().Be("CODE_RESOURCE_NOT_FOUND");
+ exception.StatusCode.Should().Be(404);
+ }
+
+ [Fact]
+ public void UnifiAuthenticationException_MessageOnlyConstructor_LeavesErrorInfoNull()
+ {
+ // Arrange & Act
+ var exception = new UnifiAuthenticationException("Auth failed");
+
+ // Assert
+ exception.Message.Should().Be("Auth failed");
+ exception.ErrorCode.Should().BeNull();
+ exception.StatusCode.Should().BeNull();
+ }
+
+ [Fact]
+ public void UnifiNotFoundException_MessageOnlyConstructor_LeavesErrorInfoNull()
+ {
+ // Arrange & Act
+ var exception = new UnifiNotFoundException("Not found");
+
+ // Assert
+ exception.Message.Should().Be("Not found");
+ exception.ErrorCode.Should().BeNull();
+ exception.StatusCode.Should().BeNull();
+ }
+}
diff --git a/Unifi.NET.Common.Tests/Models/UnifiApiResponseTests.cs b/Unifi.NET.Common.Tests/Models/UnifiApiResponseTests.cs
new file mode 100644
index 0000000..72181db
--- /dev/null
+++ b/Unifi.NET.Common.Tests/Models/UnifiApiResponseTests.cs
@@ -0,0 +1,69 @@
+using FluentAssertions;
+using Unifi.NET.Common.Models;
+
+namespace Unifi.NET.Common.Tests.Models;
+
+public class UnifiApiResponseTests
+{
+ [Fact]
+ public void IsSuccess_WhenCodeIsSuccess_ReturnsTrue()
+ {
+ // Arrange
+ var response = new UnifiApiResponse
+ {
+ Code = "SUCCESS",
+ Message = "success",
+ Data = "payload"
+ };
+
+ // Act & Assert
+ response.IsSuccess.Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData("CODE_PARAMS_INVALID")]
+ [InlineData("CODE_AUTH_FAILED")]
+ [InlineData("")]
+ public void IsSuccess_WhenCodeIsNotSuccess_ReturnsFalse(string code)
+ {
+ // Arrange
+ var response = new UnifiApiResponse
+ {
+ Code = code,
+ Message = "failure"
+ };
+
+ // Act & Assert
+ response.IsSuccess.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Data_WhenNotSet_DefaultsToNullForReferenceType()
+ {
+ // Arrange
+ var response = new UnifiApiResponse
+ {
+ Code = "SUCCESS",
+ Message = "success"
+ };
+
+ // Act & Assert
+ response.Data.Should().BeNull();
+ }
+
+ [Fact]
+ public void Data_CarriesTypedPayload()
+ {
+ // Arrange
+ var response = new UnifiApiResponse>
+ {
+ Code = "SUCCESS",
+ Message = "success",
+ Data = [1, 2, 3]
+ };
+
+ // Act & Assert
+ response.Data.Should().BeEquivalentTo([1, 2, 3]);
+ response.IsSuccess.Should().BeTrue();
+ }
+}
diff --git a/Unifi.NET.Common.Tests/UnitTest1.cs b/Unifi.NET.Common.Tests/UnitTest1.cs
deleted file mode 100644
index 556ac74..0000000
--- a/Unifi.NET.Common.Tests/UnitTest1.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace Unifi.NET.Common.Tests;
-
-public class UnitTest1
-{
- [Fact]
- public void Test1()
- {
-
- }
-}
From e45fca65c36784794f10eb661d607709d1727576 Mon Sep 17 00:00:00 2001
From: Adam Reed <16042069+iamadamreed@users.noreply.github.com>
Date: Mon, 20 Jul 2026 01:45:51 -0500
Subject: [PATCH 6/7] Update docs to .NET 10
---
CLAUDE.md | 14 +++++++++-----
README.md | 10 +++++-----
Unifi.NET.Samples/README.md | 2 +-
3 files changed, 15 insertions(+), 11 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index eb380bf..5257e40 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,7 +12,7 @@ DEFAULT_BRANCH: "main"
## Project Overview
-Unifi.NET is a collection of .NET 9 SDKs for Ubiquiti UniFi APIs. The solution wraps UniFi service APIs (Access, Network, Protect, Site Manager) with strongly-typed, Native AOT-compatible client libraries.
+Unifi.NET is a collection of .NET 10 SDKs for Ubiquiti UniFi APIs. The solution wraps UniFi service APIs (Access, Network, Protect, Site Manager) with strongly-typed, Native AOT-compatible client libraries.
## Solution Structure
@@ -28,7 +28,7 @@ Unifi.NET.sln
├── Unifi.NET.Samples/ # Sample console application
├── Directory.Build.props # Shared MSBuild properties
├── Directory.Packages.props # Central package management
-├── global.json # .NET SDK version (9.0.100)
+├── global.json # .NET SDK version (10.0.100)
└── .editorconfig # Code style configuration
```
@@ -190,11 +190,15 @@ NuGet packages follow folder structure:
Central package versions (Directory.Packages.props):
- **RestSharp**: 112.1.0
-- **Microsoft.Extensions.Http.Resilience**: 9.0.0
-- **System.Text.Json**: 9.0.0
-- **xunit**: 2.9.2
+- **Microsoft.Extensions.Http.Resilience**: 10.0.0
+- **Microsoft.OpenApi**: 2.11.0 (pinned for GHSA-v5pm-xwqc-g5wc)
+- **Microsoft.NET.Test.Sdk**: 18.8.1
+- **xunit**: 2.9.3
- **FluentAssertions**: 6.12.2
- **Moq**: 4.20.72
+- **Microsoft.CodeAnalysis.NetAnalyzers**: 10.0.0
+
+Note: `System.Text.Json` is framework-provided in .NET 10, so it no longer has an explicit `PackageVersion` entry.
## API Implementation Workflow
diff --git a/README.md b/README.md
index e85e506..12b30ca 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ A comprehensive collection of .NET SDKs for interacting with Ubiquiti UniFi APIs
[](https://www.nuget.org/packages/Unifi.NET.Access/)
[](LICENSE)
-[](https://dotnet.microsoft.com/download)
+[](https://dotnet.microsoft.com/download)
## 📦 Available Packages
@@ -30,13 +30,13 @@ A comprehensive collection of .NET SDKs for interacting with Ubiquiti UniFi APIs
## 📋 Prerequisites
-- .NET 9.0 or later
+- .NET 10.0 or later
- UniFi Console with the respective service installed
- API Token from UniFi Portal (for authentication)
- Network access to your UniFi Console (default port: 12445 for Access)
### Native AOT Requirements
-- .NET 9 SDK for building
+- .NET 10 SDK for building
- Target platform runtime identifier (e.g., `linux-x64`, `win-x64`, `osx-arm64`)
- No additional runtime dependencies needed for deployment
@@ -129,7 +129,7 @@ var users = await accessClient.Users.GetAllAsync();
### Native AOT Deployment
-The SDKs are designed for full Native AOT compatibility in .NET 9:
+The SDKs are designed for full Native AOT compatibility in .NET 10:
```xml
@@ -209,7 +209,7 @@ Unifi.NET/
### Native AOT Design Principles
-All SDKs follow .NET 9 Native AOT best practices:
+All SDKs follow .NET 10 Native AOT best practices:
#### ✅ **Fully Compatible Components**
- **System.Text.Json** with source generators (no reflection)
diff --git a/Unifi.NET.Samples/README.md b/Unifi.NET.Samples/README.md
index aefd1a7..7202c9b 100644
--- a/Unifi.NET.Samples/README.md
+++ b/Unifi.NET.Samples/README.md
@@ -15,7 +15,7 @@ The UniFi Access sample is a console application that demonstrates key features
1. **UniFi Access Console**: You need access to a UniFi Access console (v1.9.1 or later)
2. **API Token**: Generate an API token from your UniFi Access console
-3. **.NET 9 SDK**: Install the .NET 9 SDK from [dotnet.microsoft.com](https://dotnet.microsoft.com)
+3. **.NET 10 SDK**: Install the .NET 10 SDK from [dotnet.microsoft.com](https://dotnet.microsoft.com)
### Getting Started
From 90c5285f78ec566f761b946a6b6001686b978986 Mon Sep 17 00:00:00 2001
From: Adam Reed <16042069+iamadamreed@users.noreply.github.com>
Date: Mon, 20 Jul 2026 09:14:14 -0500
Subject: [PATCH 7/7] Fix empty IEnumerable returns regressing to arrays
Target-typed [] on a Task> materializes as T[], not
List, so empty results silently became fixed-size and uncastable
to List. Restore new List() on those paths; [] stays where the
target is List or byte[].
Also renames a JSON test whose name said camelCase while asserting
snake_case [JsonPropertyName] values.
---
.../Serialization/UnifiAccessJsonContextTests.cs | 8 ++++----
Unifi.NET.Access/Services/AccessPolicyService.cs | 2 +-
Unifi.NET.Access/Services/CredentialService.cs | 2 +-
Unifi.NET.Access/Services/DeviceService.cs | 2 +-
Unifi.NET.Access/Services/DoorService.cs | 2 +-
Unifi.NET.Access/Services/UserGroupService.cs | 4 ++--
Unifi.NET.Access/Services/UserService.cs | 6 +++---
7 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs b/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs
index a41a8c7..cf60c2c 100644
--- a/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs
+++ b/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs
@@ -156,11 +156,11 @@ public void UnifiApiResponse_OfDoorResponseList_RoundTripsThroughCombinedResolve
}
[Fact]
- public void UserResponse_Serialize_OmitsNullOptionalPropertiesUsingCamelCaseNames()
+ public void UserResponse_Serialize_OmitsNullOptionalPropertiesUsingJsonPropertyNames()
{
- // Arrange - the source-generated contexts configure camelCase naming
- // and omit nulls on write; verify that policy holds through the
- // combined resolver too.
+ // Arrange - the wire format comes from the explicit [JsonPropertyName]
+ // attributes (snake_case), and nulls are omitted on write; verify that
+ // policy holds through the combined resolver too.
var user = new UserResponse
{
Id = "usr-1",
diff --git a/Unifi.NET.Access/Services/AccessPolicyService.cs b/Unifi.NET.Access/Services/AccessPolicyService.cs
index f96622f..e724bf6 100644
--- a/Unifi.NET.Access/Services/AccessPolicyService.cs
+++ b/Unifi.NET.Access/Services/AccessPolicyService.cs
@@ -51,6 +51,6 @@ public async Task GetAccessPolicyAsync(string policyId, Ca
public async Task> GetAccessPoliciesAsync(CancellationToken cancellationToken = default)
{
var policies = await GetAsync>("/api/v1/developer/access_policies", cancellationToken);
- return policies ?? [];
+ return policies ?? new List();
}
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Services/CredentialService.cs b/Unifi.NET.Access/Services/CredentialService.cs
index 19b7608..bffb4b9 100644
--- a/Unifi.NET.Access/Services/CredentialService.cs
+++ b/Unifi.NET.Access/Services/CredentialService.cs
@@ -157,6 +157,6 @@ public async Task> ImportNfcCardsAsync(Impor
var jsonTypeInfo = (JsonTypeInfo>>)_jsonOptions.GetTypeInfo(typeof(UnifiApiResponse>));
var apiResponse = JsonSerializer.Deserialize(response.Content ?? "{}", jsonTypeInfo);
- return apiResponse?.Data ?? [];
+ return apiResponse?.Data ?? new List();
}
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Services/DeviceService.cs b/Unifi.NET.Access/Services/DeviceService.cs
index 4aada1a..3eeaaa1 100644
--- a/Unifi.NET.Access/Services/DeviceService.cs
+++ b/Unifi.NET.Access/Services/DeviceService.cs
@@ -29,6 +29,6 @@ public async Task> GetDevicesAsync(CancellationToken
return devices.SelectMany(list => list ?? new List());
}
- return [];
+ return new List();
}
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Services/DoorService.cs b/Unifi.NET.Access/Services/DoorService.cs
index 52a4dc0..f8ea2ab 100644
--- a/Unifi.NET.Access/Services/DoorService.cs
+++ b/Unifi.NET.Access/Services/DoorService.cs
@@ -28,7 +28,7 @@ public async Task GetDoorAsync(string doorId, CancellationToken ca
public async Task> GetDoorsAsync(CancellationToken cancellationToken = default)
{
var doors = await GetAsync>("/api/v1/developer/doors", cancellationToken);
- return doors ?? [];
+ return doors ?? new List();
}
///
diff --git a/Unifi.NET.Access/Services/UserGroupService.cs b/Unifi.NET.Access/Services/UserGroupService.cs
index 35bf409..e0e6337 100644
--- a/Unifi.NET.Access/Services/UserGroupService.cs
+++ b/Unifi.NET.Access/Services/UserGroupService.cs
@@ -212,7 +212,7 @@ public async Task> SearchUserGroupsAsync(string k
{
ArgumentException.ThrowIfNullOrEmpty(keyword);
var groups = await GetAsync>($"/api/v1/developer/user_groups/search?keyword={Uri.EscapeDataString(keyword)}", cancellationToken);
- return groups ?? [];
+ return groups ?? new List();
}
///
@@ -235,6 +235,6 @@ public async Task> ImportUserGroupsAsync(I
var jsonTypeInfo = (JsonTypeInfo>>)_jsonOptions.GetTypeInfo(typeof(UnifiApiResponse>));
var apiResponse = JsonSerializer.Deserialize(response.Content ?? "{}", jsonTypeInfo);
- return apiResponse?.Data ?? [];
+ return apiResponse?.Data ?? new List();
}
}
\ No newline at end of file
diff --git a/Unifi.NET.Access/Services/UserService.cs b/Unifi.NET.Access/Services/UserService.cs
index d53918d..a96d6b0 100644
--- a/Unifi.NET.Access/Services/UserService.cs
+++ b/Unifi.NET.Access/Services/UserService.cs
@@ -62,7 +62,7 @@ public async Task> GetUsersAsync(CancellationToken can
if (!countResponse.IsSuccessful || string.IsNullOrEmpty(countResponse.Content))
{
- return [];
+ return new List();
}
// Parse to get total count
@@ -73,7 +73,7 @@ public async Task> GetUsersAsync(CancellationToken can
{
// Fallback to old method if pagination is not available
var users = await GetAsync>("/api/v1/developer/users?expand[]=access_policy", cancellationToken);
- return users ?? [];
+ return users ?? new List();
}
var total = paginatedResponse.Pagination.Total;
@@ -215,7 +215,7 @@ public async Task> GetUserAccessPoliciesAsync(
var user = await GetUserAsync(userId, cancellationToken);
if (user.AccessPolicies == null)
{
- return [];
+ return new List();
}
// Convert AccessPolicyInfo to AccessPolicyResponse