Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
bc84cc3
feat(dali): live device controls on the configurator's device page
evgeny-boger Aug 29, 2026
524ca72
fix(dali): don't label device controls twice — the cell widget brings…
evgeny-boger Aug 29, 2026
bfe4f8a
feat(dali): make the device-controls section collapsible
evgeny-boger Aug 29, 2026
5496f22
feat(dali): pin the featured controls; bottom sheet on phones
evgeny-boger Aug 29, 2026
d907d46
feat(dali): name the page, and collapse repeated error rows in the mo…
evgeny-boger Aug 29, 2026
c69af23
feat(cell): let a host without a broker switch off topic copying
evgeny-boger Aug 29, 2026
fdeab8a
feat(dali): shared page toolbar; live controls on bus and group pages…
evgeny-boger Aug 29, 2026
89a3d06
feat(dali): live strips that read back, and guards for what the host …
evgeny-boger Aug 30, 2026
a0421c7
fix(dali): act on the layout audit — blank group tab, hidden labels, …
evgeny-boger Aug 30, 2026
1873dbe
feat(dali): an open monitor panel with nothing monitored now says so
evgeny-boger Aug 30, 2026
b5e48e0
fix(dali): act on the multi-aspect review — one seam, typed shapes, t…
evgeny-boger Aug 30, 2026
8a3ccc3
chore: bump version to 2.250.0
evgeny-boger Aug 30, 2026
fcb0d13
fix(dali): let the device-action buttons wrap on phone widths
evgeny-boger Aug 30, 2026
1026b50
feat(dali): hide the MQTT id editor on hosts without an external broker
evgeny-boger Aug 31, 2026
095f7f0
fix(dali): stop caching an empty GetGroup answer as the group's confi…
evgeny-boger Aug 31, 2026
9025410
Merge origin/master into feat/dali-device-controls
evgeny-boger Aug 31, 2026
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
14 changes: 14 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
wb-mqtt-homeui (2.250.0) stable; urgency=medium

* DALI: live device controls on device, group and bus pages — featured strip
with merged setpoint/readback pairs, "All controls" grid, phone bottom sheet
* DALI: event-scheme warning banner with a one-click fix for DALI-2 devices
whose events cannot be attributed (factory addressing scheme)
* DALI: bus monitor — per-bus console tabs, collapsed repeated error rows,
an explanatory empty state with per-bus enable buttons
* DALI: host-capability gating for embedding hosts (syslog toggle, topic copy)
* Console panel: optional empty-state content; collapsible panel: whole-row
toggle (fixes double-toggle on chevron clicks in Chrome)

-- Wiren Board Robot <info@wirenboard.com> Mon, 31 Aug 2026 20:00:00 +0300

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

* Add a note under the DALI bus monitor switch: the gateway has to be
Expand Down
33 changes: 20 additions & 13 deletions frontend/src/components/cell/cell-alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Tooltip } from '@/components/tooltip';
import { CellError } from '@/stores/devices/cell-type';
import { copyToClipboard } from '@/utils/clipboard';
import { CellHistory } from './cell-history';
import { topicCopyPolicy } from './topic-copy-policy';
import { type CellAlertProps } from './types';
import './styles.css';

