Skip to content
Open
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 custom device template management

-- Victor Vedenin <victor.vedenin@wirenboard.com> Fri, 28 Aug 2026 15:19:00 +0300

wb-mqtt-homeui (2.249.0) stable; urgency=medium

* Show the device template parameter variant matching the device firmware
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,13 @@
"delete-title": "Confirmation of deletion",
"notice": "For the selected device, the current settings will be applied, including port communication parameters. If the device is in bootloader mode, the latest firmware will be installed.",
"not-polled": "The device is not being polled",
"user-defined": "Custom"
"user-defined": "Custom",
"upload-template-title": "Upload template",
"upload-template-warning": "After uploading the template, the current configuration will be saved automatically",
"template-in-use-upload-title": "Template in use",
"confirm-template-in-use-upload": "The current configuration contains devices using template \"{{item}}\". Their settings will be displayed using the new template.",
"template-in-use-delete-title": "Template in use",
"confirm-template-in-use-delete": "The current configuration contains devices using template \"{{item}}\". Deleting it will revert them to the standard template."
},
"buttons": {
"save": "Save config",
Expand All @@ -515,7 +521,11 @@
"update": "Update",
"parameters": "Parameters",
"reread-config": "Reread config",
"switch-template": "Switch template"
"switch-template": "Switch template",
"upload-template": "Upload custom template",
"delete-template": "Delete custom template",
"overwrite-template": "Overwrite",
"template-actions": "Template"
},
"errors": {
"write":"Error writing config. See wb-mqtt-confed logs for details",
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,13 @@
"uncommitted-settings": "Настройки этого устройства изменены и ещё не сохранены. Если перечитать настройки, ваши изменения будут потеряны и заменены значениями из устройства.",
"delete-title": "Подтверждение удаления",
"not-polled": "Устройство не опрашивается",
"user-defined": "Пользовательский"
"user-defined": "Пользовательский",
"upload-template-title": "Загрузка шаблона",
"upload-template-warning": "После загрузки шаблона текущая конфигурация будет сохранена автоматически",
"template-in-use-upload-title": "Шаблон используется",
"confirm-template-in-use-upload": "В текущей конфигурации есть устройства с шаблоном \"{{item}}\". Их настройки будут отображаться по новому шаблону.",
"template-in-use-delete-title": "Шаблон используется",
"confirm-template-in-use-delete": "В текущей конфигурации есть устройства с шаблоном \"{{item}}\". При удалении они вернутся на штатный шаблон."
},
"buttons": {
"save": "Сохранить настройки",
Expand All @@ -512,7 +518,11 @@
"update": "Обновить",
"parameters": "Параметры",
"reread-config": "Перечитать настройки",
"switch-template": "Переключить шаблон"
"switch-template": "Переключить шаблон",
"upload-template": "Загрузить пользовательский шаблон",
"delete-template": "Удалить пользовательский шаблон",
"overwrite-template": "Перезаписать",
"template-actions": "Шаблон"
},
"errors": {
"write": "Ошибка при сохранении настроек. Более детальная информация находится в журнале работы сервиса wb-mqtt-confed",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { observer } from 'mobx-react-lite';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import CopyIcon from '@/assets/icons/copy.svg';
import RefreshIcon from '@/assets/icons/refresh.svg';
import SettingsIcon from '@/assets/icons/settings.svg';
import SpinnerIcon from '@/assets/icons/spinner.svg';
import TrashIcon from '@/assets/icons/trash.svg';
import { Alert } from '@/components/alert';
import { Button } from '@/components/button';
import { Dropdown, type Option } from '@/components/dropdown';
import { OptionalParamsSelectDialog } from '@/components/json-schema-editor';
import { Loader } from '@/components/loader';
import { Popup } from '@/components/popup';
import { Tooltip } from '@/components/tooltip';
import { EmbeddedSoftwarePanel, DeviceSettingsEditor } from '@/pages/settings/device-manager';
import { UnknownDeviceTabContent } from '../unknown-device-tab';
import { ReadRegistersResultAlert } from './read-registers-result-alert';
Expand Down Expand Up @@ -71,18 +78,25 @@ const LoaderPanel = ({ message }: { message: string }) => {
export const DeviceTabContent = observer(
({
tab,
deviceTypeSelectOptions,
isUserDefinedType,
onDeleteTab,
onCopyTab,
deviceTypeSelectOptions,
onDeviceTypeChange,
onSetUniqueMqttTopic,
onSearchDisconnectedDevice,
onUpdateFirmware,
onUpdateBootloader,
onUpdateComponents,
onReadRegisters,
onDeleteTemplate,
onUploadTemplate,
templateOperationPending,
templateError,
onClearTemplateError,
}: DeviceTabContentProps) => {
const [optionalParamsSelectDialogIsOpen, openOptionalParamsSelectDialog] = useState(false);
const [isTemplateMenuOpen, setIsTemplateMenuOpen] = useState(false);
const { t } = useTranslation();
if (tab.isLoading) {
return <LoaderPanel message={tab.loadingMessage} />;
Expand Down Expand Up @@ -129,41 +143,103 @@ export const DeviceTabContent = observer(
)}
<ReadRegistersResultAlert tab={tab} onDeviceTypeChange={onDeviceTypeChange} onReadRegisters={onReadRegisters} />
<div className="deviceTab-contentHeader">
<Dropdown
options={deviceTypeSelectOptions}
value={tab.deviceType}
className="deviceTab-contentHeaderSelect"
isSearchable={true}
onChange={(option: Option<string>) => onDeviceTypeChange(tab, option.value)}
/>
<div className="deviceTab-typeGroup">
<Dropdown
options={deviceTypeSelectOptions}
value={tab.deviceType}
className="deviceTab-contentHeaderSelect"
isSearchable={true}
onChange={(option: Option<string>) => onDeviceTypeChange(tab, option.value)}
/>
{onUploadTemplate && (
<Popup
className="deviceTab-templateMenu"
isOpen={isTemplateMenuOpen}
placement="bottom-end"
content={
<ul className="deviceTab-templateMenuList">
<li>
<button
className="deviceTab-templateMenuItem"
onClick={() => {
setIsTemplateMenuOpen(false); onUploadTemplate();
}}
>
{t('device-manager.buttons.upload-template')}
</button>
</li>
{isUserDefinedType && onDeleteTemplate && (
<li>
<button
className="deviceTab-templateMenuItem deviceTab-templateMenuItem--danger"
onClick={() => {
setIsTemplateMenuOpen(false); onDeleteTemplate();
}}
>
{t('device-manager.buttons.delete-template')}
</button>
</li>
)}
</ul>
}
onOpenChange={setIsTemplateMenuOpen}
>
<button
className="deviceTab-templateMenuBtn"
disabled={templateOperationPending}
>
{templateOperationPending && (
<SpinnerIcon className="deviceTab-templateMenuBtnSpinner" />
)}
{t('device-manager.buttons.template-actions')}
</button>
</Popup>
)}
</div>
<div className="deviceTab-contentHeaderButtons">
{!tab.withSubdevices && tab.readRegistersState.allowEditSettings && (
<Tooltip text={t('device-manager.buttons.parameters')}>
<Button
aria-label={t('device-manager.buttons.parameters')}
variant="secondary"
icon={<SettingsIcon />}
aria-haspopup="dialog"
onClick={() => openOptionalParamsSelectDialog(!optionalParamsSelectDialogIsOpen)}
/>
</Tooltip>
)}
<Tooltip text={t('device-manager.buttons.delete')}>
<Button
label={t('device-manager.buttons.parameters')}
aria-label={t('device-manager.buttons.delete')}
icon={<TrashIcon />}
variant="danger"
aria-haspopup="dialog"
onClick={() => openOptionalParamsSelectDialog(!optionalParamsSelectDialogIsOpen)}
onClick={onDeleteTab}
/>
)}
<Button
label={t('device-manager.buttons.delete')}
variant="danger"
aria-haspopup="dialog"
onClick={onDeleteTab}
/>
</Tooltip>
{!tab.withSubdevices && tab.readRegistersState.allowEditSettings && (
<Tooltip text={t('device-manager.buttons.copy')}>
<Button
aria-label={t('device-manager.buttons.copy')}
icon={<CopyIcon />}
aria-haspopup="dialog"
onClick={onCopyTab}
/>
</Tooltip>
)}
<Tooltip text={t('device-manager.buttons.reread-config')}>
<Button
label={t('device-manager.buttons.copy')}
aria-label={t('device-manager.buttons.reread-config')}
icon={<RefreshIcon />}
aria-haspopup="dialog"
onClick={onCopyTab}
onClick={() => onReadRegisters(tab, true)}
/>
)}
<Button
label={t('device-manager.buttons.reread-config')}
aria-haspopup="dialog"
onClick={() => onReadRegisters(tab, true)}
/>
</Tooltip>
</div>
</div>
{!!templateError && (
<Alert variant="danger" size="small" onClose={onClearTemplateError}>{templateError}</Alert>
)}
{tab.schemaStore && tab.readRegistersState.allowEditSettings && (
<DeviceSettingsEditor store={tab.schemaStore} translator={tab.schemaStore.schemaTranslator} />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,107 @@
flex-wrap: wrap;
}

.deviceTab-contentHeaderSelect {
.deviceTab-typeGroup {
display: flex;
align-items: stretch;
flex-basis: 45%;
}

.deviceTab-typeGroup .deviceTab-contentHeaderSelect {
flex: 1;
min-width: 0;
}

.deviceTab-typeGroup .deviceTab-contentHeaderSelect .dropdown__control {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 0;
}

.deviceTab-templateMenuBtn {
all: unset;
display: flex;
align-items: center;
gap: 4px;
padding: 0 10px;
border: 1px solid var(--input-border-color);
border-radius: 0 var(--input-border-radius) var(--input-border-radius) 0;
background: var(--input-background);
cursor: pointer;
color: var(--gray-color);
flex-shrink: 0;
font-size: var(--font-size-small);
white-space: nowrap;
}

.deviceTab-templateMenuBtn:hover:not(:disabled) {
background: var(--light-gray-color);
color: var(--text-color);
}

.deviceTab-templateMenuBtn:disabled {
cursor: default;
opacity: 0.7;
}

.deviceTab-templateMenuBtn svg {
width: 16px;
height: 16px;
}

.deviceTab-templateMenuBtnSpinner {
animation: spin 1s linear infinite;
}

@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

.deviceTab-templateMenu {
padding: 4px;
}

.deviceTab-templateMenuList {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}

.deviceTab-templateMenuItem {
all: unset;
display: flex;
align-items: center;
width: 100%;
box-sizing: border-box;
padding: 6px 10px;
border-radius: var(--input-border-radius);
cursor: pointer;
white-space: nowrap;
}

.deviceTab-templateMenuItem:hover {
background-color: var(--light-gray-color);
}

.deviceTab-templateMenuItem:focus {
box-shadow: var(--input-active-shadow);
}

.deviceTab-templateMenuItem--danger {
color: var(--danger-color);
}

.deviceTab-contentHeaderButtons {
display: flex;
gap: 5px;
}

@media (max-width: 1280px) {
.deviceTab-contentHeaderSelect {
.deviceTab-typeGroup {
flex-grow: 1;
min-width: 10px;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,22 @@ import { type DeviceTypeDropdownOptionGroup } from '@/stores/device-manager/type

export interface DeviceTabContentProps {
tab: DeviceTabStore;
deviceTypeSelectOptions: DeviceTypeDropdownOptionGroup[];
isUserDefinedType?: boolean;
onDeleteTab: () => void;
onCopyTab: () => Promise<void>;
deviceTypeSelectOptions: DeviceTypeDropdownOptionGroup[];
onDeviceTypeChange: (tab: DeviceTabStore, newType: string) => void;
onSetUniqueMqttTopic: (topic: string) => void;
onSearchDisconnectedDevice: () => void;
onUpdateFirmware: () => void;
onUpdateBootloader: () => void;
onUpdateComponents: () => void;
onReadRegisters: (tab: DeviceTabStore, isForce?: boolean) => void;
onDeleteTemplate?: () => void;
onUploadTemplate?: () => void;
templateOperationPending?: boolean;
templateError?: string;
onClearTemplateError?: () => void;
}

export interface ReadRegistersResultAlertProps {
Expand Down
Loading