From 19f0d97e238902fd9e362df77d5be5ee869bcc6c Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 18:13:43 +0300 Subject: [PATCH] feat(device-manager): add full device profile workspace --- .../server/device-core-client.mjs | 186 ++++++- .../server/device-core-client.test.mjs | 18 + .../server/device-manager-server.mjs | 4 +- .../src/DeviceInventoryView.tsx | 329 +++++++++++++ apps/device-manager/src/DeviceManagerApp.tsx | 26 +- .../src/deviceProfileCatalog.ts | 465 ++++++++++++++++++ apps/device-manager/src/styles.css | 393 +++++++++++++++ apps/device-manager/src/types.ts | 10 + 8 files changed, 1416 insertions(+), 15 deletions(-) create mode 100644 apps/device-manager/src/DeviceInventoryView.tsx create mode 100644 apps/device-manager/src/deviceProfileCatalog.ts diff --git a/apps/device-manager/server/device-core-client.mjs b/apps/device-manager/server/device-core-client.mjs index d9186ba..2216297 100644 --- a/apps/device-manager/server/device-core-client.mjs +++ b/apps/device-manager/server/device-core-client.mjs @@ -80,7 +80,7 @@ export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = { }; } -export function createLocalPreviewDeviceCore() { +export function createLocalPreviewDeviceCore({ fixture = null } = {}) { const ownerScopes = new Map(); const projects = new Map(); const collections = new Map(); @@ -90,6 +90,8 @@ export function createLocalPreviewDeviceCore() { const edges = new Map(); const routes = new Map(); const enrollments = new Map(); + const devices = new Map(); + const sessions = new Map(); const bindings = new Map(); const grants = new Map(); const configurationRevisions = new Map(); @@ -121,7 +123,11 @@ export function createLocalPreviewDeviceCore() { .filter((collection) => collection.projectRef === project.projectRef); return { ...project, - counts: { devices: 0, collections: projectCollections.length, discoveries: 0 }, + counts: { + devices: projectValues(devices, project.projectRef).length, + collections: projectCollections.length, + discoveries: 0, + }, }; } @@ -130,7 +136,8 @@ export function createLocalPreviewDeviceCore() { if (!project) throw serviceError("device_project_not_found", 404); return { project: projectSummary(project), - devices: [], + devices: projectValues(devices, projectRef) + .map(({ projectRef: _projectRef, ...device }) => device), discoveries: [], enrollments: projectValues(enrollments, projectRef), collections: projectValues(collections, projectRef) @@ -140,7 +147,8 @@ export function createLocalPreviewDeviceCore() { modelProfiles: [...modelProfiles.values()], edges: [...edges.values()], routes: projectValues(routes, projectRef), - sessions: [], + sessions: projectValues(sessions, projectRef) + .map(({ projectRef: _projectRef, ...session }) => session), bindings: projectValues(bindings, projectRef), configurationRevisions: projectValues(configurationRevisions, projectRef), configurationStates: projectValues(configurationStates, projectRef), @@ -156,6 +164,20 @@ export function createLocalPreviewDeviceCore() { }; } + if (fixture === "arusnavi-b2") seedArusnaviB2Preview({ + ownerScopes, + projects, + devices, + modelProfiles, + edges, + routes, + sessions, + configurationStates, + }); + else if (fixture != null && fixture !== "") { + throw serviceError("device_manager_preview_fixture_invalid", 400); + } + return { configured: true, async listProjects() { @@ -552,6 +574,8 @@ export function createLocalPreviewDeviceCore() { edges, routes, enrollments, + devices, + sessions, bindings, grants, configurationRevisions, @@ -572,6 +596,160 @@ function requirePlatformOwner(actor) { } } +function seedArusnaviB2Preview({ + ownerScopes, + projects, + devices, + modelProfiles, + edges, + routes, + sessions, + configurationStates, +}) { + const timestamp = new Date().toISOString(); + const ownerScopeRef = "owner-scope:78da71d5-f48f-4de0-8e47-729f6d644151"; + const projectRef = "project:ad7b357c-c7ac-4bf8-a638-c7f956e9aa71"; + const deviceRef = "device:b6a55921-7888-44b5-a93e-241aa2fdd3d7"; + const edgeRef = "edge:73da0c42-a641-4559-b8f7-23509b60bfe9"; + const routeRef = "route:fef9b7a0-a462-4d68-9991-af026203368b"; + const sessionRef = "session:57ead610-47de-45f7-a42d-fbe4fa0aba38"; + const scope = { + ownerScopeRef, + scopeKind: "personal", + ownerRef: "user:local-device-admin", + displayName: "Local Device Admin", + lifecycleState: "active", + }; + ownerScopes.set("personal:user:local-device-admin", scope); + projects.set(projectRef, { + projectRef, + projectKey: "arusnavi-b2-preview", + name: "ARUSNAVI B2 preview", + description: "Локальная визуальная фикстура без реальных идентификаторов и команд", + lifecycleState: "active", + ownerScope: scope, + access: { projectRole: "owner", capabilities: ownerCapabilities }, + counts: { devices: 1, collections: 0, discoveries: 0 }, + createdAt: timestamp, + updatedAt: timestamp, + }); + modelProfiles.set("arusnavi.b2.internal.v1", { + modelProfileRef: "arusnavi.b2.internal.v1", + adapterVersionRef: null, + schemaVersion: "1.0.0", + vendor: "ARUSNAVI", + model: "B2", + deviceType: "tracker", + protocol: "INTERNAL", + schemaArtifactRef: "schema:arusnavi.b2.internal.v1", + profileDigest: null, + capabilities: ["telemetry", "configuration", "commands"], + lifecycleState: "active", + createdAt: timestamp, + updatedAt: timestamp, + }); + edges.set(edgeRef, { + edgeRef, + edgeKey: "preview-edge", + displayName: "Preview VPS edge", + deploymentRef: "deployment:preview", + lifecycleState: "active", + createdAt: timestamp, + updatedAt: timestamp, + }); + routes.set(routeRef, { + routeRef, + projectRef, + routeKey: "preview-b2-route", + displayName: "B2 direct preview", + edgeRef, + edgeName: "Preview VPS edge", + modelProfileRef: "arusnavi.b2.internal.v1", + profileName: "ARUSNAVI B2", + listenerRef: "listener:preview", + protocol: "INTERNAL", + direction: "bidirectional", + lifecycleState: "active", + sessionCount: 1, + activeSessionCount: 1, + createdAt: timestamp, + updatedAt: timestamp, + }); + devices.set(deviceRef, { + projectRef, + deviceRef, + deviceKey: "pilot-b2-preview", + displayName: "Пилотный B2", + modelProfileRef: "arusnavi.b2.internal.v1", + lifecycleState: "active", + identifier: { kind: "imei", masked: "***********0001" }, + session: { state: "online", lastSeenAt: timestamp }, + reported: { + observedAt: timestamp, + identity: { iccid1: "****************1111", iccid2: "****************2222" }, + firmware: { currentVersion: "0.02", appliedAt: timestamp, availableVersion: "0.05" }, + configuration: { + monitoring: { + servers: [ + { host: "legacy.example.invalid", port: 20623, protocol: "INTERNAL", identity: "0" }, + { host: "direct.example.invalid", port: 9921, protocol: "INTERNAL", identity: "0" }, + ], + }, + transmission: { navigation: { position: true, motion: true, hdop: false } }, + trajectory: { + normal: { courseDeltaDegrees: 15, speedDeltaKph: 10, distanceMeters: 15, parkingIntervalSeconds: 15 }, + roaming: { courseDeltaDegrees: 20, speedDeltaKph: 50, distanceMeters: 1000, parkingIntervalSeconds: 300 }, + }, + navigation: { + sources: { satellite: true, wifi: false, lbs: false, tag: false }, + constellations: { gps: true, glonass: true, galileo: false, beidou: false }, + filter: { minimumSatellites: 4, maximumHdopTimesTen: 30 }, + }, + }, + telemetry: { + navigation: { + latitude: "55.7500", + longitude: "37.6200", + speedKph: 18, + altitudeMeters: 156, + satellites: 12, + courseDegrees: 84, + hdop: 1.2, + }, + gsm: { signal: 79, operator: "preview", lac: "masked", cid: "masked" }, + system: { externalVoltageMv: 13240, internalVoltageMv: 4120, status: "Норма" }, + }, + }, + createdAt: timestamp, + updatedAt: timestamp, + }); + sessions.set(sessionRef, { + sessionRef, + projectRef, + routeRef, + routeName: "B2 direct preview", + deviceRef, + deviceName: "Пилотный B2", + protocol: "INTERNAL", + lifecycleState: "online", + connectedAt: timestamp, + lastSeenAt: timestamp, + disconnectedAt: null, + closeReasonCode: null, + frameCount: 1842, + byteCount: 734208, + }); + configurationStates.set(deviceRef, { + projectRef, + deviceRef, + deviceName: "Пилотный B2", + desiredConfigurationRevisionRef: null, + appliedConfigurationRevisionRef: null, + appliedAt: null, + updatedAt: timestamp, + }); +} + const previewTransitions = Object.freeze({ adapterPackage: Object.freeze({ active: Object.freeze(["active", "retired"]), diff --git a/apps/device-manager/server/device-core-client.test.mjs b/apps/device-manager/server/device-core-client.test.mjs index 0c1a2e6..cd59f22 100644 --- a/apps/device-manager/server/device-core-client.test.mjs +++ b/apps/device-manager/server/device-core-client.test.mjs @@ -220,6 +220,24 @@ test("local preview is empty and creates resources only through canonical comman assert.deepEqual(workspace.devices, []); }); +test("explicit B2 preview fixture is isolated from the empty canonical preview", async () => { + const client = createLocalPreviewDeviceCore({ fixture: "arusnavi-b2" }); + const projects = await client.listProjects(actor); + assert.equal(projects.length, 1); + assert.equal(projects[0].counts.devices, 1); + const workspace = await client.getWorkspace(actor, projects[0].projectRef); + assert.equal(workspace.devices[0].modelProfileRef, "arusnavi.b2.internal.v1"); + assert.equal(workspace.devices[0].identifier.masked, "***********0001"); + assert.equal(workspace.sessions[0].lifecycleState, "online"); + assert.equal(workspace.policies.commandTransport, "disabled"); + assert.equal(JSON.stringify(workspace).includes("123456789012345"), false); + + assert.throws( + () => createLocalPreviewDeviceCore({ fixture: "unknown" }), + /device_manager_preview_fixture_invalid/, + ); +}); + function jsonResponse(status, body) { return new Response(JSON.stringify(body), { status, diff --git a/apps/device-manager/server/device-manager-server.mjs b/apps/device-manager/server/device-manager-server.mjs index 1fe6364..556fd0a 100644 --- a/apps/device-manager/server/device-manager-server.mjs +++ b/apps/device-manager/server/device-manager-server.mjs @@ -140,7 +140,9 @@ export async function createConfiguredDeviceManagerServer({ env = process.env } if (String(env.NODE_ENV || "").toLowerCase() === "production") { throw new Error("device_manager_local_preview_forbidden"); } - coreClient = createLocalPreviewDeviceCore(); + coreClient = createLocalPreviewDeviceCore({ + fixture: String(env.NODEDC_DEVICE_MANAGER_PREVIEW_FIXTURE || "").trim() || null, + }); } else { const tokenFile = String(env.NODEDC_DEVICE_CORE_TOKEN_FILE || "").trim(); if (!tokenFile) throw new Error("device_core_token_file_required"); diff --git a/apps/device-manager/src/DeviceInventoryView.tsx b/apps/device-manager/src/DeviceInventoryView.tsx new file mode 100644 index 0000000..1e92ae7 --- /dev/null +++ b/apps/device-manager/src/DeviceInventoryView.tsx @@ -0,0 +1,329 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + GlassSurface, + Icon, + IconButton, + StatusBadge, +} from "@nodedc/ui-react"; +import { + accessLabel, + getDeviceProfileCatalog, + type DeviceFieldAccess, + type DeviceProfileField, +} from "./deviceProfileCatalog"; +import type { + DeviceView, + ProjectWorkspace, + SessionView, +} from "./types"; + +export function DeviceInventoryView({ + workspace, + canEnroll, + onCreateEnrollment, + onPoll, + onError, +}: { + workspace: ProjectWorkspace; + canEnroll: boolean; + onCreateEnrollment: () => void; + onPoll: () => Promise; + onError: (reason: unknown) => void; +}) { + const [selectedDeviceRef, setSelectedDeviceRef] = useState(null); + const selectedDevice = workspace.devices.find( + (device) => device.deviceRef === selectedDeviceRef, + ) ?? null; + const activeRouteAvailable = workspace.routes.some( + (route) => route.lifecycleState === "active", + ); + + useEffect(() => { + setSelectedDeviceRef(null); + }, [workspace.project.projectRef]); + + useEffect(() => { + if (selectedDeviceRef && !selectedDevice) setSelectedDeviceRef(null); + }, [selectedDevice, selectedDeviceRef]); + + useEffect(() => { + if (!selectedDeviceRef) return undefined; + const poll = () => { + if (document.visibilityState === "visible") onPoll().catch(onError); + }; + const timer = window.setInterval(poll, 5_000); + return () => window.clearInterval(timer); + }, [onError, onPoll, selectedDeviceRef]); + + if (selectedDevice) { + return ( + setSelectedDeviceRef(null)} + /> + ); + } + + return ( +
+
+
+ Реестр устройств +

{workspace.devices.length} зарегистрировано в проекте

+
+ + + +
+ + {!workspace.devices.length ? ( + + +

В проекте пока нет устройств

+

Добавьте разрешённый трекер через «плюс». Идентификатор попадёт в Device Core по защищённому enrollment-процессу.

+
+ ) : ( +
+
+ Устройство + Профиль + Идентификатор + Канал + Последний пакет +
+ {workspace.devices.map((device) => { + const session = latestSession(workspace, device); + const online = session?.lifecycleState === "online" || device.session?.state === "online"; + return ( + + ); + })} +
+ )} +
+ ); +} + +function DeviceDetailView({ + device, + workspace, + onBack, +}: { + device: DeviceView; + workspace: ProjectWorkspace; + onBack: () => void; +}) { + const catalog = getDeviceProfileCatalog(device.modelProfileRef); + const [activeSectionId, setActiveSectionId] = useState(catalog.sections[0]?.id ?? "passport"); + const detailRef = useRef(null); + const profile = workspace.modelProfiles.find( + (item) => item.modelProfileRef === device.modelProfileRef, + ) ?? null; + const session = latestSession(workspace, device); + const configurationState = workspace.configurationStates.find( + (item) => item.deviceRef === device.deviceRef, + ) ?? null; + const context = useMemo(() => ({ + device, + profile, + session, + configurationState, + reported: device.reported ?? {}, + policies: { + ...workspace.policies, + firmwareUpdate: "blocked", + }, + }), [configurationState, device, profile, session, workspace.policies]); + const activeSection = catalog.sections.find( + (section) => section.id === activeSectionId, + ) ?? catalog.sections[0]; + + useEffect(() => { + setActiveSectionId(catalog.sections[0]?.id ?? "passport"); + }, [catalog.profileRef, device.deviceRef]); + + useEffect(() => { + const panelBody = detailRef.current?.closest(".nodedc-application-panel__body"); + if (panelBody) panelBody.scrollTop = 0; + }, [activeSectionId, device.deviceRef]); + + if (!activeSection) return null; + + return ( +
+
+ + + +
+ {catalog.vendor} · {catalog.model} +

{device.displayName}

+

{device.identifier?.masked || "Идентификатор не назначен"} · {device.modelProfileRef}

+
+ + {session?.lifecycleState === "online" ? "Онлайн" : session?.lifecycleState || device.lifecycleState} + +
+ +
+ + + +
+ +
+ + +
+
+
+ {catalog.title} +

{activeSection.title}

+

{activeSection.description}

+
+ +
+ + + +
+ {activeSection.fields.map((item) => { + const access = item.access ?? activeSection.access; + const value = readPath(context, item.path); + return ( +
+
+ {item.label} + {access !== activeSection.access ? : null} +
+ {formatFieldValue(value, item)} + {item.description ? {item.description} : null} +
+ ); + })} +
+ +
+ Desired + {configurationState?.desiredConfigurationRevisionRef || "Не задано"} + Applied + {configurationState?.appliedConfigurationRevisionRef || "Не подтверждено"} +
+
+
+
+ ); +} + +function AccessBadge({ access, compact = false }: { access: DeviceFieldAccess; compact?: boolean }) { + const tone = access === "managed" ? "accent" : access === "protected" ? "warning" : "neutral"; + return {accessLabel(access)}; +} + +function AccessNotice({ + access, + commandTransport, +}: { + access: DeviceFieldAccess; + commandTransport: ProjectWorkspace["policies"]["commandTransport"]; +}) { + if (access === "read-only") { + return
Этот блок отражает фактическое состояние устройства и не редактируется.
; + } + if (access === "protected") { + return
Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.
; + } + return
{commandTransport === "enabled" ? "Настройка управляется через command ledger." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}
; +} + +function latestSession(workspace: ProjectWorkspace, device: DeviceView): SessionView | null { + const sessions = workspace.sessions.filter((item) => item.deviceRef === device.deviceRef); + return sessions.sort((left, right) => { + if (left.lifecycleState === "online" && right.lifecycleState !== "online") return -1; + if (right.lifecycleState === "online" && left.lifecycleState !== "online") return 1; + return String(right.lastSeenAt || right.connectedAt || "").localeCompare(String(left.lastSeenAt || left.connectedAt || "")); + })[0] ?? null; +} + +function profileLabel(workspace: ProjectWorkspace, device: DeviceView) { + const profile = workspace.modelProfiles.find( + (item) => item.modelProfileRef === device.modelProfileRef, + ); + return profile ? `${profile.vendor} ${profile.model}` : device.modelProfileRef; +} + +function readPath(input: unknown, path: string): unknown { + return path.split(".").reduce((value, key) => { + if (!value || typeof value !== "object") return undefined; + return (value as Record)[key]; + }, input); +} + +function formatFieldValue(value: unknown, item: DeviceProfileField) { + if (value === undefined || value === null || value === "") return "Нет данных"; + if (item.sensitive) return "Задано · значение скрыто"; + if (item.valueKind === "date") return formatDate(String(value)); + if (item.valueKind === "boolean" || typeof value === "boolean") return value ? "Включено" : "Выключено"; + if (value === "blocked") return "Запрещено"; + if (Array.isArray(value)) return value.length ? value.join(", ") : "Нет данных"; + if (typeof value === "object") return JSON.stringify(value); + return `${String(value)}${item.unit ? ` ${item.unit}` : ""}`; +} + +function formatDate(value: string | null | undefined) { + if (!value) return "Нет данных"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(date); +} + +export const __deviceInventoryTestables = { + formatFieldValue, + readPath, +} as const; diff --git a/apps/device-manager/src/DeviceManagerApp.tsx b/apps/device-manager/src/DeviceManagerApp.tsx index 5b1e492..dce37ec 100644 --- a/apps/device-manager/src/DeviceManagerApp.tsx +++ b/apps/device-manager/src/DeviceManagerApp.tsx @@ -41,6 +41,7 @@ import { DeviceControlView, type ControlViewId, } from "./DeviceControlViews"; +import { DeviceInventoryView } from "./DeviceInventoryView"; type ViewId = | "overview" @@ -165,6 +166,12 @@ export function DeviceManagerApp() { await refreshProjects(); }; + const pollWorkspace = async () => { + if (!activeProjectRef) return; + const next = await loadWorkspace(activeProjectRef); + setWorkspace(next); + }; + if (loading || !session) { return
Подключаем Device Core…
; } @@ -286,6 +293,7 @@ export function DeviceManagerApp() { canClaim={canClaim} session={session} onRefresh={refreshWorkspace} + onPoll={pollWorkspace} onError={(reason) => setError(errorText(reason))} onCreateCollection={() => setCollectionDialogOpen(true)} onCreateEnrollment={() => setEnrollmentDialogOpen(true)} @@ -458,7 +466,7 @@ function Metric({ label, value, detail, tone = "neutral" }: { label: string; val ); } -function ProjectView({ view, workspace, canManageCollections, canEnroll, canClaim, session, onRefresh, onError, onCreateCollection, onCreateEnrollment, onClaim }: { +function ProjectView({ view, workspace, canManageCollections, canEnroll, canClaim, session, onRefresh, onPoll, onError, onCreateCollection, onCreateEnrollment, onClaim }: { view: ViewId; workspace: ProjectWorkspace | null; canManageCollections: boolean; @@ -466,6 +474,7 @@ function ProjectView({ view, workspace, canManageCollections, canEnroll, canClai canClaim: boolean; session: DeviceManagerSession; onRefresh: () => Promise; + onPoll: () => Promise; onError: (reason: unknown) => void; onCreateCollection: () => void; onCreateEnrollment: () => void; @@ -482,15 +491,12 @@ function ProjectView({ view, workspace, canManageCollections, canEnroll, canClai />; } if (view === "inventory") return ( - ({ - id: device.deviceRef, - title: device.displayName, - subtitle: `${device.modelProfileRef} · ${device.identifier?.masked ?? "идентификатор не назначен"}`, - status: device.session?.state || device.lifecycleState, - tone: device.session?.state === "online" ? "success" : "neutral", - }))} + ); if (view === "discovery") return ( diff --git a/apps/device-manager/src/deviceProfileCatalog.ts b/apps/device-manager/src/deviceProfileCatalog.ts new file mode 100644 index 0000000..d2463a6 --- /dev/null +++ b/apps/device-manager/src/deviceProfileCatalog.ts @@ -0,0 +1,465 @@ +export type DeviceFieldAccess = "read-only" | "managed" | "protected"; + +export type DeviceFieldValueKind = "text" | "number" | "boolean" | "date"; + +export interface DeviceProfileField { + key: string; + label: string; + path: string; + access?: DeviceFieldAccess; + valueKind?: DeviceFieldValueKind; + unit?: string; + description?: string; + sensitive?: boolean; +} + +export interface DeviceProfileSection { + id: string; + label: string; + title: string; + description: string; + access: DeviceFieldAccess; + fields: DeviceProfileField[]; +} + +export interface DeviceProfileCatalog { + profileRef: string; + vendor: string; + model: string; + title: string; + sections: DeviceProfileSection[]; +} + +const field = ( + key: string, + label: string, + path: string, + options: Omit = {}, +): DeviceProfileField => ({ key, label, path, ...options }); + +const managed = ( + key: string, + label: string, + path: string, + options: Omit = {}, +) => field(key, label, path, { ...options, access: "managed" }); + +const protectedField = ( + key: string, + label: string, + path: string, + options: Omit = {}, +) => field(key, label, path, { ...options, access: "protected" }); + +const serverFields = (slot: number) => [ + managed(`server-${slot}-host`, `Сервер ${slot}: DNS / IP`, `reported.configuration.monitoring.servers.${slot - 1}.host`), + managed(`server-${slot}-port`, `Сервер ${slot}: порт`, `reported.configuration.monitoring.servers.${slot - 1}.port`, { valueKind: "number" }), + managed(`server-${slot}-protocol`, `Сервер ${slot}: протокол`, `reported.configuration.monitoring.servers.${slot - 1}.protocol`), + managed(`server-${slot}-identity`, `Сервер ${slot}: ID (SN)`, `reported.configuration.monitoring.servers.${slot - 1}.identity`), + managed(`server-${slot}-password`, `Сервер ${slot}: пароль`, `reported.configuration.monitoring.servers.${slot - 1}.password`, { sensitive: true }), +]; + +const managedIndexedFields = ( + count: number, + prefix: string, + label: string, + path: string, + options: Omit = {}, +) => Array.from({ length: count }, (_, index) => managed( + `${prefix}-${index + 1}`, + `${label} ${index + 1}`, + `${path}.${index}`, + options, +)); + +const phoneFields = Array.from({ length: 5 }, (_, index) => [ + managed(`phone-${index + 1}-number`, `Телефон ${index + 1}: номер`, `reported.configuration.phones.${index}.number`, { sensitive: true }), + managed(`phone-${index + 1}-mode`, `Телефон ${index + 1}: режим`, `reported.configuration.phones.${index}.mode`), +]).flat(); + +const simFields = (slot: number) => [ + managed(`sim-${slot}-gprs`, `SIM ${slot}: передача данных`, `reported.configuration.simCards.${slot - 1}.gprsEnabled`, { valueKind: "boolean" }), + managed(`sim-${slot}-apn`, `SIM ${slot}: APN оператора`, `reported.configuration.simCards.${slot - 1}.apn`), + managed(`sim-${slot}-login`, `SIM ${slot}: логин APN`, `reported.configuration.simCards.${slot - 1}.login`, { sensitive: true }), + managed(`sim-${slot}-password`, `SIM ${slot}: пароль APN`, `reported.configuration.simCards.${slot - 1}.password`, { sensitive: true }), + managed(`sim-${slot}-roaming`, `SIM ${slot}: роуминг`, `reported.configuration.simCards.${slot - 1}.roamingEnabled`, { valueKind: "boolean" }), + managed(`sim-${slot}-operator`, `SIM ${slot}: приоритетный оператор`, `reported.configuration.simCards.${slot - 1}.preferredOperatorCode`), + managed(`sim-${slot}-pin`, `SIM ${slot}: PIN`, `reported.configuration.simCards.${slot - 1}.pin`, { sensitive: true }), + managed(`sim-${slot}-ussd`, `SIM ${slot}: USSD запроса баланса`, `reported.configuration.simCards.${slot - 1}.balanceUssd`, { sensitive: true }), + managed(`sim-${slot}-poll`, `SIM ${slot}: период запроса баланса`, `reported.configuration.simCards.${slot - 1}.balancePollHours`, { valueKind: "number", unit: "ч" }), +]; + +const motionEventFields = ["acceleration", "braking", "cornering", "vertical"].flatMap((event) => { + const labels: Record = { + acceleration: "Разгон", + braking: "Торможение", + cornering: "Угловое ускорение", + vertical: "Вертикальное ускорение", + }; + return Array.from({ length: 3 }, (_, level) => [ + managed(`${event}-${level + 1}-threshold`, `${labels[event]} ${level + 1}: порог`, `reported.configuration.drivingStyle.${event}.${level}.thresholdMg`, { valueKind: "number", unit: "mg" }), + managed(`${event}-${level + 1}-duration`, `${labels[event]} ${level + 1}: длительность превышения`, `reported.configuration.drivingStyle.${event}.${level}.durationMs`, { valueKind: "number", unit: "мс" }), + managed(`${event}-${level + 1}-reset`, `${labels[event]} ${level + 1}: задержка сброса`, `reported.configuration.drivingStyle.${event}.${level}.resetDelayMs`, { valueKind: "number", unit: "мс" }), + ]).flat(); +}).flat(); + +const violationFields = ["speed", "rpm"].flatMap((kind) => Array.from({ length: 4 }, (_, level) => [ + managed(`${kind}-${level + 1}-threshold`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: порог`, `reported.configuration.drivingStyle.violations.${kind}.${level}.threshold`, { valueKind: "number", unit: kind === "speed" ? "км/ч" : "об/мин" }), + managed(`${kind}-${level + 1}-duration`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: минимальное время`, `reported.configuration.drivingStyle.violations.${kind}.${level}.minimumDurationSeconds`, { valueKind: "number", unit: "с" }), + managed(`${kind}-${level + 1}-reset`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: порог сброса`, `reported.configuration.drivingStyle.violations.${kind}.${level}.resetThreshold`, { valueKind: "number", unit: kind === "speed" ? "км/ч" : "об/мин" }), +]).flat()).flat(); + +const modbusRegisterFields = Array.from({ length: 10 }, (_, index) => [ + managed(`modbus-register-${index + 1}`, `Регистр ${index + 1}: номер`, `reported.configuration.modbus.registers.${index}.number`, { valueKind: "number" }), + managed(`modbus-register-${index + 1}-pair`, `Регистр ${index + 1}: читать два регистра`, `reported.configuration.modbus.registers.${index}.readPair`, { valueKind: "boolean" }), +]).flat(); + +const bleFields = Array.from({ length: 10 }, (_, index) => [ + managed(`ble-${index + 1}-mac`, `BLE датчик ${index + 1}: MAC`, `reported.configuration.bluetooth.sensors.${index}.mac`), + managed(`ble-${index + 1}-integration`, `BLE датчик ${index + 1}: интеграция`, `reported.configuration.bluetooth.sensors.${index}.integrationExpression`), +]).flat(); + +export const ARUSNAVI_B2_CATALOG: DeviceProfileCatalog = { + profileRef: "arusnavi.b2.internal.v1", + vendor: "ARUSNAVI", + model: "B2", + title: "ARUSNAVI B2", + sections: [ + { + id: "passport", + label: "Паспорт", + title: "Паспорт и состояние устройства", + description: "Реестровая идентичность, профиль модели и текущее состояние канала. Исходный идентификатор показывается только в проекции, разрешённой Device Core.", + access: "read-only", + fields: [ + field("display-name", "Название", "device.displayName"), + field("device-key", "Ключ устройства", "device.deviceKey"), + field("device-ref", "Device Core ref", "device.deviceRef"), + field("vendor", "Производитель", "profile.vendor"), + field("model", "Модель", "profile.model"), + field("device-type", "Тип", "profile.deviceType"), + field("profile", "Профиль модели", "device.modelProfileRef"), + field("identifier-kind", "Тип идентификатора", "device.identifier.kind"), + field("identifier", "Идентификатор", "device.identifier.masked"), + field("iccid-1", "ICCID 1", "reported.identity.iccid1"), + field("iccid-2", "ICCID 2", "reported.identity.iccid2"), + field("lifecycle", "Состояние реестра", "device.lifecycleState"), + field("created", "Зарегистрирован", "device.createdAt", { valueKind: "date" }), + field("updated", "Обновлён", "device.updatedAt", { valueKind: "date" }), + field("reported-at", "Снимок устройства получен", "reported.observedAt", { valueKind: "date" }), + managed("asset-model", "Модель актива", "reported.metadata.model"), + managed("registration", "Регистрационный номер", "reported.metadata.registrationNumber"), + managed("object", "Объект", "reported.metadata.object"), + managed("description", "Описание", "reported.metadata.description"), + managed("sim-label-1", "Метка SIM 1", "reported.metadata.simLabel1"), + managed("sim-label-2", "Метка SIM 2", "reported.metadata.simLabel2"), + ], + }, + { + id: "live", + label: "Онлайн", + title: "Живой канал и телеметрия", + description: "Значения обновляются из последней gateway-сессии и безопасного снимка телеметрии. Интерфейс опрашивает Device Core, пока открыта карточка.", + access: "read-only", + fields: [ + field("session-state", "Состояние соединения", "session.lifecycleState"), + field("session-route", "Маршрут", "session.routeName"), + field("session-connected", "Подключён", "session.connectedAt", { valueKind: "date" }), + field("session-last-seen", "Последний пакет", "session.lastSeenAt", { valueKind: "date" }), + field("session-frames", "Принято пакетов", "session.frameCount", { valueKind: "number" }), + field("session-bytes", "Принято данных", "session.byteCount", { valueKind: "number", unit: "байт" }), + field("latitude", "Широта", "reported.telemetry.navigation.latitude"), + field("longitude", "Долгота", "reported.telemetry.navigation.longitude"), + field("speed", "Скорость", "reported.telemetry.navigation.speedKph", { valueKind: "number", unit: "км/ч" }), + field("altitude", "Высота", "reported.telemetry.navigation.altitudeMeters", { valueKind: "number", unit: "м" }), + field("satellites", "Спутники", "reported.telemetry.navigation.satellites", { valueKind: "number" }), + field("course", "Курс", "reported.telemetry.navigation.courseDegrees", { valueKind: "number", unit: "°" }), + field("hdop", "HDOP", "reported.telemetry.navigation.hdop"), + field("gsm-signal", "Уровень GSM", "reported.telemetry.gsm.signal"), + field("gsm-operator", "Оператор", "reported.telemetry.gsm.operator"), + field("gsm-lac", "LAC", "reported.telemetry.gsm.lac"), + field("gsm-cid", "CID", "reported.telemetry.gsm.cid"), + field("external-voltage", "Внешнее напряжение", "reported.telemetry.system.externalVoltageMv", { valueKind: "number", unit: "мВ" }), + field("internal-voltage", "Внутреннее напряжение", "reported.telemetry.system.internalVoltageMv", { valueKind: "number", unit: "мВ" }), + field("errors", "Ошибки и статусы", "reported.telemetry.system.status"), + field("inputs", "Входы и выходы", "reported.telemetry.system.io"), + field("modules", "Статусы модулей", "reported.telemetry.system.modules"), + field("engine-hours", "Моточасы", "reported.telemetry.can.engineHours"), + field("odometer", "Пробег", "reported.telemetry.can.odometer"), + field("fuel-total", "Полный расход топлива", "reported.telemetry.can.fuelTotal"), + field("fuel-level", "Уровень топлива", "reported.telemetry.can.fuelLevel"), + field("rpm", "Обороты двигателя", "reported.telemetry.can.rpm"), + field("engine-temp", "Температура двигателя", "reported.telemetry.can.engineTemperature"), + field("vehicle-speed", "Скорость по CAN", "reported.telemetry.can.vehicleSpeed"), + field("axle-pressure", "Давление на оси", "reported.telemetry.can.axlePressure"), + field("crash", "Контроллер аварии", "reported.telemetry.can.crashController"), + field("instant-fuel", "Моментальный расход", "reported.telemetry.can.instantFuel"), + field("adblue", "Уровень AdBlue", "reported.telemetry.can.adBlueLevel"), + ], + }, + { + id: "firmware", + label: "Прошивка", + title: "Версия программного обеспечения", + description: "Версию и доступность обновления показываем, но запуск обновления для пилотного B2 запрещён. Этот запрет не снимается включением обычного командного канала.", + access: "protected", + fields: [ + field("firmware-current", "Текущая версия", "reported.firmware.currentVersion"), + field("firmware-applied", "Версия применена", "reported.firmware.appliedAt", { valueKind: "date" }), + field("firmware-available", "Доступная версия", "reported.firmware.availableVersion"), + field("firmware-description", "Описание версии", "reported.firmware.description"), + protectedField("firmware-action", "Обновление прошивки", "policies.firmwareUpdate", { description: "Заблокировано для пилотного устройства" }), + ], + }, + { + id: "templates", + label: "Шаблоны", + title: "Шаблоны настроек", + description: "Шаблон хранит именованный снимок конфигурации модели. Применение должно создавать новую desired-ревизию, а не менять устройство в обход command ledger.", + access: "managed", + fields: [ + field("template-current", "Применённый шаблон", "reported.configurationTemplate.name"), + field("template-applied", "Шаблон применён", "reported.configurationTemplate.appliedAt", { valueKind: "date" }), + managed("template-select", "Выбранный шаблон", "reported.configurationTemplate.selected"), + managed("template-name", "Название нового шаблона", "reported.configurationTemplate.draft.name"), + managed("template-description", "Описание нового шаблона", "reported.configurationTemplate.draft.description"), + ], + }, + { + id: "monitoring", + label: "Серверы", + title: "Серверы мониторинга", + description: "B2 поддерживает четыре серверных слота. Существующий Gelios сохраняется параллельно; новый маршрут не должен его перетирать.", + access: "managed", + fields: [1, 2, 3, 4].flatMap(serverFields), + }, + { + id: "transmission", + label: "Передача", + title: "Набор передаваемых данных", + description: "Флаги определяют состав телеметрии, которую формирует устройство.", + access: "managed", + fields: [ + managed("tx-nav-position", "Навигация: широта и долгота", "reported.configuration.transmission.navigation.position", { valueKind: "boolean" }), + managed("tx-nav-motion", "Навигация: скорость, высота, спутники и курс", "reported.configuration.transmission.navigation.motion", { valueKind: "boolean" }), + managed("tx-nav-hdop", "Навигация: HDOP", "reported.configuration.transmission.navigation.hdop", { valueKind: "boolean" }), + managed("tx-gsm-operator", "GSM: сигнал и оператор", "reported.configuration.transmission.gsm.operator", { valueKind: "boolean" }), + managed("tx-gsm-cell", "GSM: LAC и CID", "reported.configuration.transmission.gsm.cell", { valueKind: "boolean" }), + managed("tx-system-status", "Системные: ошибки и статусы", "reported.configuration.transmission.system.status", { valueKind: "boolean" }), + managed("tx-system-io", "Системные: входы, выходы и модули", "reported.configuration.transmission.system.io", { valueKind: "boolean" }), + managed("tx-system-voltage", "Системные: напряжения", "reported.configuration.transmission.system.voltage", { valueKind: "boolean" }), + ...["statuses", "engineHours", "odometer", "fuelTotal", "fuelLevel", "rpm", "engineTemperature", "vehicleSpeed", "axlePressure", "crashController", "instantFuel", "adBlueLevel"].map((key) => managed(`tx-can-${key}`, `CAN: ${({ statuses: "статусы работы", engineHours: "моточасы", odometer: "пробег", fuelTotal: "полный расход топлива", fuelLevel: "уровень топлива", rpm: "обороты двигателя", engineTemperature: "температура двигателя", vehicleSpeed: "скорость", axlePressure: "давление на оси", crashController: "контроллер аварии", instantFuel: "моментальный расход", adBlueLevel: "уровень AdBlue" } as Record)[key]}`, `reported.configuration.transmission.can.${key}`, { valueKind: "boolean" })), + ], + }, + { + id: "trajectory", + label: "Траектория", + title: "Отрисовка траектории и датчик движения", + description: "Обычные и роуминговые интервалы, заморозка координат и параметры встроенного датчика движения.", + access: "managed", + fields: [ + ...["normal", "roaming"].flatMap((mode) => { + const label = mode === "normal" ? "Основной режим" : "Роуминг"; + return [ + managed(`${mode}-course`, `${label}: изменение курса`, `reported.configuration.trajectory.${mode}.courseDeltaDegrees`, { valueKind: "number", unit: "°" }), + managed(`${mode}-speed`, `${label}: изменение скорости`, `reported.configuration.trajectory.${mode}.speedDeltaKph`, { valueKind: "number", unit: "км/ч" }), + managed(`${mode}-distance`, `${label}: расстояние между точками`, `reported.configuration.trajectory.${mode}.distanceMeters`, { valueKind: "number", unit: "м" }), + managed(`${mode}-parking`, `${label}: интервал на стоянке`, `reported.configuration.trajectory.${mode}.parkingIntervalSeconds`, { valueKind: "number", unit: "с" }), + ]; + }), + managed("freeze-low-speed", "Заморозка координат при скорости ниже 2 км/ч", "reported.configuration.trajectory.freeze.lowSpeed", { valueKind: "boolean" }), + managed("freeze-motion", "Заморозка по датчику движения", "reported.configuration.trajectory.freeze.motionSensor", { valueKind: "boolean" }), + managed("freeze-ignition", "Заморозка по зажиганию", "reported.configuration.trajectory.freeze.ignition", { valueKind: "boolean" }), + managed("freeze-quiet", "Тихоходная техника", "reported.configuration.trajectory.freeze.lowSpeedVehicle", { valueKind: "boolean" }), + managed("motion-sensitivity", "Чувствительность датчика движения", "reported.configuration.motionSensor.sensitivity", { valueKind: "number" }), + managed("motion-delay", "Задержка срабатывания", "reported.configuration.motionSensor.delaySeconds", { valueKind: "number", unit: "с" }), + managed("motion-impact", "Порог удара", "reported.configuration.motionSensor.impact", { valueKind: "number" }), + managed("motion-tilt", "Порог наклона", "reported.configuration.motionSensor.tilt", { valueKind: "number" }), + ], + }, + { + id: "io", + label: "Входы / выходы", + title: "Входы и выходы", + description: "Режимы PIN0–PIN7 и пороги. Непосредственное переключение выходов относится к защищённым командам.", + access: "managed", + fields: [ + ...managedIndexedFields(8, "pin-mode", "Режим PIN", "reported.configuration.io.pinModes"), + managed("speed-coefficient", "Коэффициент датчика скорости", "reported.configuration.io.speedSensorCoefficient", { valueKind: "number" }), + managed("virtual-ignition", "Порог виртуального зажигания", "reported.configuration.io.virtualIgnitionThresholdMv", { valueKind: "number", unit: "мВ" }), + managed("analog-pin-2", "Порог аналогового входа PIN2", "reported.configuration.io.analogThresholds.pin2Mv", { valueKind: "number", unit: "мВ" }), + managed("analog-pin-3", "Порог аналогового входа PIN3", "reported.configuration.io.analogThresholds.pin3Mv", { valueKind: "number", unit: "мВ" }), + protectedField("output-4", "Команда выхода PIN4", "reported.operations.outputs.pin4"), + protectedField("output-5", "Команда выхода PIN5", "reported.operations.outputs.pin5"), + protectedField("output-6", "Команда выхода PIN6", "reported.operations.outputs.pin6"), + ], + }, + { + id: "ports", + label: "Порты", + title: "Цифровые порты и датчики", + description: "RS232, RS485, CAN, Wi‑Fi, фотоснимки, 1‑Wire и фильтрация датчиков.", + access: "managed", + fields: [ + managed("rs232", "RS232", "reported.configuration.ports.rs232.mode"), + managed("rs485", "RS485", "reported.configuration.ports.rs485.mode"), + managed("can-program", "Номер программы CAN", "reported.configuration.ports.can.program", { valueKind: "number" }), + managed("can-internal", "Активировать внутренний CAN", "reported.configuration.ports.can.internalEnabled", { valueKind: "boolean" }), + managed("can-seatbelt", "Контролировать ремень по CAN", "reported.configuration.ports.can.seatbelt", { valueKind: "boolean" }), + managed("can-headlight", "Контролировать ближний свет по CAN", "reported.configuration.ports.can.headlight", { valueKind: "boolean" }), + managed("wifi-ssid", "Wi‑Fi: имя сети", "reported.configuration.ports.wifi.ssid"), + managed("wifi-password", "Wi‑Fi: пароль", "reported.configuration.ports.wifi.password", { sensitive: true }), + managed("photo-interval", "Интервал фотоснимков", "reported.configuration.ports.camera.intervalMinutes", { valueKind: "number", unit: "мин" }), + managed("photo-resolution", "Разрешение фотоснимков", "reported.configuration.ports.camera.resolution"), + managed("one-wire-auto", "Сохранять новые термодатчики", "reported.configuration.ports.oneWire.autoDiscover", { valueKind: "boolean" }), + ...managedIndexedFields(10, "one-wire", "Адрес термодатчика", "reported.configuration.ports.oneWire.sensorAddresses"), + managed("median-filter", "Медианный фильтр датчиков", "reported.configuration.ports.sensorFilter.medianEnabled", { valueKind: "boolean" }), + ...managedIndexedFields(4, "lls-filter", "Степень фильтрации LLS", "reported.configuration.ports.sensorFilter.lls", { valueKind: "number" }), + ], + }, + { + id: "modbus", + label: "Modbus", + title: "Параметры Modbus", + description: "Последовательный порт, сетевые адреса и до десяти читаемых регистров.", + access: "managed", + fields: [ + managed("modbus-baud", "Скорость обмена", "reported.configuration.modbus.baudRate", { valueKind: "number" }), + managed("modbus-poll", "Таймер опроса", "reported.configuration.modbus.pollSeconds", { valueKind: "number", unit: "с" }), + managed("modbus-parity", "Проверка на чётность", "reported.configuration.modbus.parity"), + managed("modbus-stop", "Stop bits", "reported.configuration.modbus.stopBits"), + managed("modbus-address-a", "Сетевой адрес датчика для регистров 1–5", "reported.configuration.modbus.addresses.first", { valueKind: "number" }), + managed("modbus-address-b", "Сетевой адрес датчика для регистров 6–10", "reported.configuration.modbus.addresses.second", { valueKind: "number" }), + ...modbusRegisterFields, + ], + }, + { + id: "bluetooth", + label: "Bluetooth", + title: "Bluetooth (BLE) датчики", + description: "Режим BLE-модуля, код сопряжения и десять датчиков с выражениями универсальной интеграции.", + access: "managed", + fields: [ + managed("ble-mode", "Режим работы Bluetooth", "reported.configuration.bluetooth.mode"), + managed("ble-pairing", "Код сопряжения", "reported.configuration.bluetooth.pairingCode", { sensitive: true }), + ...bleFields, + ], + }, + { + id: "driving-style", + label: "Стиль вождения", + title: "Стиль вождения", + description: "Пороговые профили акселерометра и превышений скорости/оборотов.", + access: "managed", + fields: [ + ...motionEventFields, + managed("accelerometer-transmit", "Передавать данные акселерометра", "reported.configuration.drivingStyle.transmitAccelerometer", { valueKind: "boolean" }), + managed("accelerometer-reset-events", "Передавать события сброса", "reported.configuration.drivingStyle.transmitResetEvents", { valueKind: "boolean" }), + managed("accelerometer-bitmask", "Передавать состояния сработок", "reported.configuration.drivingStyle.transmitTriggerMask", { valueKind: "boolean" }), + managed("accelerometer-average", "Глубина усреднения акселерометра", "reported.configuration.drivingStyle.averagingDepth", { valueKind: "number" }), + ...violationFields, + ], + }, + { + id: "phones", + label: "Телефоны", + title: "Разрешённые телефоны", + description: "До пяти номеров и индивидуальный режим доступа для SMS-управления.", + access: "managed", + fields: phoneFields, + }, + { + id: "sim", + label: "SIM-карты", + title: "SIM-карты и мобильная сеть", + description: "Параметры двух SIM-профилей. Пароли, PIN и USSD не возвращаются в открытом виде.", + access: "managed", + fields: [...simFields(1), ...simFields(2)], + }, + { + id: "navigation", + label: "Навигация", + title: "Навигация и фильтрация координат", + description: "Источники координат, спутниковые группировки, внешний локатор и фильтры качества.", + access: "managed", + fields: [ + managed("nav-satellite", "Спутниковая навигация", "reported.configuration.navigation.sources.satellite", { valueKind: "boolean" }), + managed("nav-wifi", "Wi‑Fi локатор", "reported.configuration.navigation.sources.wifi", { valueKind: "boolean" }), + managed("nav-lbs", "LBS локатор", "reported.configuration.navigation.sources.lbs", { valueKind: "boolean" }), + managed("nav-tag", "Навигационная метка", "reported.configuration.navigation.sources.tag", { valueKind: "boolean" }), + ...["gps", "glonass", "galileo", "beidou"].map((key) => managed(`nav-${key}`, key.toUpperCase(), `reported.configuration.navigation.constellations.${key}`, { valueKind: "boolean" })), + managed("locator-url", "URL локатора", "reported.configuration.navigation.locator.url", { sensitive: true }), + managed("locator-moving", "Интервал локатора в движении", "reported.configuration.navigation.locator.movingIntervalSeconds", { valueKind: "number", unit: "с" }), + managed("locator-parked", "Интервал локатора на стоянке", "reported.configuration.navigation.locator.parkedIntervalSeconds", { valueKind: "number", unit: "с" }), + managed("filter-satellites", "Минимальное число спутников", "reported.configuration.navigation.filter.minimumSatellites", { valueKind: "number" }), + managed("filter-hdop", "Максимальный HDOP × 10", "reported.configuration.navigation.filter.maximumHdopTimesTen", { valueKind: "number" }), + managed("filter-altitude-min", "Минимальная высота", "reported.configuration.navigation.filter.minimumAltitudeMeters", { valueKind: "number", unit: "м" }), + managed("filter-altitude-max", "Максимальная высота", "reported.configuration.navigation.filter.maximumAltitudeMeters", { valueKind: "number", unit: "м" }), + managed("filter-speed-min", "Минимальная мгновенная скорость", "reported.configuration.navigation.filter.minimumInstantSpeedKph", { valueKind: "number", unit: "км/ч" }), + managed("filter-speed-max", "Максимальная мгновенная скорость", "reported.configuration.navigation.filter.maximumInstantSpeedKph", { valueKind: "number", unit: "км/ч" }), + managed("filter-speed-average", "Максимальная средняя скорость", "reported.configuration.navigation.filter.maximumAverageSpeedKph", { valueKind: "number", unit: "км/ч" }), + managed("filter-time", "Максимальное время фильтрации", "reported.configuration.navigation.filter.maximumSeconds", { valueKind: "number", unit: "с" }), + ], + }, + { + id: "system", + label: "Системные", + title: "Системные параметры", + description: "Системные интервалы и энергосбережение. Секретные значения отображаются только как факт наличия.", + access: "managed", + fields: [ + managed("sms-password", "Пароль устройства (SMS)", "reported.configuration.system.smsPassword", { sensitive: true }), + managed("web-check-hours", "Проверять WEB-конфигуратор каждые", "reported.configuration.system.webConfiguration.checkHours", { valueKind: "number", unit: "ч" }), + managed("web-check-start", "Проверять WEB-конфигуратор при старте", "reported.configuration.system.webConfiguration.onStart", { valueKind: "boolean" }), + managed("sleep-mode", "Режим сна", "reported.configuration.system.powerSaving.mode"), + managed("sleep-wake-interval", "Выходить на связь каждые", "reported.configuration.system.powerSaving.wakeIntervalMinutes", { valueKind: "number", unit: "мин" }), + managed("sleep-online", "Время пребывания на связи", "reported.configuration.system.powerSaving.onlineMinutes", { valueKind: "number", unit: "мин" }), + managed("sleep-motion", "Выходить из сна по датчику движения", "reported.configuration.system.powerSaving.wakeOnMotion", { valueKind: "boolean" }), + managed("sleep-input", "Выходить из сна по изменению входа", "reported.configuration.system.powerSaving.wakeOnInput", { valueKind: "boolean" }), + managed("battery-ignition", "Заряжать АКБ только при включённом зажигании", "reported.configuration.system.chargeBatteryOnIgnitionOnly", { valueKind: "boolean" }), + ], + }, + { + id: "diagnostics", + label: "Диагностика", + title: "Диагностика и операции", + description: "Доступные B2 операции отражены полностью, но выполняются только через подтверждённый двусторонний канал и отдельный command ledger.", + access: "protected", + fields: [ + field("debug-last-session", "Последняя удалённая отладка", "reported.diagnostics.lastSessionAt", { valueKind: "date" }), + field("debug-output", "Результат удалённой отладки", "reported.diagnostics.output"), + protectedField("op-packet", "Запросить пакет телеметрии", "reported.operations.requestTelemetry"), + protectedField("op-info", "Запросить информацию", "reported.operations.requestInfo"), + protectedField("op-coordinates", "Запросить координаты", "reported.operations.requestCoordinates"), + protectedField("op-config", "Синхронизировать настройки", "reported.operations.syncConfiguration"), + protectedField("op-restart", "Перезапустить устройство", "reported.operations.restart"), + protectedField("op-clear", "Очистить память", "reported.operations.clearMemory"), + protectedField("op-firmware", "Обновить прошивку", "reported.operations.updateFirmware", { description: "Запрещено для пилотного B2" }), + ], + }, + ], +}; + +const genericCatalog: DeviceProfileCatalog = { + profileRef: "generic.device.v1", + vendor: "NODE.DC", + model: "Generic device", + title: "Устройство", + sections: ARUSNAVI_B2_CATALOG.sections.filter((section) => ["passport", "live"].includes(section.id)), +}; + +const catalogs = new Map([ + [ARUSNAVI_B2_CATALOG.profileRef, ARUSNAVI_B2_CATALOG], +]); + +export function getDeviceProfileCatalog(profileRef: string): DeviceProfileCatalog { + return catalogs.get(profileRef) ?? { ...genericCatalog, profileRef }; +} + +export function accessLabel(access: DeviceFieldAccess) { + return ({ + "read-only": "Только чтение", + managed: "Управляемая настройка", + protected: "Защищённая операция", + })[access]; +} diff --git a/apps/device-manager/src/styles.css b/apps/device-manager/src/styles.css index 5428755..bc632e5 100644 --- a/apps/device-manager/src/styles.css +++ b/apps/device-manager/src/styles.css @@ -504,6 +504,366 @@ textarea { font-size: 0.72rem; } +.device-inventory { + display: grid; + gap: 1rem; +} + +.device-inventory__toolbar > div:first-child { + display: grid; + gap: 0.2rem; +} + +.device-inventory__toolbar strong { + font-size: 0.95rem; +} + +.device-inventory__empty { + min-height: 280px; +} + +.device-inventory-table { + overflow: hidden; + border: 1px solid var(--nodedc-glass-border); + border-radius: 20px; + background: color-mix(in srgb, var(--nodedc-glass-surface-bg) 86%, transparent); +} + +.device-inventory-table__head, +.device-inventory-row { + display: grid; + grid-template-columns: minmax(180px, 1.45fr) minmax(130px, 1fr) minmax(140px, 1fr) minmax(90px, 0.7fr) minmax(150px, 0.9fr) 28px; + align-items: center; + gap: 0.8rem; +} + +.device-inventory-table__head { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--nodedc-glass-border); + color: var(--nodedc-text-tertiary); + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.device-inventory-row { + width: 100%; + border: 0; + border-bottom: 1px solid var(--nodedc-glass-border); + background: transparent; + color: inherit; + padding: 0.9rem 1rem; + font: inherit; + text-align: left; + cursor: pointer; + transition: background 160ms ease, transform 160ms ease; +} + +.device-inventory-row:last-child { + border-bottom: 0; +} + +.device-inventory-row:hover, +.device-inventory-row:focus-visible { + outline: none; + background: var(--nodedc-glass-control-bg); +} + +.device-inventory-row:active { + transform: translateY(1px); +} + +.device-inventory-row > span { + min-width: 0; + overflow: hidden; + color: var(--nodedc-text-secondary); + font-size: 0.76rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-inventory-row__device { + display: flex; + align-items: center; + gap: 0.7rem; +} + +.device-inventory-row__device > span:last-child { + display: grid; + min-width: 0; + gap: 0.15rem; +} + +.device-inventory-row__device strong { + overflow: hidden; + color: var(--nodedc-text-primary); + font-size: 0.82rem; + text-overflow: ellipsis; +} + +.device-inventory-row__device small { + overflow: hidden; + color: var(--nodedc-text-tertiary); + font-size: 0.67rem; + text-overflow: ellipsis; +} + +.device-inventory-row__icon { + display: grid; + width: 32px; + height: 32px; + flex: 0 0 32px; + place-items: center; + border-radius: 10px; + background: var(--nodedc-glass-control-bg); + color: var(--nodedc-accent, #b9ff37); +} + +.device-inventory-row__open { + display: grid; + justify-items: end; + color: var(--nodedc-text-tertiary) !important; +} + +.device-detail { + display: grid; + gap: 1rem; +} + +.device-detail__header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.9rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--nodedc-glass-border); +} + +.device-detail__identity { + display: grid; + min-width: 0; + gap: 0.12rem; +} + +.device-detail__identity small { + color: var(--nodedc-accent, #b9ff37); + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.device-detail__identity h2, +.device-detail__identity p { + margin: 0; +} + +.device-detail__identity h2 { + overflow: hidden; + font-size: clamp(1.15rem, 2vw, 1.55rem); + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-detail__identity p { + overflow: hidden; + color: var(--nodedc-text-secondary); + font-size: 0.73rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-detail__legend { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.device-detail__layout { + display: grid; + grid-template-columns: minmax(150px, 190px) minmax(0, 1fr); + align-items: start; + gap: 1rem; +} + +.device-detail__navigation { + display: grid; + overflow: hidden; + border: 1px solid var(--nodedc-glass-border); + border-radius: 16px; + background: color-mix(in srgb, var(--nodedc-glass-surface-bg) 82%, transparent); +} + +.device-detail__navigation-item { + display: grid; + gap: 0.15rem; + border: 0; + border-bottom: 1px solid var(--nodedc-glass-border); + background: transparent; + color: var(--nodedc-text-secondary); + padding: 0.68rem 0.8rem; + text-align: left; + cursor: pointer; +} + +.device-detail__navigation-item:last-child { + border-bottom: 0; +} + +.device-detail__navigation-item:hover, +.device-detail__navigation-item:focus-visible { + outline: none; + background: var(--nodedc-glass-control-bg); +} + +.device-detail__navigation-item[data-active="true"] { + background: var(--nodedc-glass-control-bg); + color: var(--nodedc-text-primary); + box-shadow: inset 3px 0 0 var(--nodedc-accent, #b9ff37); +} + +.device-detail__navigation-item span { + font-size: 0.76rem; + font-weight: 700; +} + +.device-detail__navigation-item small { + color: var(--nodedc-text-tertiary); + font-size: 0.58rem; +} + +.device-detail-section { + display: grid; + min-width: 0; + gap: 0.9rem; +} + +.device-detail-section__heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.device-detail-section__heading > div { + display: grid; + gap: 0.28rem; +} + +.device-detail-section__heading span:first-child { + color: var(--nodedc-text-tertiary); + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.device-detail-section__heading h3, +.device-detail-section__heading p { + margin: 0; +} + +.device-detail-section__heading h3 { + font-size: 1.08rem; +} + +.device-detail-section__heading p { + max-width: 760px; + color: var(--nodedc-text-secondary); + font-size: 0.75rem; + line-height: 1.55; +} + +.device-detail-notice { + display: flex; + align-items: flex-start; + gap: 0.55rem; + border: 1px solid var(--nodedc-glass-border); + border-radius: 13px; + background: var(--nodedc-glass-control-bg); + color: var(--nodedc-text-secondary); + padding: 0.7rem 0.8rem; + font-size: 0.7rem; + line-height: 1.45; +} + +.device-detail-notice[data-access="managed"] { + border-color: color-mix(in srgb, var(--nodedc-accent, #b9ff37) 32%, var(--nodedc-glass-border)); +} + +.device-detail-notice[data-access="protected"] { + border-color: color-mix(in srgb, #ffb347 38%, var(--nodedc-glass-border)); +} + +.device-detail-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; +} + +.device-detail-field { + display: grid; + min-width: 0; + gap: 0.42rem; + border: 1px solid var(--nodedc-glass-border); + border-radius: 14px; + background: color-mix(in srgb, var(--nodedc-glass-control-bg) 78%, transparent); + padding: 0.75rem 0.8rem; +} + +.device-detail-field[data-access="read-only"] { + background: color-mix(in srgb, var(--nodedc-glass-control-bg) 58%, transparent); +} + +.device-detail-field[data-access="protected"] { + border-color: color-mix(in srgb, #ffb347 26%, var(--nodedc-glass-border)); +} + +.device-detail-field__label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + color: var(--nodedc-text-secondary); + font-size: 0.67rem; +} + +.device-detail-field strong { + overflow-wrap: anywhere; + font-size: 0.8rem; + font-weight: 650; + line-height: 1.4; +} + +.device-detail-field > small { + color: var(--nodedc-text-tertiary); + font-size: 0.62rem; + line-height: 1.45; +} + +.device-access-badge--compact { + flex: 0 0 auto; + padding: 0.25rem 0.45rem !important; + font-size: 0.56rem !important; +} + +.device-detail-section__state { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.35rem 0.7rem; + border-top: 1px solid var(--nodedc-glass-border); + padding-top: 0.85rem; + color: var(--nodedc-text-secondary); + font-size: 0.65rem; +} + +.device-detail-section__state strong { + overflow-wrap: anywhere; + color: var(--nodedc-text-primary); + font-weight: 600; +} + .device-manager-form { display: grid; gap: 1rem; @@ -570,4 +930,37 @@ textarea { grid-column: 2; justify-self: end; } + + .device-inventory-table__head { + display: none; + } + + .device-inventory-row { + grid-template-columns: minmax(0, 1fr) auto; + } + + .device-inventory-row > span:not(.device-inventory-row__device):not(.device-inventory-row__open) { + display: none; + } + + .device-detail__layout { + grid-template-columns: 1fr; + } + + .device-detail__navigation { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .device-detail__navigation-item:nth-child(odd) { + border-right: 1px solid var(--nodedc-glass-border); + } + + .device-detail-fields { + grid-template-columns: 1fr; + } + + .device-detail-section__heading { + align-items: stretch; + flex-direction: column; + } } diff --git a/apps/device-manager/src/types.ts b/apps/device-manager/src/types.ts index 4e8a3ae..1867fa4 100644 --- a/apps/device-manager/src/types.ts +++ b/apps/device-manager/src/types.ts @@ -53,6 +53,16 @@ export interface DeviceView { lifecycleState: string; identifier: { kind: string; masked: string } | null; session: { state: string; lastSeenAt: string | null } | null; + reported?: { + observedAt?: string | null; + identity?: Record; + metadata?: Record; + firmware?: Record; + configuration?: Record; + telemetry?: Record; + diagnostics?: Record; + operations?: Record; + } | null; createdAt: string | null; updatedAt: string | null; }