diff --git a/debian/changelog b/debian/changelog index 9cb098df0..4fa8440e3 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +wb-mqtt-homeui (2.250.0) stable; urgency=medium + + * Add the device configurator page (Integrations -> Device configurator) + + -- Victor Fedorov Mon, 31 Aug 2026 10:32:29 +0300 + wb-mqtt-homeui (2.249.0) stable; urgency=medium * Show the device template parameter variant matching the device firmware diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9cec8d81c..64ef4ab18 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,6 +33,7 @@ "dompurify": "3.4.5", "glyphicons-only-bootstrap": "1.0.1", "i18next": "26.3.6", + "js-yaml": "5.4.0", "lodash": "4.18.1", "lz-string": "1.5.0", "mobx": "6.15.4", @@ -934,6 +935,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2271,6 +2295,29 @@ } } }, + "node_modules/@svgr/core/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@svgr/hast-util-to-babel-ast": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", @@ -4158,7 +4205,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -8509,16 +8555,25 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.0.tgz", + "integrity": "sha512-jE7vUJIebKzYQI5xu4co5CRBDlDEYnHrdzsxs4O2giCz4v2SbVMYKpmt1D9L38OKQAeCWmrOTRiCV93u0UkaJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jsesc": { diff --git a/frontend/package.json b/frontend/package.json index 5309cf205..29ba9351b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "dompurify": "3.4.5", "glyphicons-only-bootstrap": "1.0.1", "i18next": "26.3.6", + "js-yaml": "5.4.0", "lodash": "4.18.1", "lz-string": "1.5.0", "mobx": "6.15.4", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 56eb0994c..148bebf1d 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -94,6 +94,7 @@ "list": "List", "devices": "Devices", "integrations": "Integrations", + "configurator": "Device configurator", "alice": "Yandex Alice", "widgets": "Widgets", "history": "History", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index cef983769..351edb95c 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -94,6 +94,7 @@ "list": "Список", "devices": "Устройства", "integrations": "Интеграции", + "configurator": "Конфигуратор устройств", "alice": "Яндекс Алиса", "widgets": "Виджеты", "history": "История", diff --git a/frontend/src/pages/configurator/build-config.ts b/frontend/src/pages/configurator/build-config.ts new file mode 100644 index 000000000..0d27a34b2 --- /dev/null +++ b/frontend/src/pages/configurator/build-config.ts @@ -0,0 +1,177 @@ +import type { DeviceDraft } from '@/stores/configurator'; +import type { Cell } from '@/stores/devices'; +import { roleDef } from './roles'; +import { DEVICE_TYPES } from './templates'; +import type { Config, ConfigBinding, ConfigDevice, ConfigService, DeviceType, Range, TransformSpec } from './types'; + +// Роли с девайс-зависимой шкалой: range берётся из /meta и хранится в конфиге. +// Фиксированные шкалы (brightness/position) range не хранят — их несёт transform (scale). +const DEVICE_RANGE_ROLES = new Set(['color_temperature', 'target_temperature']); + +export function typeById(id: string): DeviceType | undefined { + return DEVICE_TYPES.find((type) => type.id === id); +} + +// Тип устройства по подсказанной роли — для «создать из контрола» одним кликом. +// Многоролевые (brightness/color/CT) заводят `light` с одним слотом; остальное дособирается в карточке. +const ROLE_TYPE: Record = { + on_off: 'switch', + brightness: 'light', + color: 'light', + color_temperature: 'light', + temperature: 'temperature_sensor', + humidity: 'humidity_sensor', + co2: 'co2_sensor', + illuminance: 'illuminance_sensor', + motion: 'motion_sensor', + occupancy: 'occupancy_sensor', + contact: 'contact_sensor', + leak: 'leak_sensor', + smoke: 'smoke_sensor', + power: 'power_sensor', + energy: 'energy_meter', + position: 'cover', + press: 'button', +}; + +export function defaultTypeForRole(role: string): string | undefined { + return ROLE_TYPE[role]; +} + +// Обязательные роли: всегда-required + условные (requires сработал — зависящий слот привязан). +export function requiredRoles(device: DeviceDraft, type: DeviceType): Set { + const roles = new Set(); + type.slots.forEach((slot) => { + if (slot.required) { + roles.add(slot.role); + } + if (device.bindings[slot.role] && slot.requires) { + slot.requires.forEach((role) => roles.add(role)); + } + }); + return roles; +} + +export function isReady(device: DeviceDraft, type: DeviceType): boolean { + return [...requiredRoles(device, type)].every((role) => Boolean(device.bindings[role])); +} + +// Итоговый диапазон: правка оператора → фикс. шкала роли → /meta (для девайс-зависимых) → дефолт. +export function resolveRange(role: string, cell: Cell, override?: Range): Range | undefined { + if (override) { + return override; + } + const def = roleDef(role); + if (def.fixedScale && def.range) { + return def.range; + } + if (DEVICE_RANGE_ROLES.has(role)) { + if (cell.min !== undefined && cell.max !== undefined) { + return { min: cell.min, max: cell.max, step: cell.step ?? def.range?.step ?? 1 }; + } + return def.range; + } + return undefined; +} + +function buildBinding(role: string, cell: Cell): ConfigBinding { + const def = roleDef(role); + const binding: ConfigBinding = { state: cell.topic }; + if (def.access === 'readwrite') { + binding.command = `${cell.topic}/on`; + } + let transform: TransformSpec | undefined; + if (def.value_type === 'bool') { + transform = { type: 'boolean' }; + } else if (def.value_type === 'color') { + transform = { type: 'rgb_hs' }; + } else if (def.fixedScale && def.range && cell.min !== undefined && cell.max !== undefined + && (cell.min !== def.range.min || cell.max !== def.range.max)) { + transform = { + type: 'scale', + from: { min: cell.min, max: cell.max }, + to: { min: def.range.min, max: def.range.max }, + }; + } + if (transform) { + binding.transform = transform; + } + return binding; +} + +function buildService(sid: number, role: string, cell: Cell, override?: Range): ConfigService { + const service: ConfigService = { sid, role, binding: buildBinding(role, cell) }; + if (DEVICE_RANGE_ROLES.has(role)) { + const range = resolveRange(role, cell, override); + if (range) { + service.range = range; + } + } + return service; +} + +// Обратная развёртка: сохранённый конфиг → черновики редактора. Привязки восстанавливаются по +// топику (binding.state → id контрола среди обнаруженных); если контрол сейчас недоступен, слот +// останется непривязанным. range и настройки адаптеров переносятся как есть. +export function draftsFromConfig(config: Config, cellByTopic: Map): DeviceDraft[] { + return config.devices.map((device) => { + const bindings: Record = {}; + const ranges: Record = {}; + device.services.forEach((service) => { + const cellId = cellByTopic.get(service.binding.state); + if (cellId) { + bindings[service.role] = cellId; + } + if (service.range) { + ranges[service.role] = service.range; + } + }); + return { + did: device.did, + name: device.name, + type: device.type, + module: device.module, + area: device.area, + adapters: { ...(device.adapter_settings ?? {}) }, + bindings, + ranges, + }; + }); +} + +// Черновик → плоский конфиг. Экспортируются только готовые устройства с заданным module; +// сервисы = привязанные слоты типа (в порядке слотов), sid = 1..n. +export function buildConfig(devices: DeviceDraft[], cellById: Map): Config { + const config: Config = { version: '1.0', devices: [] }; + + devices.forEach((device) => { + const type = typeById(device.type); + if (!type || !device.module.trim() || !isReady(device, type)) { + return; + } + const services: ConfigService[] = []; + type.slots.forEach((slot) => { + const cellId = device.bindings[slot.role]; + const cell = cellId ? cellById.get(cellId) : undefined; + if (!cell) { + return; + } + services.push(buildService(services.length + 1, slot.role, cell, device.ranges[slot.role])); + }); + if (services.length === 0) { + return; + } + const built: ConfigDevice = { + did: device.did, + name: device.name.trim() || `did ${device.did}`, + type: device.type, + module: device.module.trim(), + ...(device.area?.trim() ? { area: device.area.trim() } : {}), + adapter_settings: device.adapters, + services, + }; + config.devices.push(built); + }); + + return config; +} diff --git a/frontend/src/pages/configurator/configurator.tsx b/frontend/src/pages/configurator/configurator.tsx new file mode 100644 index 000000000..17767fd32 --- /dev/null +++ b/frontend/src/pages/configurator/configurator.tsx @@ -0,0 +1,507 @@ +import { dump as dumpYaml, load as loadYaml } from 'js-yaml'; +import { observer } from 'mobx-react-lite'; +import { useEffect, useRef, useState } from 'react'; +import { PageLayout } from '@/layouts/page'; +import { authStore, UserRole } from '@/stores/auth'; +import { configuratorStore } from '@/stores/configurator'; +import { devicesStore, DeviceType, type Cell } from '@/stores/devices'; +import { + buildConfig, + defaultTypeForRole, + draftsFromConfig, + isReady, + requiredRoles, + resolveRange, + typeById, +} from './build-config'; +import { inferRole } from './infer'; +import { roleDef } from './roles'; +import { DEVICE_TYPES } from './templates'; +import type { Config } from './types'; +import './styles.css'; + +// Цели, которые устройство может отдавать (adapter_settings). +const ADAPTERS = [ + { key: 'matter', label: 'Matter' }, + { key: 'alice', label: 'Алиса' }, +]; + +// Контрол подходит роли, если совпал вид и доступ (запись → нужен rw-контрол). +const matchesRole = (cell: Cell, role: string): boolean => { + const def = roleDef(role); + return inferRole(cell).kind === def.kind && (def.access === 'read' || !cell.readOnly); +}; + +const cellLabel = (cell: Cell): string => `${cell.name} — ${cell.deviceId}`; + +// Роль по контролу неоднозначна, если /meta многозначно: range (какой уровень?), alarm, +// или value без единиц (общий числовой) — оператору стоит уточнить роль. +const AMBIGUOUS_TYPES = new Set(['range', 'alarm']); +const isAmbiguous = (cell: Cell): boolean => + AMBIGUOUS_TYPES.has(cell.type as string) || (cell.type === 'value' && !cell.units); + +const ConfiguratorPage = observer(() => { + const [typePick, setTypePick] = useState(DEVICE_TYPES[0].id); + const [showSystem, setShowSystem] = useState(false); + const [showVirtual, setShowVirtual] = useState(false); + const [showBound, setShowBound] = useState(false); + const [saving, setSaving] = useState(false); + const [saveStatus, setSaveStatus] = useState<{ ok: boolean; text: string } | null>(null); + + const importedRef = useRef(false); + const [savedConfig, setSavedConfig] = useState(null); + const [backendAvailable, setBackendAvailable] = useState(false); + + const devices = Array.from(devicesStore.devices.values()); + const allCells = devices.flatMap((device) => devicesStore.getDeviceCells(device.id)); + const cellById = new Map(allCells.map((cell) => [cell.id, cell])); + const cellByTopic = new Map(allCells.map((cell) => [cell.topic, cell.id])); + + // Проверяем, установлен ли бэкенд wb-converter-ext. Без него страница работает автономно + // («собрать → Скопировать/Скачать YAML»): кнопку «Сохранить» и подгрузку не показываем. + useEffect(() => { + let cancelled = false; + fetch('/converter-ext/status') + .then((response) => (response.ok ? response.json() : null)) + .then((status) => { + if (!cancelled && status && typeof status.has_config === 'boolean') { + setBackendAvailable(true); + } + }) + .catch(() => { + // Бэкенд недоступен — остаёмся в автономном режиме. + }); + return () => { + cancelled = true; + }; + }, []); + + // Когда бэкенд есть — забираем ранее сохранённый на контроллере конфиг. + useEffect(() => { + if (!backendAvailable) { + return; + } + let cancelled = false; + fetch('/converter-ext/config') + .then((response) => (response.ok ? response.text() : null)) + .then((text) => { + if (cancelled || !text) { + return; + } + try { + const parsed = loadYaml(text) as Config; + if (parsed && Array.isArray(parsed.devices)) { + setSavedConfig(parsed); + } + } catch { + // Битый файл не должен ломать открытие страницы. + } + }) + .catch(() => { + // Сервис недоступен — просто открываемся с пустым конфигом. + }); + return () => { + cancelled = true; + }; + }, [backendAvailable]); + + // Разворачиваем конфиг в черновики, когда контролы загрузились (иначе привязки не срослись бы). + // Один раз и только если оператор ещё ничего не набрал, чтобы не затирать правки. + useEffect(() => { + if (importedRef.current || !savedConfig || configuratorStore.devices.length > 0 || allCells.length === 0) { + return; + } + configuratorStore.setDevices(draftsFromConfig(savedConfig, cellByTopic)); + importedRef.current = true; + }, [savedConfig, allCells.length]); + + const config = buildConfig(configuratorStore.devices, cellById); + const configYaml = dumpYaml(config, { lineWidth: 120 }); + + // Скрываем шум по источнику устройства (driver из /meta → device.type) и уже привязанные контролы. + const boundCellIds = new Set( + configuratorStore.devices.flatMap((device) => Object.values(device.bindings)), + ); + const systemCount = devices.filter((device) => device.type === DeviceType.System).length; + const virtualCount = devices.filter((device) => device.type === DeviceType.Virtual).length; + const discoveryDevices = devices.filter((device) => { + if (device.type === DeviceType.System) { + return showSystem; + } + if (device.type === DeviceType.Virtual) { + return showVirtual; + } + return true; + }); + // Устройства с непустым списком контролов (после фильтра привязанных) — для показа и счётчиков. + const shownDevices = discoveryDevices + .map((device) => ({ + device, + cells: devicesStore.getDeviceCells(device.id).filter((cell) => showBound || !boundCellIds.has(cell.id)), + })) + .filter((entry) => entry.cells.length > 0); + const shownCellCount = shownDevices.reduce((sum, entry) => sum + entry.cells.length, 0); + + const typeLabel = (id: string) => typeById(id)?.name.ru ?? id; + + const handleAddDevice = () => { + configuratorStore.addDevice(typePick, typeLabel(typePick)); + }; + + const handleCopy = () => { + navigator.clipboard?.writeText(configYaml); + }; + + const handleDownload = () => { + const blob = new Blob([configYaml], { type: 'text/yaml' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'wb-convention-config.yaml'; + link.click(); + URL.revokeObjectURL(url); + }; + + // Браузер не может писать в файловую систему контроллера, поэтому конфиг сохраняет бэк: + // POST на /converter-ext/config (проксируется nginx на сервис wb-converter-ext). + const handleSave = async () => { + setSaving(true); + setSaveStatus(null); + try { + const response = await fetch('/converter-ext/config', { + method: 'POST', + headers: { 'Content-Type': 'application/yaml' }, + body: configYaml, + }); + if (response.ok) { + const result = await response.json(); + setSaveStatus({ ok: true, text: `Сохранено на контроллере — устройств: ${result.device_count}` }); + } else { + const detail = await response.text(); + setSaveStatus({ ok: false, text: `Ошибка ${response.status}: ${detail}` }); + } + } catch (error) { + setSaveStatus({ ok: false, text: `Не удалось связаться с сервисом: ${String(error)}` }); + } finally { + setSaving(false); + } + }; + + return ( + +

+ Добавьте устройство (тип) и укажите, к какому физическому прибору оно + относится — поле «Прибор». По нему адаптеры склеивают части одного прибора (у MSW + температура, влажность и CO₂ — это разные устройства с одним прибором). Затем привяжите + контролы к ролям. +

+ +
+ + Обнаружено + + {`${shownDevices.length} устройств · ${shownCellCount} контролов`} + + + +
+ + + +
+ + {shownDevices.length === 0 && ( +

Нет устройств для показа (проверьте плашки выше).

+ )} + {shownDevices.map(({ device, cells }) => ( +
+ + {device.name} + {device.id} + {`${cells.length} контролов`} + +
    + {cells.map((cell) => { + const suggestion = inferRole(cell); + const createType = defaultTypeForRole(suggestion.role); + return ( +
  • + {cell.name} + {suggestion.role} + {isAmbiguous(cell) && ( + + ? + + )} + {createType && ( + + )} +
  • + ); + })} +
+
+ ))} +
+ +
+
+
+ + +
+ + {configuratorStore.devices.length === 0 && ( +

Пока нет устройств. Добавьте по типу выше.

+ )} + + {configuratorStore.devices.map((device) => { + const type = typeById(device.type); + if (!type) { + return null; + } + const ready = isReady(device, type); + const required = requiredRoles(device, type); + const moduleMissing = !device.module.trim(); + const invalid = !ready || moduleMissing; + return ( +
+
+ {`did ${device.did}`} + configuratorStore.setName(device.did, event.target.value)} + /> + {`${type.name.ru} · ${type.id}`} + +
+ +
+ + + + {ADAPTERS.map((adapter) => ( + + ))} + +
+ + {moduleMissing && ( +

⚠ Заполните поле «Прибор» — иначе устройство не экспортируется.

+ )} + {!ready && ( +

⚠ Заполните обязательные слоты — иначе не экспортируется.

+ )} + + + + + + + + + + + + {type.slots.map((slot) => { + const def = roleDef(slot.role); + const matching = allCells.filter((cell) => matchesRole(cell, slot.role)); + const others = allCells.filter((cell) => !matchesRole(cell, slot.role)); + const value = device.bindings[slot.role] || ''; + const isRequired = required.has(slot.role); + const missing = isRequired && !value; + const boundCell = value ? cellById.get(value) : undefined; + const effRange = boundCell && def.kind === 'level' + ? resolveRange(slot.role, boundCell, device.ranges[slot.role]) + : undefined; + const applyRange = (field: 'min' | 'max' | 'step', raw: string) => { + const parsed = Number(raw); + if (!effRange || Number.isNaN(parsed)) { + return; + } + configuratorStore.setRange(device.did, slot.role, { ...effRange, [field]: parsed }); + }; + const handleBind = (cellId: string) => { + configuratorStore.bindSlot(device.did, slot.role, cellId); + const cell = cellId ? cellById.get(cellId) : undefined; + if (cell) { + configuratorStore.setModuleIfEmpty(device.did, cell.deviceId); + } + }; + return ( + + + + + + + ); + })} + +
Слот (роль)виддоступКонтрол
+ {slot.role} + {isRequired && *} + {def.kind}{def.access === 'read' ? 'ro' : 'rw'} + + {effRange && ( +
+ + + +
+ )} +
+
+ ); + })} +
+ + +
+
+ ); +}); + +export default ConfiguratorPage; diff --git a/frontend/src/pages/configurator/index.ts b/frontend/src/pages/configurator/index.ts new file mode 100644 index 000000000..b7e89ab10 --- /dev/null +++ b/frontend/src/pages/configurator/index.ts @@ -0,0 +1 @@ +export { default } from './configurator'; diff --git a/frontend/src/pages/configurator/infer.ts b/frontend/src/pages/configurator/infer.ts new file mode 100644 index 000000000..03de3fa75 --- /dev/null +++ b/frontend/src/pages/configurator/infer.ts @@ -0,0 +1,74 @@ +import type { Cell } from '@/stores/devices'; +import type { InferredRole } from './types'; + +// units → (role, kind). Единицы — из набора WB units (см. конвенцию mapping.md). +// Это прототип авто-инференса: показывает, как контрол ложится в нашу модель. +const unitRoles: Record = { + 'deg C': { role: 'temperature', kind: 'measurement' }, + '%, RH': { role: 'humidity', kind: 'measurement' }, + W: { role: 'power', kind: 'measurement' }, + kWh: { role: 'energy', kind: 'accumulation' }, + V: { role: 'voltage', kind: 'measurement' }, + mV: { role: 'voltage', kind: 'measurement' }, + A: { role: 'current', kind: 'measurement' }, + mA: { role: 'current', kind: 'measurement' }, + lx: { role: 'illuminance', kind: 'measurement' }, + Pa: { role: 'pressure', kind: 'measurement' }, + mbar: { role: 'pressure', kind: 'measurement' }, + bar: { role: 'pressure', kind: 'measurement' }, + ppm: { role: 'co2', kind: 'measurement' }, + 'm^3': { role: 'water', kind: 'accumulation' }, + 'm^3/h': { role: 'water_flow', kind: 'measurement' }, +}; + +// Специфические (устаревшие) WB-типы контролов → (role, kind). +// WB рекомендует value + units, но эти типы ещё встречаются на устройствах. +const typeRoles: Record = { + temperature: { role: 'temperature', kind: 'measurement' }, + rel_humidity: { role: 'humidity', kind: 'measurement' }, + atmospheric_pressure: { role: 'pressure', kind: 'measurement' }, + sound_level: { role: 'sound_level', kind: 'measurement' }, + rainfall: { role: 'rainfall', kind: 'measurement' }, + wind_speed: { role: 'wind_speed', kind: 'measurement' }, + power: { role: 'power', kind: 'measurement' }, + power_consumption: { role: 'energy', kind: 'accumulation' }, + voltage: { role: 'voltage', kind: 'measurement' }, + current: { role: 'current', kind: 'measurement' }, + resistance: { role: 'resistance', kind: 'measurement' }, + concentration: { role: 'co2', kind: 'measurement' }, + heat_power: { role: 'heat_power', kind: 'measurement' }, + heat_energy: { role: 'heat_energy', kind: 'accumulation' }, + water_flow: { role: 'water_flow', kind: 'measurement' }, + water_consumption: { role: 'water', kind: 'accumulation' }, +}; + +// Инференс канонической роли по WB-контролу (type + units + readonly + enum). +export function inferRole(cell: Cell): InferredRole { + if (cell.isEnum) { + return { role: 'mode', kind: 'enumeration' }; + } + + switch (cell.type as string) { + case 'switch': + return { role: cell.readOnly ? 'binary' : 'on_off', kind: 'binary_state' }; + case 'alarm': + return { role: 'alarm', kind: 'binary_state' }; + case 'pushbutton': + return cell.readOnly + ? { role: 'press', kind: 'event' } + : { role: 'command', kind: 'command' }; + case 'range': + return { role: 'level', kind: 'level' }; + case 'rgb': + return { role: 'color', kind: 'color' }; + case 'text': + return { role: 'text', kind: 'text' }; + case 'value': + return unitRoles[cell.units] ?? { role: 'value', kind: 'measurement' }; + default: + // специфический WB-тип → по имени; иначе по units; иначе числовой сенсор по умолчанию + return typeRoles[cell.type as string] + ?? unitRoles[cell.units] + ?? { role: cell.type as string, kind: 'measurement' }; + } +} diff --git a/frontend/src/pages/configurator/roles.ts b/frontend/src/pages/configurator/roles.ts new file mode 100644 index 000000000..d553db354 --- /dev/null +++ b/frontend/src/pages/configurator/roles.ts @@ -0,0 +1,43 @@ +import type { RoleDef, RoleKind } from './types'; + +// Словарь ролей — зеркало contract/get_catalog.json (roles). Вид, тип значения, доступ и +// нейтральная шкала выводятся отсюда по роли; в конфиге они не хранятся (lean-сервис). +export const ROLE_CATALOG: Record = { + on_off: { kind: 'binary_state', value_type: 'bool', access: 'readwrite' }, + brightness: { + kind: 'level', value_type: 'number', access: 'readwrite', unit: '%', + range: { min: 0, max: 100, step: 1 }, fixedScale: true, + }, + position: { + kind: 'level', value_type: 'number', access: 'readwrite', unit: '%', + range: { min: 0, max: 100, step: 1 }, fixedScale: true, + }, + color: { kind: 'color', value_type: 'color', access: 'readwrite' }, + color_temperature: { + kind: 'level', value_type: 'number', access: 'readwrite', unit: 'K', + range: { min: 2700, max: 6500, step: 100 }, + }, + temperature: { kind: 'measurement', value_type: 'number', access: 'read', unit: 'deg C' }, + humidity: { kind: 'measurement', value_type: 'number', access: 'read', unit: '%' }, + co2: { kind: 'measurement', value_type: 'number', access: 'read', unit: 'ppm' }, + illuminance: { kind: 'measurement', value_type: 'number', access: 'read', unit: 'lx' }, + power: { kind: 'measurement', value_type: 'number', access: 'read', unit: 'W' }, + energy: { kind: 'accumulation', value_type: 'number', access: 'read', unit: 'kWh' }, + contact: { kind: 'binary_state', value_type: 'bool', access: 'read' }, + motion: { kind: 'binary_state', value_type: 'bool', access: 'read' }, + occupancy: { kind: 'binary_state', value_type: 'bool', access: 'read' }, + leak: { kind: 'binary_state', value_type: 'bool', access: 'read' }, + smoke: { kind: 'binary_state', value_type: 'bool', access: 'read' }, + press: { kind: 'event', value_type: 'string', access: 'read' }, +}; + +const FALLBACK: RoleDef = { kind: 'measurement', value_type: 'number', access: 'read' }; + +// Словарь роли; для роли вне каталога — измерение только на чтение (безопасный дефолт инференса). +export function roleDef(role: string): RoleDef { + return ROLE_CATALOG[role] ?? FALLBACK; +} + +export function roleKindOf(role: string): RoleKind { + return roleDef(role).kind; +} diff --git a/frontend/src/pages/configurator/styles.css b/frontend/src/pages/configurator/styles.css new file mode 100644 index 000000000..92f125c02 --- /dev/null +++ b/frontend/src/pages/configurator/styles.css @@ -0,0 +1,440 @@ +.configurator-summary { + margin-bottom: 16px; +} + +.configurator-device { + margin-bottom: 24px; +} + +.configurator-deviceTitle { + margin: 0 0 8px; +} + +.configurator-deviceId { + color: #888; + font-weight: normal; + font-size: 0.85em; +} + +.configurator-table { + width: 100%; + border-collapse: collapse; +} + +.configurator-table th, +.configurator-table td { + padding: 4px 8px; + text-align: left; + border-bottom: 1px solid #eee; +} + +.configurator-layout { + display: flex; + gap: 24px; + align-items: flex-start; +} + +.configurator-devices { + flex: 1 1 auto; + min-width: 0; +} + +.configurator-preview { + position: sticky; + top: 16px; + flex: 0 0 380px; + max-width: 380px; +} + +.configurator-previewHead { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 8px; +} + +.configurator-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.configurator-saveButton { + font-weight: 600; + color: #fff; + background: var(--accentColor, #3c6f18); + border: 1px solid var(--accentColor, #3c6f18); + border-radius: 4px; +} +.configurator-saveButton:disabled { + opacity: 0.5; +} + +.configurator-saveStatus { + margin: 6px 0 0; + font-size: 0.85em; +} +.configurator-saveStatus.is-ok { + color: #3c6f18; +} +.configurator-saveStatus.is-error { + color: #d9534f; +} + +.configurator-previewHead h3 { + margin: 0; +} + +.configurator-empty { + color: #888; +} + +.configurator-roleSelect { + max-width: 160px; + padding: 1px 4px; +} + +.configurator-nameInput { + width: 100%; + min-width: 140px; + padding: 1px 4px; +} + +.configurator-ctrlId { + color: #888; + font-size: 0.8em; +} + +.configurator-main { + flex: 1 1 auto; + min-width: 0; +} + +.configurator-add { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.configurator-deviceHead { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 6px; +} + +.configurator-deviceType { + flex: 1 1 auto; + color: #888; + font-size: 0.85em; +} + +.configurator-deviceMeta { + display: flex; + align-items: flex-end; + gap: 8px; + margin: 0 0 8px; +} + +.configurator-field { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; +} + +.configurator-field input { + width: 100%; + padding: 1px 4px; +} + +.configurator-fieldLabel { + font-size: 0.78em; + font-weight: 600; + color: #555; +} + +.configurator-fieldHint { + font-weight: 400; + color: #999; +} + +.configurator-field.is-missing input { + border-color: #d9534f; +} + +.configurator-field.is-missing .configurator-fieldLabel { + color: #d9534f; +} + +.configurator-serviceMeta { + display: flex; + gap: 8px; + margin: 0 0 6px; +} + +.configurator-serviceMeta input { + flex: 1; + padding: 1px 4px; +} + +.configurator-propName { + display: block; + width: 100%; + margin-top: 4px; + padding: 1px 4px; + font-size: 0.8em; +} + +.configurator-device.is-invalid { + border-left: 3px solid #e0a800; + padding-left: 8px; +} + +.configurator-warn { + margin: 0 0 8px; + color: #b8860b; + font-size: 0.85em; +} + +.configurator-req { + color: #d33; + margin-left: 2px; +} + +.configurator-table tr.is-missing td { + background: #fff8e1; +} + +.configurator-slotSelect { + min-width: 220px; + padding: 1px 4px; +} + +.configurator-range { + display: flex; + gap: 10px; + margin-top: 6px; +} + +.configurator-range label { + display: inline-flex; + gap: 4px; + align-items: center; + font-size: 0.76rem; + color: #888; +} + +.configurator-range input { + width: 66px; + padding: 1px 4px; +} + +.configurator-service { + margin: 8px 0 8px 16px; + padding: 6px 0 6px 10px; + border-left: 2px solid #d0d5da; +} + +.configurator-service.is-invalid { + border-left-color: #e0a800; +} + +.configurator-serviceHead { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 4px; +} + +.configurator-addService { + display: flex; + gap: 8px; + margin: 6px 0 0 16px; +} + +.configurator-rooms { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 16px; +} + +.configurator-roomChip { + display: inline-flex; + gap: 4px; + align-items: center; + padding: 2px 6px; + background: #eef1f4; + border-radius: 4px; +} + +.configurator-roomChip input { + min-width: 90px; + padding: 1px 4px; +} + +.configurator-roomSelect { + max-width: 160px; + padding: 1px 4px; +} + +.configurator-hint { + align-self: center; + color: #888; + font-size: 0.8em; +} + +.configurator-config { + max-height: 70vh; + margin: 0; + padding: 12px; + overflow: auto; + font-size: 0.8em; + background: #f6f8fa; + border: 1px solid #e1e4e8; + border-radius: 6px; +} + +/* Панель «Обнаружено» (T1) — сворачиваемая, чтобы не занимать весь экран */ +.configurator-discovery { + margin: 12px 0 20px; + padding: 0 14px; + border: 1px solid var(--separationLineColor, #d7dde3); + border-radius: 8px; + background: var(--cardBackgroundColor, #fff); +} + +/* Заголовок всей панели — клик сворачивает/разворачивает */ +.configurator-discovery-summary { + display: flex; + align-items: baseline; + gap: 10px; + padding: 12px 0; + font-size: 15px; + font-weight: 600; + cursor: pointer; + list-style: none; +} +.configurator-discovery-summary::-webkit-details-marker { + display: none; +} +.configurator-discovery-summary::before { + content: '▸'; + align-self: center; + font-size: 12px; + opacity: 0.6; +} +.configurator-discovery[open] > .configurator-discovery-summary::before { + content: '▾'; +} +.configurator-discovery-summaryCount { + margin-left: auto; + font-size: 13px; + font-weight: 400; + opacity: 0.6; +} + +/* Ряд фильтров — виден только когда панель раскрыта */ +.configurator-discovery-filters { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + margin: 0 0 10px; + padding-left: 20px; +} +.configurator-discovery-filters label { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 13px; + cursor: pointer; +} + +/* Устройство — тоже сворачиваемое, свёрнуто по умолчанию */ +.configurator-discovery-device { + border-top: 1px solid var(--separationLineColor, #eef1f4); +} +.configurator-discovery-deviceHead { + display: flex; + align-items: baseline; + gap: 10px; + padding: 8px 0; + cursor: pointer; + list-style: none; +} +.configurator-discovery-deviceHead::-webkit-details-marker { + display: none; +} +.configurator-discovery-deviceHead::before { + content: '▸'; + align-self: center; + font-size: 11px; + opacity: 0.5; +} +.configurator-discovery-device[open] > .configurator-discovery-deviceHead::before { + content: '▾'; +} +.configurator-discovery-deviceName { + font-weight: 600; +} +.configurator-discovery-deviceId { + font-size: 12px; + opacity: 0.6; + font-family: monospace; +} +.configurator-discovery-count { + margin-left: auto; + font-size: 12px; + opacity: 0.6; +} +.configurator-discovery-cells { + margin: 2px 0 10px; + padding-left: 20px; + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + list-style: none; +} +.configurator-discovery-cell { + display: inline-flex; + align-items: baseline; + gap: 6px; + font-size: 13px; + opacity: 0.85; +} +.configurator-discovery-role { + font-size: 11px; + opacity: 0.6; + font-family: monospace; +} +.configurator-discovery-ambiguous { + align-self: center; + display: inline-flex; + width: 14px; + height: 14px; + align-items: center; + justify-content: center; + border-radius: 50%; + background: #e6a700; + color: #fff; + font-size: 10px; + font-weight: 700; +} + +/* Кнопка «создать из контрола» (T1, шаг 4) */ +.configurator-discovery-add { + margin-left: 4px; + padding: 1px 6px; + font-size: 11px; + line-height: 1.4; + border: 1px solid var(--accentColor, #3c6f18); + color: var(--accentColor, #3c6f18); + background: transparent; + border-radius: 4px; + cursor: pointer; +} +.configurator-discovery-add:hover { + background: rgba(60, 111, 24, 0.08); +} diff --git a/frontend/src/pages/configurator/templates.ts b/frontend/src/pages/configurator/templates.ts new file mode 100644 index 000000000..529c50125 --- /dev/null +++ b/frontend/src/pages/configurator/templates.ts @@ -0,0 +1,94 @@ +import type { DeviceType } from './types'; + +// Каталог типов устройств (плоская модель). Тип = набор слотов-ролей; kind/access живут в +// словаре роли (roles.ts). Зеркало contract/get_catalog.json (types). +export const DEVICE_TYPES: DeviceType[] = [ + { + id: 'switch', + name: { ru: 'Реле', en: 'Switch' }, + slots: [{ role: 'on_off', required: true }], + }, + { + id: 'outlet', + name: { ru: 'Розетка', en: 'Outlet' }, + slots: [{ role: 'on_off', required: true }], + }, + { + id: 'light', + name: { ru: 'Лампа', en: 'Light' }, + slots: [ + { role: 'on_off', required: true }, + { role: 'brightness', required: false }, + { role: 'color_temperature', required: false, requires: ['brightness'] }, + { role: 'color', required: false, requires: ['brightness'] }, + ], + }, + { + id: 'temperature_sensor', + name: { ru: 'Датчик температуры', en: 'Temperature sensor' }, + slots: [{ role: 'temperature', required: true }], + }, + { + id: 'humidity_sensor', + name: { ru: 'Датчик влажности', en: 'Humidity sensor' }, + slots: [{ role: 'humidity', required: true }], + }, + { + id: 'illuminance_sensor', + name: { ru: 'Датчик освещённости', en: 'Illuminance sensor' }, + slots: [{ role: 'illuminance', required: true }], + }, + { + id: 'co2_sensor', + name: { ru: 'Датчик CO2', en: 'CO2 sensor' }, + slots: [{ role: 'co2', required: true }], + }, + { + id: 'contact_sensor', + name: { ru: 'Датчик открытия (геркон)', en: 'Contact sensor' }, + slots: [{ role: 'contact', required: true }], + }, + { + id: 'motion_sensor', + name: { ru: 'Датчик движения', en: 'Motion sensor' }, + slots: [{ role: 'motion', required: true }], + }, + { + id: 'occupancy_sensor', + name: { ru: 'Датчик присутствия', en: 'Occupancy sensor' }, + slots: [{ role: 'occupancy', required: true }], + }, + { + id: 'leak_sensor', + name: { ru: 'Датчик протечки', en: 'Leak sensor' }, + slots: [{ role: 'leak', required: true }], + }, + { + id: 'smoke_sensor', + name: { ru: 'Датчик дыма', en: 'Smoke sensor' }, + slots: [{ role: 'smoke', required: true }], + }, + { + id: 'power_sensor', + name: { ru: 'Датчик мощности', en: 'Power sensor' }, + slots: [{ role: 'power', required: true }], + }, + { + id: 'energy_meter', + name: { ru: 'Счётчик энергии', en: 'Energy meter' }, + slots: [{ role: 'energy', required: true }], + }, + { + id: 'button', + name: { ru: 'Кнопка', en: 'Button' }, + slots: [{ role: 'press', required: true }], + }, + { + id: 'cover', + name: { ru: 'Привод / шторы', en: 'Cover' }, + slots: [ + { role: 'position', required: true }, + { role: 'on_off', required: false }, + ], + }, +]; diff --git a/frontend/src/pages/configurator/types.ts b/frontend/src/pages/configurator/types.ts new file mode 100644 index 000000000..5d16984bb --- /dev/null +++ b/frontend/src/pages/configurator/types.ts @@ -0,0 +1,90 @@ +export type RoleKind = + | 'measurement' + | 'accumulation' + | 'binary_state' + | 'level' + | 'enumeration' + | 'color' + | 'event' + | 'command' + | 'text' + | 'unknown'; + +export type Access = 'read' | 'readwrite'; + +export type ValueType = 'bool' | 'number' | 'string' | 'color'; + +export interface InferredRole { + role: string; + kind: RoleKind; +} + +// Словарь роли: вид, тип значения, доступ и нейтральная шкала. Источник kind/value_type/unit — +// роль (а не хранится в конфиге). Зеркало contract/get_catalog.json (roles). +export interface RoleDef { + kind: RoleKind; + value_type: ValueType; + access: Access; + unit?: string; + range?: Range; + // true — фиксированная шкала (яркость/позиция 0–100): range нейтральный, сырые границы → scale. + fixedScale?: boolean; +} + +// Тип устройства из каталога: набор слотов (ролей). kind/access живут в словаре роли. +export interface TypeSlot { + role: string; + required: boolean; + // условная обязательность: если этот слот привязан, перечисленные роли тоже обязательны + requires?: string[]; +} + +export interface DeviceType { + id: string; + name: { + ru: string; + en: string; + }; + slots: TypeSlot[]; +} + +export interface Range { + min: number; + max: number; + step: number; +} + +export type TransformSpec = + | { type: 'boolean' } + | { type: 'rgb_hs' } + | { type: 'scale'; from: { min: number; max: number }; to: { min: number; max: number } }; + +export interface ConfigBinding { + state: string; + command?: string; + transform?: TransformSpec; +} + +// Плоская модель: устройство (did/type/module) → сервис (sid/role/binding). +export interface ConfigService { + sid: number; + role: string; + name?: string; + range?: Range; + binding: ConfigBinding; +} + +export interface ConfigDevice { + did: number; + name: string; + type: string; + module: string; + area?: string; + adapter_settings?: Record; + services: ConfigService[]; +} + +export interface Config { + version: string; + devices: ConfigDevice[]; +} diff --git a/frontend/src/router/routes.tsx b/frontend/src/router/routes.tsx index e9702e037..8b8e820b8 100644 --- a/frontend/src/router/routes.tsx +++ b/frontend/src/router/routes.tsx @@ -11,6 +11,7 @@ const SvgDashboardPage = lazy(() => import('@/pages/dashboards/svg/[slug]/index' const EditSvgDashboardPage = lazy(() => import('@/pages/dashboards/svg/[slug]/edit')); const WidgetsPage = lazy(() => import('@/pages/dashboards/widgets')); const DevicesPage = lazy(() => import('@/pages/devices')); +const ConfiguratorPage = lazy(() => import('@/pages/configurator')); const AlicePage = lazy(() => import('@/pages/integrations/alice')); const HistoryPage = lazy(() => import('@/pages/history')); const RulesPage = lazy(() => import('@/pages/rules/index')); @@ -76,6 +77,10 @@ export const routes: RouteObject[] = [ path: 'devices', element: , }, + { + path: 'configurator', + element: , + }, { path: 'integrations', children: [ diff --git a/frontend/src/stores/configurator/configurator-store.ts b/frontend/src/stores/configurator/configurator-store.ts new file mode 100644 index 000000000..c01674c9e --- /dev/null +++ b/frontend/src/stores/configurator/configurator-store.ts @@ -0,0 +1,117 @@ +import { makeAutoObservable } from 'mobx'; +import type { DeviceDraft } from './types'; + +// did 0–9 зарезервированы (как корневой узел Matter / bridge aid в HomeKit) — старт с 10. +const FIRST_DID = 10; +const DEFAULT_ADAPTERS: Record = { matter: true, alice: false }; + +export default class ConfiguratorStore { + devices: DeviceDraft[] = []; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true }); + } + + addDevice(type: string, name: string) { + const did = Math.max(FIRST_DID - 1, ...this.devices.map((device) => device.did)) + 1; + this.devices.push({ + did, + name, + type, + module: '', + area: undefined, + adapters: { ...DEFAULT_ADAPTERS }, + bindings: {}, + ranges: {}, + }); + } + + // Discovery-first: завести устройство сразу из контрола (тип по роли, контрол привязан, module задан). + addDeviceFromControl(type: string, role: string, cellId: string, module: string, name: string) { + const did = Math.max(FIRST_DID - 1, ...this.devices.map((device) => device.did)) + 1; + this.devices.push({ + did, + name, + type, + module, + area: undefined, + adapters: { ...DEFAULT_ADAPTERS }, + bindings: { [role]: cellId }, + ranges: {}, + }); + } + + removeDevice(did: number) { + this.devices = this.devices.filter((device) => device.did !== did); + } + + setName(did: number, name: string) { + const device = this._device(did); + if (device) { + device.name = name; + } + } + + setModule(did: number, module: string) { + const device = this._device(did); + if (device) { + device.module = module; + } + } + + // Автоподсказка группировки: подставить module по контролу, если оператор его ещё не задал. + setModuleIfEmpty(did: number, module: string) { + const device = this._device(did); + if (device && !device.module.trim()) { + device.module = module; + } + } + + setArea(did: number, area: string) { + const device = this._device(did); + if (device) { + device.area = area || undefined; + } + } + + setAdapter(did: number, key: string, value: boolean) { + const device = this._device(did); + if (device) { + device.adapters[key] = value; + } + } + + bindSlot(did: number, role: string, cellId: string) { + const device = this._device(did); + if (!device) { + return; + } + if (cellId) { + device.bindings[role] = cellId; + } else { + delete device.bindings[role]; + delete device.ranges[role]; + } + } + + setRange(did: number, role: string, range: { min: number; max: number; step: number }) { + const device = this._device(did); + if (device) { + device.ranges[role] = range; + } + } + + // Заменить черновики целиком — используется при подгрузке сохранённого конфига с контроллера. + setDevices(devices: DeviceDraft[]) { + this.devices = devices; + } + + clear() { + this.devices = []; + } + + // --- Private --- + private _device(did: number): DeviceDraft | undefined { + return this.devices.find((device) => device.did === did); + } +} diff --git a/frontend/src/stores/configurator/index.ts b/frontend/src/stores/configurator/index.ts new file mode 100644 index 000000000..b642a1a1e --- /dev/null +++ b/frontend/src/stores/configurator/index.ts @@ -0,0 +1,6 @@ +import ConfiguratorStore from './configurator-store'; + +const configuratorStore = new ConfiguratorStore(); + +export { ConfiguratorStore, configuratorStore }; +export type { DeviceDraft } from './types'; diff --git a/frontend/src/stores/configurator/types.ts b/frontend/src/stores/configurator/types.ts new file mode 100644 index 000000000..9e8c919a9 --- /dev/null +++ b/frontend/src/stores/configurator/types.ts @@ -0,0 +1,13 @@ +// Черновик редактирования (плоская модель). Устройство несёт тип, группировку (module), +// настройки адаптеров и привязки ролей к контролам. Производное (binding/transform/kind/…) +// достраивает сборка (build-config), здесь только решения оператора. +export interface DeviceDraft { + did: number; // числовой id; 0–9 резерв, старт с 10, не переиспользовать + name: string; + type: string; // тип устройства из каталога + module: string; // группировка «один физ. прибор» (обяз. для экспорта) + area?: string; // комната свободным текстом (опц.) + adapters: Record; // adapter_settings: в какие цели отдавать + bindings: Record; // role → cell id + ranges: Record; // role → правка диапазона +} diff --git a/frontend/src/stores/ui/menu-items.ts b/frontend/src/stores/ui/menu-items.ts index f6c69fe0f..a8756ac32 100644 --- a/frontend/src/stores/ui/menu-items.ts +++ b/frontend/src/stores/ui/menu-items.ts @@ -186,6 +186,9 @@ export const getMenuItems = ( id: 'integrations', icon: IntegrationsIcon, isShow: hasRights(UserRole.Admin) && !params.has('fullscreen'), + children: [ + { label: 'navigation.labels.configurator', url: '/configurator' }, + ], }, { label: 'navigation.labels.history',