Panels become devices: per-device documents in the flow repo, a Devices tab

- flow/devices.py: DeviceDef in FLOWS_DIR/_devices/<id>.json, committed under
  the flow store's write lock, 409 on a stale version; nonces in DEVICES_FILE
  outside git; in-memory presence announced as device_status
- api/routes/devices.py: CRUD, pairing with an optional {name, board,
  firmware}, a pending list adopted without a code from the LAN only, unpair;
  /panels/pair, /panels/pair/{code} and GET /panels/{id} kept as aliases,
  PUT /panels/ gone
- _device_may replaces _panel_may, JWT claims unchanged
- panels.json moves into devices once at startup, then .migrated
- MCP list_devices, get_device; search category device
- Devices tab (list, detail with presence, pairing, ordered dashboards,
  touch, I/O), PanelsDialog removed, serialised optimistic saves
This commit is contained in:
Melvin Strobl
2026-10-16 15:48:36 +02:00
parent 3c0912a3fe
commit 28a565e501
56 changed files with 3539 additions and 2404 deletions
+248 -131
View File
@@ -911,6 +911,179 @@ export const DeadLetterSchema = {
title: 'DeadLetter'
} as const;
export const DeviceCreateSchema = {
properties: {
title: {
type: 'string',
title: 'Title',
default: ''
}
},
type: 'object',
title: 'DeviceCreate'
} as const;
export const DeviceDefSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
dashboards: {
items: {
type: 'string'
},
type: 'array',
title: 'Dashboards'
},
touch: {
type: 'boolean',
title: 'Touch',
default: false
},
io: {
items: {
'$ref': '#/components/schemas/DevicePort'
},
type: 'array',
title: 'Io'
},
version: {
type: 'integer',
title: 'Version',
default: 0
}
},
type: 'object',
required: ['id'],
title: 'DeviceDef',
description: 'One device, and what it shows.'
} as const;
export const DevicePortSchema = {
properties: {
port: {
type: 'string',
title: 'Port'
},
message: {
type: 'string',
title: 'Message'
},
direction: {
type: 'string',
enum: ['in', 'out'],
title: 'Direction'
},
config: {
additionalProperties: true,
type: 'object',
title: 'Config'
}
},
type: 'object',
required: ['port', 'message', 'direction'],
title: 'DevicePort',
description: `One piece of the device's own hardware, wired to a message.
A wall panel is a node as well as a screen: a button on its GPIO, a
microphone, a speaker. Which ports exist is the device's business, so the
name is free text and \`\`config\`\` is passed through to it unread.`
} as const;
export const DevicePublicSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
dashboards: {
items: {
type: 'string'
},
type: 'array',
title: 'Dashboards'
},
touch: {
type: 'boolean',
title: 'Touch',
default: false
},
io: {
items: {
'$ref': '#/components/schemas/DevicePort'
},
type: 'array',
title: 'Io'
},
version: {
type: 'integer',
title: 'Version',
default: 0
},
online: {
type: 'boolean',
title: 'Online',
default: false
},
last_seen: {
anyOf: [
{
type: 'string',
format: 'date-time'
},
{
type: 'null'
}
],
title: 'Last Seen'
}
},
type: 'object',
required: ['id'],
title: 'DevicePublic',
description: 'A device as the Devices tab lists it, with whether it is there.'
} as const;
export const DevicesPublicSchema = {
properties: {
data: {
items: {
'$ref': '#/components/schemas/DevicePublic'
},
type: 'array',
title: 'Data'
},
count: {
type: 'integer',
title: 'Count',
default: 0
},
frontend_host: {
type: 'string',
title: 'Frontend Host',
default: ''
}
},
type: 'object',
title: 'DevicesPublic',
description: `The devices, and the address a device should be pointed at.
The address is the server's own, because the browser's origin is not a
reliable answer: an admin working through the portal is on the portal's
origin, and this one is for a screen on this network.`
} as const;
export const EndpointSchema = {
properties: {
kind: {
@@ -2924,16 +3097,45 @@ export const OAuthClientRegisterSchema = {
description: 'RFC 7591 dynamic client registration request.'
} as const;
export const PairHelloSchema = {
properties: {
name: {
type: 'string',
title: 'Name',
default: ''
},
board: {
type: 'string',
title: 'Board',
default: ''
},
firmware: {
type: 'string',
title: 'Firmware',
default: ''
}
},
type: 'object',
title: 'PairHello',
description: 'What a device may say about itself when it asks for a code.'
} as const;
export const PairRequestSchema = {
properties: {
code: {
type: 'string',
title: 'Code'
title: 'Code',
default: ''
},
request: {
type: 'string',
title: 'Request',
default: ''
}
},
type: 'object',
required: ['code'],
title: 'PairRequest'
title: 'PairRequest',
description: 'Adopt the device showing ``code``, or the pending ``request``.'
} as const;
export const PairStartedSchema = {
@@ -2971,6 +3173,17 @@ export const PairStatusSchema = {
],
title: 'Access Token'
},
device: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Device'
},
panel: {
anyOf: [
{
@@ -2985,145 +3198,49 @@ export const PairStatusSchema = {
},
type: 'object',
title: 'PairStatus',
description: 'Nothing yet, or the credential and the panel it is for.'
} as const;
export const PanelDefSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
dashboards: {
items: {
type: 'string'
},
type: 'array',
title: 'Dashboards'
},
touch: {
type: 'boolean',
title: 'Touch',
default: false
},
io: {
items: {
'$ref': '#/components/schemas/PanelPort'
},
type: 'array',
title: 'Io'
},
nonce: {
type: 'integer',
title: 'Nonce',
default: 0
}
},
type: 'object',
required: ['id'],
title: 'PanelDef',
description: 'One device, and what it shows.'
} as const;
export const PanelPortSchema = {
properties: {
port: {
type: 'string',
title: 'Port'
},
message: {
type: 'string',
title: 'Message'
},
direction: {
type: 'string',
enum: ['in', 'out'],
title: 'Direction'
},
config: {
additionalProperties: true,
type: 'object',
title: 'Config'
}
},
type: 'object',
required: ['port', 'message', 'direction'],
title: 'PanelPort',
description: `One piece of the device's own hardware, wired to a message.
A wall panel is a node as well as a screen: a button on its GPIO, a
microphone, a speaker. Which ports exist is the device's business, so the
name is free text and \`\`config\`\` is passed through to it unread.`
} as const;
export const PanelsConfigSchema = {
properties: {
panels: {
items: {
'$ref': '#/components/schemas/PanelDef'
},
type: 'array',
title: 'Panels'
},
version: {
type: 'integer',
title: 'Version',
default: 0
}
},
type: 'object',
title: 'PanelsConfig',
description: 'Every panel this instance knows about.'
} as const;
export const PanelsPublicSchema = {
properties: {
panels: {
items: {
'$ref': '#/components/schemas/PanelDef'
},
type: 'array',
title: 'Panels'
},
version: {
type: 'integer',
title: 'Version',
default: 0
},
frontend_host: {
type: 'string',
title: 'Frontend Host',
default: ''
}
},
type: 'object',
title: 'PanelsPublic',
description: `The panels, and the address a device should be pointed at.
The address is the server's own, because the browser's origin is not a
reliable answer to it: an admin working through the portal is on the
portal's origin, and this one is for a screen on this network.
An instance enrolled with a portal has a second address, built by the
dialog from what \`\`/cloud/status\`\` reports rather than from here — a panel
is not the thing that knows whether remote access is on.`
description: 'Nothing yet, or the credential and the device it is for.'
} as const;
export const PendingDeviceSchema = {
properties: {
request: {
type: 'string',
title: 'Request',
default: ''
},
device: {
type: 'string',
title: 'Device'
},
name: {
type: 'string',
title: 'Name',
default: ''
},
board: {
type: 'string',
title: 'Board',
default: ''
},
firmware: {
type: 'string',
title: 'Firmware',
default: ''
},
remote: {
type: 'boolean',
title: 'Remote',
default: false
},
needs_code: {
type: 'boolean',
title: 'Needs Code',
default: true
},
expires_in: {
type: 'integer',
title: 'Expires In',
default: 0
}
},
type: 'object',
@@ -3772,7 +3889,7 @@ export const SearchEntrySchema = {
properties: {
category: {
type: 'string',
enum: ['flow', 'node', 'input', 'output', 'dashboard', 'widget', 'panel', 'secret', 'module', 'worker', 'alert'],
enum: ['flow', 'node', 'input', 'output', 'dashboard', 'widget', 'device', 'secret', 'module', 'worker', 'alert'],
title: 'Category'
},
name: {
File diff suppressed because one or more lines are too long
+147 -101
View File
@@ -229,6 +229,67 @@ export type DeadLetter = {
reason: string;
};
export type DeviceCreate = {
title?: string;
};
/**
* One device, and what it shows.
*/
export type DeviceDef = {
id: string;
title?: string;
dashboards?: Array<(string)>;
touch?: boolean;
io?: Array<DevicePort>;
version?: number;
};
/**
* One piece of the device's own hardware, wired to a message.
*
* A wall panel is a node as well as a screen: a button on its GPIO, a
* microphone, a speaker. Which ports exist is the device's business, so the
* name is free text and ``config`` is passed through to it unread.
*/
export type DevicePort = {
port: string;
message: string;
direction: 'in' | 'out';
config?: {
[key: string]: unknown;
};
};
export type direction = 'in' | 'out';
/**
* A device as the Devices tab lists it, with whether it is there.
*/
export type DevicePublic = {
id: string;
title?: string;
dashboards?: Array<(string)>;
touch?: boolean;
io?: Array<DevicePort>;
version?: number;
online?: boolean;
last_seen?: (string | null);
};
/**
* The devices, and the address a device should be pointed at.
*
* The address is the server's own, because the browser's origin is not a
* reliable answer: an admin working through the portal is on the portal's
* origin, and this one is for a screen on this network.
*/
export type DevicesPublic = {
data?: Array<DevicePublic>;
count?: number;
frontend_host?: string;
};
/**
* Serializable payload types.
*
@@ -982,8 +1043,21 @@ export type OAuthClientRegister = {
token_endpoint_auth_method?: (string | null);
};
/**
* What a device may say about itself when it asks for a code.
*/
export type PairHello = {
name?: string;
board?: string;
firmware?: string;
};
/**
* Adopt the device showing ``code``, or the pending ``request``.
*/
export type PairRequest = {
code: string;
code?: string;
request?: string;
};
/**
@@ -996,74 +1070,26 @@ export type PairStarted = {
};
/**
* Nothing yet, or the credential and the panel it is for.
* Nothing yet, or the credential and the device it is for.
*/
export type PairStatus = {
access_token?: (string | null);
device?: (string | null);
panel?: (string | null);
};
/**
* One device, and what it shows.
*/
export type PanelDef = {
id: string;
title?: string;
dashboards?: Array<(string)>;
touch?: boolean;
io?: Array<PanelPort>;
nonce?: number;
};
/**
* One piece of the device's own hardware, wired to a message.
*
* A wall panel is a node as well as a screen: a button on its GPIO, a
* microphone, a speaker. Which ports exist is the device's business, so the
* name is free text and ``config`` is passed through to it unread.
*/
export type PanelPort = {
port: string;
message: string;
direction: 'in' | 'out';
config?: {
[key: string]: unknown;
};
};
export type direction = 'in' | 'out';
/**
* Every panel this instance knows about.
*/
export type PanelsConfig = {
panels?: Array<PanelDef>;
version?: number;
};
/**
* The panels, and the address a device should be pointed at.
*
* The address is the server's own, because the browser's origin is not a
* reliable answer to it: an admin working through the portal is on the
* portal's origin, and this one is for a screen on this network.
*
* An instance enrolled with a portal has a second address, built by the
* dialog from what ``/cloud/status`` reports rather than from here — a panel
* is not the thing that knows whether remote access is on.
*/
export type PanelsPublic = {
panels?: Array<PanelDef>;
version?: number;
frontend_host?: string;
};
/**
* Who is asking, as far as the request itself says.
*/
export type PendingDevice = {
request?: string;
device: string;
name?: string;
board?: string;
firmware?: string;
remote?: boolean;
needs_code?: boolean;
expires_in?: number;
};
/**
@@ -1266,14 +1292,14 @@ export type RunRequest = {
* called, which is all the matching needs.
*/
export type SearchEntry = {
category: 'flow' | 'node' | 'input' | 'output' | 'dashboard' | 'widget' | 'panel' | 'secret' | 'module' | 'worker' | 'alert';
category: 'flow' | 'node' | 'input' | 'output' | 'dashboard' | 'widget' | 'device' | 'secret' | 'module' | 'worker' | 'alert';
name: string;
title?: string;
parent?: string;
kind?: string;
};
export type category = 'flow' | 'node' | 'input' | 'output' | 'dashboard' | 'widget' | 'panel' | 'secret' | 'module' | 'worker' | 'alert';
export type category = 'flow' | 'node' | 'input' | 'output' | 'dashboard' | 'widget' | 'device' | 'secret' | 'module' | 'worker' | 'alert';
export type SecretNames = {
data: Array<(string)>;
@@ -1778,6 +1804,68 @@ export type DashboardsRenameDashboardData = {
export type DashboardsRenameDashboardResponse = (DashboardDef_Output);
export type DevicesStartPairingData = {
requestBody?: (PairHello | null);
};
export type DevicesStartPairingResponse = (PairStarted);
export type DevicesPollPairingData = {
code: string;
secret?: string;
};
export type DevicesPollPairingResponse = (PairStatus);
export type DevicesReadDevicesResponse = (DevicesPublic);
export type DevicesReadPendingResponse = (Array<PendingDevice>);
export type DevicesPendingDeviceData = {
code: string;
};
export type DevicesPendingDeviceResponse = (PendingDevice);
export type DevicesCreateDeviceData = {
deviceId: string;
requestBody?: (DeviceCreate | null);
};
export type DevicesCreateDeviceResponse = (DeviceDef);
export type DevicesReadDeviceData = {
deviceId: string;
};
export type DevicesReadDeviceResponse = (DeviceDef);
export type DevicesSaveDeviceData = {
deviceId: string;
requestBody: DeviceDef;
};
export type DevicesSaveDeviceResponse = (DeviceDef);
export type DevicesDeleteDeviceData = {
deviceId: string;
};
export type DevicesDeleteDeviceResponse = (Message);
export type DevicesApprovePairingData = {
deviceId: string;
requestBody: PairRequest;
};
export type DevicesApprovePairingResponse = (Message);
export type DevicesUnpairDeviceData = {
deviceId: string;
};
export type DevicesUnpairDeviceResponse = (Message);
export type FlavorsReadFlavorsResponse = (FlavorsPublic);
export type FlavorsCreateFlavorData = {
@@ -2108,48 +2196,6 @@ export type ObservabilityReadDeadLettersResponse = (Array<DeadLetter>);
export type ObservabilityFlushMetricsResponse = (Message);
export type PanelsReadPanelsResponse = (PanelsPublic);
export type PanelsSavePanelsData = {
requestBody: PanelsConfig;
};
export type PanelsSavePanelsResponse = (PanelsPublic);
export type PanelsStartPairingResponse = (PairStarted);
export type PanelsPollPairingData = {
code: string;
secret?: string;
};
export type PanelsPollPairingResponse = (PairStatus);
export type PanelsPendingDeviceData = {
code: string;
};
export type PanelsPendingDeviceResponse = (PendingDevice);
export type PanelsApprovePairingData = {
panelId: string;
requestBody: PairRequest;
};
export type PanelsApprovePairingResponse = (Message);
export type PanelsUnpairPanelData = {
panelId: string;
};
export type PanelsUnpairPanelResponse = (Message);
export type PanelsReadPanelData = {
panelId: string;
};
export type PanelsReadPanelResponse = (PanelDef);
export type PrivateCreateUserData = {
requestBody: PrivateUserCreate;
};