From 84498d4e7b92eecc7b6e47af1e16fc4d72db817b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zachary=20Gu=C3=A9not?= Date: Tue, 8 Sep 2026 14:41:51 +0200 Subject: [PATCH] feat(integrations): add UniFi OS schemas and pure mappers Co-Authored-By: Claude Opus 5 --- .../src/unifi-os/test/unifi-os-types.spec.ts | 142 +++++++++++ .../src/unifi-os/unifi-os-types.ts | 230 ++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 packages/integrations/src/unifi-os/test/unifi-os-types.spec.ts create mode 100644 packages/integrations/src/unifi-os/unifi-os-types.ts diff --git a/packages/integrations/src/unifi-os/test/unifi-os-types.spec.ts b/packages/integrations/src/unifi-os/test/unifi-os-types.spec.ts new file mode 100644 index 000000000..1f23f30f3 --- /dev/null +++ b/packages/integrations/src/unifi-os/test/unifi-os-types.spec.ts @@ -0,0 +1,142 @@ +/** + * @vitest-environment node + */ +import { describe, expect, test } from "vitest"; + +import type { UnifiOsClient, UnifiOsDevice } from "../unifi-os-types"; +import { + countClients, + findGateway, + matchCloudSite, + normalizeMacAddress, + summarizeDevices, + unifiOsClientSchema, +} from "../unifi-os-types"; + +const client = (type: string, accessType = "DEFAULT"): UnifiOsClient => ({ + id: `client-${type}-${accessType}-${Math.random()}`, + name: "client", + type, + access: { type: accessType }, +}); + +const device = (overrides: Partial = {}): UnifiOsDevice => ({ + id: "device-1", + name: "device", + model: "USW Lite 8", + macAddress: "aa:bb:cc:11:22:33", + state: "ONLINE", + firmwareUpdatable: false, + features: ["switching"], + ...overrides, +}); + +describe("normalizeMacAddress", () => { + test("lowercases and strips separators so both APIs compare equal", () => { + expect(normalizeMacAddress("AA:BB:CC:11:22:33")).toBe("aabbcc112233"); + expect(normalizeMacAddress("aa-bb-cc-11-22-33")).toBe("aabbcc112233"); + expect(normalizeMacAddress("AABBCC112233")).toBe("aabbcc112233"); + }); +}); + +describe("countClients", () => { + test("counts each connection type separately", () => { + const counts = countClients([ + client("WIRED"), + client("WIRED"), + client("WIRELESS"), + client("VPN"), + client("TELEPORT"), + ]); + + expect(counts).toEqual({ total: 5, wired: 2, wireless: 1, vpn: 2, guests: 0 }); + }); + + test("folds TELEPORT into vpn because both are remote access", () => { + expect(countClients([client("TELEPORT")]).vpn).toBe(1); + }); + + test("counts guests across every connection type", () => { + const counts = countClients([client("WIRELESS", "GUEST"), client("WIRED", "GUEST"), client("WIRED")]); + + expect(counts.guests).toBe(2); + expect(counts.wired).toBe(2); + }); + + test("treats an unknown connection type as counted but unclassified", () => { + const counts = countClients([client("SOMETHING_NEW")]); + + expect(counts.total).toBe(1); + expect(counts.wired + counts.wireless + counts.vpn).toBe(0); + }); + + test("tolerates a client without an access block", () => { + const counts = countClients([{ id: "x", name: "x", type: "WIRED" }]); + + expect(counts).toEqual({ total: 1, wired: 1, wireless: 0, vpn: 0, guests: 0 }); + }); +}); + +describe("summarizeDevices", () => { + test("splits online from everything else and counts pending firmware", () => { + const counts = summarizeDevices([ + device({ id: "a", state: "ONLINE", firmwareUpdatable: true }), + device({ id: "b", state: "OFFLINE" }), + device({ id: "c", state: "UPDATING", firmwareUpdatable: true }), + ]); + + expect(counts).toEqual({ total: 3, online: 1, offline: 2, firmwareUpdatable: 2 }); + }); + + test("returns zeroes for an empty site rather than throwing", () => { + expect(summarizeDevices([])).toEqual({ total: 0, online: 0, offline: 0, firmwareUpdatable: 0 }); + }); +}); + +describe("findGateway", () => { + test("prefers the device advertising the gateway feature", () => { + const gateway = device({ id: "gw", features: ["gateway"], model: "UXG Pro" }); + expect(findGateway([device(), gateway])?.id).toBe("gw"); + }); + + test("falls back to a gateway-looking model when features are empty", () => { + const gateway = device({ id: "gw", features: [], model: "Gateway Pro" }); + expect(findGateway([device(), gateway])?.id).toBe("gw"); + }); + + test("returns null when no gateway is present instead of picking any device", () => { + expect(findGateway([device()])).toBeNull(); + }); +}); + +describe("matchCloudSite", () => { + const siteA = { siteId: "a", meta: { gatewayMac: "AA:BB:CC:11:22:33" } }; + const siteB = { siteId: "b", meta: { gatewayMac: "dd:ee:ff:44:55:66" } }; + + test("matches on the gateway MAC regardless of formatting", () => { + expect(matchCloudSite("aa-bb-cc-11-22-33", [siteB, siteA])?.siteId).toBe("a"); + }); + + test("returns null rather than another account site when nothing matches", () => { + expect(matchCloudSite("11:22:33:44:55:66", [siteA, siteB])).toBeNull(); + }); + + test("returns null when the local gateway is unknown", () => { + expect(matchCloudSite(null, [siteA, siteB])).toBeNull(); + }); +}); + +describe("unifiOsClientSchema", () => { + test("keeps fields the published OpenAPI does not document", () => { + const parsed = unifiOsClientSchema.parse({ + id: "1", + name: "n", + type: "WIRED", + access: { type: "DEFAULT" }, + macAddress: "aa:bb:cc:11:22:33", + uplinkDeviceId: "u1", + }); + + expect(parsed).toMatchObject({ macAddress: "aa:bb:cc:11:22:33", uplinkDeviceId: "u1" }); + }); +}); diff --git a/packages/integrations/src/unifi-os/unifi-os-types.ts b/packages/integrations/src/unifi-os/unifi-os-types.ts new file mode 100644 index 000000000..ba179b1ab --- /dev/null +++ b/packages/integrations/src/unifi-os/unifi-os-types.ts @@ -0,0 +1,230 @@ +import { ParseError } from "@homarr/common/server"; +import { z } from "zod/v4"; + +/** + * Ubiquiti ne publie l'OpenAPI que pour la 10.4.57 alors que les consoles sont + * en 10.6.x et renvoient déjà des champs absents du spec. Tous les objets sont + * donc « loose » : un champ inattendu ne doit jamais faire échouer un widget. + */ + +export const unifiOsInfoSchema = z.looseObject({ + applicationVersion: z.string(), +}); + +export const unifiOsSiteSchema = z.looseObject({ + id: z.string(), + name: z.string(), + internalReference: z.string().optional(), +}); + +export const unifiOsClientSchema = z.looseObject({ + id: z.string(), + name: z.string(), + type: z.string(), + access: z.looseObject({ type: z.string() }).optional(), +}); + +export const unifiOsDeviceSchema = z.looseObject({ + id: z.string(), + name: z.string(), + model: z.string(), + macAddress: z.string(), + state: z.string(), + firmwareUpdatable: z.boolean().optional(), + features: z.array(z.string()).default([]), +}); + +export const unifiOsDeviceStatisticsSchema = z.looseObject({ + uptimeSec: z.number().optional(), + cpuUtilizationPct: z.number().optional(), + memoryUtilizationPct: z.number().optional(), + uplink: z.looseObject({ txRateBps: z.number(), rxRateBps: z.number() }).optional(), +}); + +export const unifiOsVpnServerSchema = z.looseObject({ + id: z.string(), + name: z.string().optional(), + enabled: z.boolean(), +}); + +/** Enveloppe de pagination commune à tous les endpoints de liste. */ +export const createUnifiOsPageSchema = (item: TItem) => + z.looseObject({ + offset: z.number(), + limit: z.number(), + count: z.number(), + totalCount: z.number(), + data: z.array(item), + }); + +export const unifiSmSiteSchema = z.looseObject({ + siteId: z.string(), + hostId: z.string().optional(), + meta: z.looseObject({ + desc: z.string().optional(), + name: z.string().optional(), + gatewayMac: z.string().optional(), + }), + statistics: z + .looseObject({ + ispInfo: z.looseObject({ name: z.string().optional() }).optional(), + percentages: z.looseObject({ wanUptime: z.number().optional() }).optional(), + wans: z.record(z.string(), z.looseObject({ externalIp: z.string().optional() })).optional(), + }) + .optional(), +}); + +const unifiSmWanMetricsSchema = z.looseObject({ + avgLatency: z.number().optional(), + maxLatency: z.number().optional(), + packetLoss: z.number().optional(), + uptime: z.number().optional(), + ispName: z.string().optional(), +}); + +export const unifiSmIspMetricsResponseSchema = z.looseObject({ + data: z.looseObject({ + metrics: z.array( + z.looseObject({ + periods: z.array( + z.looseObject({ + metricTime: z.string(), + data: z.looseObject({ wan: unifiSmWanMetricsSchema.optional() }), + }), + ), + }), + ), + }), +}); + +export type UnifiOsClient = z.infer; +export type UnifiOsDevice = z.infer; +export type UnifiOsSite = z.infer; +export type UnifiOsDeviceStatistics = z.infer; + +export interface UnifiOsClientCounts { + total: number; + wired: number; + wireless: number; + vpn: number; + guests: number; +} + +export interface UnifiOsDeviceCounts { + total: number; + online: number; + offline: number; + firmwareUpdatable: number; +} + +export interface UnifiOsGateway { + name: string; + model: string; + macAddress: string; + uptimeSec: number | null; + cpuPct: number | null; + memoryPct: number | null; +} + +export interface UnifiOsIspMetrics { + name: string; + externalIp: string | null; + latencyMs: number; + maxLatencyMs: number; + packetLossPct: number; + uptimePct: number; + /** Horodatage de la mesure : la donnée cloud accuse ~27 min de retard. */ + measuredAt: Date; +} + +export interface UnifiOsSummary { + site: { + id: string; + name: string; + applicationVersion: string; + }; + wan: { + state: "online" | "offline" | "unknown"; + throughput: { txBps: number; rxBps: number } | null; + isp: UnifiOsIspMetrics | null; + }; + clients: UnifiOsClientCounts; + devices: UnifiOsDeviceCounts; + gateway: UnifiOsGateway | null; +} + +export const parseUnifiOsAsync = async ( + value: unknown, + schema: z.ZodType, + message: string, +): Promise => { + const result = await schema.safeParseAsync(value); + if (!result.success) { + throw new ParseError(message, { cause: result.error }); + } + return result.data; +}; + +/** + * L'API locale renvoie `aa:bb:cc:11:22:33`, le cloud peut renvoyer une autre + * casse ou d'autres séparateurs. La comparaison se fait sur une forme canonique. + */ +export const normalizeMacAddress = (mac: string): string => mac.toLowerCase().replace(/[^0-9a-f]/g, ""); + +const VPN_CLIENT_TYPES = new Set(["VPN", "TELEPORT"]); + +export const countClients = (clients: UnifiOsClient[]): UnifiOsClientCounts => { + const counts: UnifiOsClientCounts = { total: 0, wired: 0, wireless: 0, vpn: 0, guests: 0 }; + + for (const client of clients) { + counts.total += 1; + + if (client.type === "WIRED") counts.wired += 1; + else if (client.type === "WIRELESS") counts.wireless += 1; + else if (VPN_CLIENT_TYPES.has(client.type)) counts.vpn += 1; + + if (client.access?.type === "GUEST") counts.guests += 1; + } + + return counts; +}; + +export const summarizeDevices = (devices: UnifiOsDevice[]): UnifiOsDeviceCounts => { + const online = devices.filter((device) => device.state === "ONLINE").length; + + return { + total: devices.length, + online, + // Tout ce qui n'est pas ONLINE est indisponible du point de vue du dashboard, + // qu'il s'agisse d'OFFLINE, d'UPDATING ou d'un état futur inconnu. + offline: devices.length - online, + firmwareUpdatable: devices.filter((device) => device.firmwareUpdatable === true).length, + }; +}; + +const GATEWAY_MODEL_HINT = /gateway|udm|uxg|ucg|usg/i; + +export const findGateway = (devices: UnifiOsDevice[]): UnifiOsDevice | null => + devices.find((device) => device.features.includes("gateway")) ?? + devices.find((device) => GATEWAY_MODEL_HINT.test(device.model)) ?? + null; + +/** + * Une clé Site Manager voit tous les sites du compte, y compris ceux d'autres + * consoles. Sans correspondance stricte sur le MAC du gateway, on afficherait + * la latence d'un autre réseau. Renvoyer null est le seul comportement sûr. + */ +export const matchCloudSite = ( + gatewayMac: string | null, + sites: TSite[], +): TSite | null => { + if (gatewayMac === null) return null; + + const normalized = normalizeMacAddress(gatewayMac); + + return ( + sites.find( + (site) => site.meta.gatewayMac !== undefined && normalizeMacAddress(site.meta.gatewayMac) === normalized, + ) ?? null + ); +};