feat(integrations): add UniFi OS schemas and pure mappers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zacharyandClaude Opus 5 committed 2026-09-13 22:21:43 +02:00
1 parent a6e4df0460
commit 84498d4e7b
2 files changed
+372

No files matched your search

@@ -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> = {}): 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" });
});
});
@@ -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 = <TItem extends z.ZodType>(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<typeof unifiOsClientSchema>;
export type UnifiOsDevice = z.infer<typeof unifiOsDeviceSchema>;
export type UnifiOsSite = z.infer<typeof unifiOsSiteSchema>;
export type UnifiOsDeviceStatistics = z.infer<typeof unifiOsDeviceStatisticsSchema>;
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 <TOutput>(
value: unknown,
schema: z.ZodType<TOutput>,
message: string,
): Promise<TOutput> => {
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 = <TSite extends { meta: { gatewayMac?: string } }>(
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
);
};