Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
wb-mqtt-homeui (2.250.0) stable; urgency=medium

* Add the device configurator page (Integrations -> Device configurator)

-- Victor Fedorov <victor.fedorov@wirenboard.com> 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
Expand Down
67 changes: 61 additions & 6 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"list": "List",
"devices": "Devices",
"integrations": "Integrations",
"configurator": "Device configurator",
"alice": "Yandex Alice",
"widgets": "Widgets",
"history": "History",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"list": "Список",
"devices": "Устройства",
"integrations": "Интеграции",
"configurator": "Конфигуратор устройств",
"alice": "Яндекс Алиса",
"widgets": "Виджеты",
"history": "История",
Expand Down
177 changes: 177 additions & 0 deletions frontend/src/pages/configurator/build-config.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string> {
const roles = new Set<string>();
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<string, string>): DeviceDraft[] {
return config.devices.map((device) => {
const bindings: Record<string, string> = {};
const ranges: Record<string, Range> = {};
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<string, Cell>): 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;
}
Loading