Expand All @@ -15,22 +16,28 @@ export const CellAlert = observer(({ cell, name, hideHistory }: CellAlertProps)
? 'gray'
: cell.value ? 'danger' : 'success';

const alert = (
<Alert
size="small"
variant={variant}
className="deviceCell-alert"
onClick={topicCopyPolicy.enabled ? () => copyToClipboard(cell.id) : undefined}
>
{name || cell.name}
</Alert>
);

return (
<>
<Tooltip
text={<span><b>'{cell.id}'</b> {t('widget.labels.copy')}</span>}
placement="top-start"
trigger="click"
>
<Alert
size="small"
variant={variant}
className="deviceCell-alert"
onClick={() => copyToClipboard(cell.id)}
{topicCopyPolicy.enabled ? (
<Tooltip
text={<span><b>'{cell.id}'</b> {t('widget.labels.copy')}</span>}
placement="top-start"
trigger="click"
>
{name || cell.name}
</Alert>
</Tooltip>
{alert}
</Tooltip>
) : alert}

{!hideHistory && <CellHistory cell={cell} />}
</>
Expand Down
29 changes: 20 additions & 9 deletions frontend/src/components/cell/cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Tooltip } from '@/components/tooltip';
import { CellComponent } from '@/stores/devices';
import { CellError } from '@/stores/devices/cell-type';
import { copyToClipboard } from '@/utils/clipboard';
import { topicCopyPolicy } from './topic-copy-policy';
import { type CellProps } from './types';
import './styles.css';

Expand Down Expand Up @@ -59,15 +60,11 @@ export const CellContent = observer(({ cell, name, isCompact, isReadOnly, extra,
},
)}
>
{!isCompact && ![CellComponent.Alert, CellComponent.Button].includes(cell.displayType) && (
<Tooltip
text={<span><b>'{cell.id}'</b> {t('widget.labels.copy')}</span>}
placement="top-start"
trigger="click"
>
{!isCompact && ![CellComponent.Alert, CellComponent.Button].includes(cell.displayType) && (() => {
const nameBlock = (
<div
className="deviceCell-name"
onClick={() => copyToClipboard(cell.id)}
onClick={topicCopyPolicy.enabled ? () => copyToClipboard(cell.id) : undefined}
>
{cell.error?.includes(CellError.Period) && (
<Suspense>
Expand All @@ -85,8 +82,22 @@ export const CellContent = observer(({ cell, name, isCompact, isReadOnly, extra,
<CellHistory cell={cell} />
)}
</div>
</Tooltip>
)}
);
// Without a broker to use the topic in, neither the copy nor its
// "copied to clipboard" tooltip has a point — see topicCopyPolicy.
if (!topicCopyPolicy.enabled) {
return nameBlock;
}
return (
<Tooltip
text={<span><b>'{cell.id}'</b> {t('widget.labels.copy')}</span>}
placement="top-start"
trigger="click"
>
{nameBlock}
</Tooltip>
);
})()}
{isCompact && !hideHistory && cell.displayType === CellComponent.Range && (
<CellHistory cell={cell} />
)}
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/components/cell/topic-copy-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Whether clicking a cell's name copies its MQTT topic id.
*
* On a controller the id is the topic a rule or a dashboard would use, so the
* copy is worth the click. A host with no broker — the standalone WASM
* device editor drives cells from an in-browser loopback — has nowhere to
* paste it, and "copied to clipboard" there is just a puzzling toast. Such a
* host switches this off once at startup.
*/
export const topicCopyPolicy = {
enabled: true,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from '@testing-library/react';
import { CollapsiblePanel } from './collapsible-panel';

describe('CollapsiblePanel: the whole title row toggles, exactly once per click', () => {
it('clicking the title text collapses and expands the body', () => {
render(<CollapsiblePanel title="Broadcast settings"><div>body</div></CollapsiblePanel>);
expect(screen.getByText('body')).toBeTruthy();
fireEvent.click(screen.getByText('Broadcast settings'));
expect(screen.queryByText('body')).toBeNull();
fireEvent.click(screen.getByText('Broadcast settings'));
expect(screen.getByText('body')).toBeTruthy();
});

it('the toggle row is not a <label> — Chrome forwards label clicks into the button and toggles twice', () => {
const { container } = render(<CollapsiblePanel title="T">x</CollapsiblePanel>);
expect(container.querySelector('label.collapsiblePanel-label')).toBeNull();
expect(container.querySelector('div.collapsiblePanel-label')).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ export const CollapsiblePanel = ({ title, isCollapsed = false, children }) => {

return (
<section className="collapsiblePanel-container">
<label className="collapsiblePanel-label">
{/* Not a <label>: a label forwards clicks on its text to the button, but
Chrome also forwards a click that lands on the button's own icon, so a
click on the chevron toggled twice — i.e. did nothing. One handler on
the row covers text, empty space, and the button (keyboard included:
Enter/Space on the button fire a click that bubbles here). */}
<div className="collapsiblePanel-label" onClick={() => setCollapsed(!collapsed)}>
<Button
className="collapsiblePanel-button"
variant="secondary"
Expand All @@ -21,10 +26,9 @@ export const CollapsiblePanel = ({ title, isCollapsed = false, children }) => {
aria-labelledby={titleId}
aria-label={collapsed ? t('common.buttons.expand') : t('common.buttons.collapse')}
icon={collapsed ? <ChevronRightIcon /> : <ChevronDownIcon />}
onClick={() => setCollapsed(!collapsed)}
/>
<span id={titleId}>{title}</span>
</label>
</div>
{!collapsed && <div>{children}</div>}
</section>
);
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/console-panel/console-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ import { Tabs } from '@/components/tabs';
import { Tooltip } from '@/components/tooltip';
import { consolePanelStore as store } from '@/stores/console-panel';
import { getOverflowIds } from './get-overflow-ids';
import type { ConsolePanelProps } from './types';
import './styles.css';

const OVERFLOW_BTN_SPACE = 26;

export const ConsolePanel = observer(() => {
export const ConsolePanel = observer(({ emptyState }: ConsolePanelProps = {}) => {
const { t } = useTranslation();
const isMobile = useMediaQuery({ maxWidth: 768 });
const container = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -348,7 +349,7 @@ export const ConsolePanel = observer(() => {
</div>
</header>

{activeTab && <activeTab.renderContent />}
{activeTab ? <activeTab.renderContent /> : emptyState}
</aside>
);
});
6 changes: 6 additions & 0 deletions frontend/src/components/console-panel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ export interface ConsoleLogScrollerProps {
scrollKey: number | string;
children: ReactNode;
}

export interface ConsolePanelProps {
/** Shown in the content area while no tab is registered — an open panel with
nothing to say otherwise reads as a rendering bug. */
emptyState?: ReactNode;
}
15 changes: 13 additions & 2 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1095,7 +1095,16 @@
"monitor-col-command": "Command",
"monitor-col-response": "Response",
"monitor-foreign": "foreign",
"monitor-broadcast": "Broadcast"
"monitor-broadcast": "Broadcast",
"all-controls": "All controls",
"event-scheme-warning": "Some instances use an event addressing scheme that does not name the sending device, so their events cannot update the controls. Switch them to \"device short and instance number\".",
"group-load-failed": "Could not read the group settings — the bus may be busy.",
"group-members-initializing": "The group's devices are still being initialized — the settings will appear as soon as one member has been read.",
"monitor-empty-hint": "The bus monitor is off, so no frames are being captured. Enable it for a bus below, or with the Bus Monitor toggle at the bottom of that bus's tab:",
"monitor-repeats": "repeated {{count}} times",
"state-on": "on",
"state-off": "off",
"monitor-repeats_one": "repeated {{count}} time"
},
"buttons": {
"rescan": "Rescan",
Expand All @@ -1113,7 +1122,9 @@
"reset-confirm": "Reset",
"run": "Run",
"commands": "Commands…",
"retry": "Retry"
"retry": "Retry",
"fix-event-schemes": "Set recommended scheme",
"retry-load": "Retry"
}
}
}
17 changes: 15 additions & 2 deletions frontend/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1124,7 +1124,18 @@
"monitor-col-command": "Команда",
"monitor-col-response": "Ответ",
"monitor-foreign": "сторонний",
"monitor-broadcast": "Широковещательные"
"monitor-broadcast": "Широковещательные",
"all-controls": "Все элементы управления",
"event-scheme-warning": "Некоторые компоненты используют схему адресации событий, не содержащую адрес устройства, поэтому их события не могут обновлять контролы. Переключите их на схему «короткий адрес устройства и номер компонента».",
"group-load-failed": "Не удалось прочитать настройки группы — возможно, шина занята.",
"group-members-initializing": "Устройства группы ещё инициализируются — настройки появятся, как только будет прочитан хотя бы один участник.",
"monitor-empty-hint": "Монитор шины выключен, кадры не записываются. Включите его для нужной шины ниже или переключателем «Монитор шины» внизу вкладки этой шины:",
"monitor-repeats": "повторено {{count}} раз",
"state-on": "вкл",
"state-off": "выкл",
"monitor-repeats_one": "повторено {{count}} раз",
"monitor-repeats_few": "повторено {{count}} раза",
"monitor-repeats_many": "повторено {{count}} раз"
},
"buttons": {
"rescan": "Пересканировать",
Expand All @@ -1142,7 +1153,9 @@
"reset-confirm": "Сбросить",
"run": "Выполнить",
"commands": "Команды…",
"retry": "Повторить"
"retry": "Повторить",
"fix-event-schemes": "Установить рекомендуемую схему",
"retry-load": "Повторить"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,13 @@
height: 180px;
}

.dali-busCommands-catalogButton .button-text {
display: none;
}

.dali-busCommands-results .wb-tableWrapper {
overflow-x: visible;
}
}

/* At phone widths the flex row squeezed the catalog button into a label-less
pill; buttons here keep their intrinsic width and wrap instead. */
.dali-busCommands-toolbar .button {
flex-shrink: 0;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const formatHexBytes = (hex: string) => hex.match(/.{1,2}/g)?.join(' ') ?? hex;
* it with the address filter); memoised on the raw line so unchanged rows skip
* re-rendering as new entries arrive.
*/
export const BusMonitorRow = memo(({ frame }: { frame: ParsedBusMonitorLine }) => {
export const BusMonitorRow = memo(({ frame, repeat = 1 }: { frame: ParsedBusMonitorLine; repeat?: number }) => {
const { t } = useTranslation();
const { direction, response, badges } = frame;

Expand All @@ -30,6 +30,11 @@ export const BusMonitorRow = memo(({ frame }: { frame: ParsedBusMonitorLine }) =
</span>
)}
{badges.fromLunatone && <span className="daliMonitor-badge">lunatone</span>}
{repeat > 1 && (
<span className="daliMonitor-repeatBadge" title={t('dali.labels.monitor-repeats', { count: repeat })}>
×{repeat}
</span>
)}
</span>
<span className="daliMonitor-response">
{response.kind === 'error' && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { parseBusMonitorLine } from '@/stores/dali/parse-bus-monitor-line';
import type { ParsedBusMonitorLine } from '@/stores/dali/types';
import { downloadFile } from '@/utils/download';
import { BusMonitorHeader, BusMonitorRow } from './bus-monitor-row';
import { collapseErrorRows } from './collapse-error-rows';
import { ConsoleMenu } from './console-menu';
import type { BusMonitorTabProps } from './types';
import './styles.css';
Expand Down Expand Up @@ -137,10 +138,12 @@ export const DaliBusMonitorContent = observer(({ monitorStore }: { monitorStore:
})
: frames;

const rows = collapseErrorRows(visible);

return (
<ConsoleLogScroller scrollKey={visible.length}>
<BusMonitorHeader />
{visible.map((frame, i) => <BusMonitorRow key={i} frame={frame} />)}
{rows.map((row, i) => <BusMonitorRow key={i} frame={row.frame} repeat={row.repeat} />)}
</ConsoleLogScroller>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { ParsedBusMonitorLine } from '@/stores/dali/types';
import { collapseErrorRows } from './collapse-error-rows';

const line = (over: Record<string, any> = {}): ParsedBusMonitorLine => ({
time: '12:00:00',
hex: 'a3fe',
command: 'QueryActualLevel(0)',
direction: 'out',
badges: {},
...over,
response: { kind: 'error', text: 'no response', ...(over.response ?? {}) },
} as ParsedBusMonitorLine);

describe('collapseErrorRows: identical consecutive errors fold into one ×N row', () => {
it('counts a run of identical errors once, keeping the latest frame', () => {
const rows = collapseErrorRows([
line({ time: '12:00:00' }),
line({ time: '12:00:01' }),
line({ time: '12:00:02' }),
]);
expect(rows).toHaveLength(1);
expect(rows[0].repeat).toBe(3);
expect(rows[0].frame.time).toBe('12:00:02');
});

it('a differing hex, command or response text breaks the run', () => {
expect(collapseErrorRows([line(), line({ hex: 'a3ff' })])).toHaveLength(2);
expect(collapseErrorRows([line(), line({ command: 'Other' })])).toHaveLength(2);
expect(collapseErrorRows([line(), line({ response: { text: 'framing error' } })])).toHaveLength(2);
});

it('ordinary traffic never collapses, even when byte-identical', () => {
const ok = line({ response: { kind: 'value', text: '42' } });
const rows = collapseErrorRows([ok, ok]);
expect(rows).toHaveLength(2);
expect(rows.every((row) => row.repeat === 1)).toBe(true);
});
});
Loading