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
9 changes: 9 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
wb-mqtt-homeui (2.250.3) stable; urgency=medium

* DALI group page: an empty GetGroup answer (no member initialized yet) is no
longer cached as the group's configuration — the page says the members are
initializing and re-asks every 3 s; a failed GetGroup shows a retry instead of
a blank page

-- Wiren Board Robot <info@wirenboard.com> Mon, 31 Aug 2026 20:30: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
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,8 @@
"monitor-col-command": "Command",
"monitor-col-response": "Response",
"monitor-foreign": "foreign",
"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-broadcast": "Broadcast"
},
"buttons": {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,8 @@
"monitor-col-command": "Команда",
"monitor-col-response": "Ответ",
"monitor-foreign": "сторонний",
"group-load-failed": "Не удалось прочитать настройки группы — возможно, шина занята.",
"group-members-initializing": "Устройства группы ещё инициализируются — настройки появятся, как только будет прочитан хотя бы один участник.",
"monitor-broadcast": "Широковещательные"
},
"buttons": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import classNames from 'classnames';
import { observer } from 'mobx-react-lite';
import { type CSSProperties } from 'react';
import { type CSSProperties, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert } from '@/components/alert';
import { Button } from '@/components/button';
import { JsonSchemaEditor } from '@/components/json-schema-editor';
import { Loader } from '@/components/loader';
Expand Down Expand Up @@ -51,6 +52,20 @@ const GroupParam = observer(({ store, param }: { store: GroupStore; param: Objec
});

export const GroupTabContent = observer(({ store }: { store: GroupStore }) => {
const { t } = useTranslation();

// While the members are still initializing the daemon has no parameters to
// offer; keep asking — the form fills itself in as soon as one member is
// read, with no reload required.
const awaitingMembers = store.isAwaitingMembers;
useEffect(() => {
if (!awaitingMembers) {
return undefined;
}
const timer = window.setInterval(() => store.load(), 3000);
return () => window.clearInterval(timer);
}, [awaitingMembers, store]);

if (store.isLoading) {
return (
<div className="dali-contentLoader">
Expand All @@ -60,7 +75,21 @@ export const GroupTabContent = observer(({ store }: { store: GroupStore }) => {
}

if (!store.objectStore) {
return null;
// The one way here is a failed GetGroup — a busy bus can time the RPC
// out. Rendering nothing looked like a blank page with no way back; say
// what happened and offer the retry.
return (
<Alert variant="warn">
<div className="dali-groupLoadFailed">
<span>{t('dali.labels.group-load-failed')}</span>
<Button label={t('dali.buttons.retry')} onClick={() => store.load()} />
</div>
</Alert>
);
}

if (store.isAwaitingMembers) {
return <Alert variant="info">{t('dali.labels.group-members-initializing')}</Alert>;
}

const params = store.objectStore.params.filter((p) => !p.hidden);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,12 @@
.dali-groupParam-editor .input:not(.dali-color-temperature-slider-input) {
width: 100%;
}

/* The failed-load alert: message and its retry on one line, wrapping when narrow. */
.dali-groupLoadFailed {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
39 changes: 39 additions & 0 deletions frontend/src/stores/dali/group-store-awaiting-members.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { daliProxyMock } from '@/test/mocks/services';
import { GroupStore } from './group-store';

vi.mock('@/services', () => import('@/test/mocks/services'));
vi.mock('@/stores/json-schema-editor', () => import('@/test/mocks/json-schema-editor'));
vi.mock('@/utils/format-error', () => import('@/test/mocks/format-error'));

describe('GroupStore keeps asking while the group members initialize', () => {
let store: GroupStore;

beforeEach(() => {
vi.clearAllMocks();
store = new GroupStore('bus1_g5', 5, { dropDeviceCaches: vi.fn() } as any);
});

test('an empty GetGroup answer is not cached: the next load asks again', async () => {
// While no member device has finished initializing, GetGroup merges over
// nothing and legitimately answers an empty schema. Caching it froze the
// tab on "controls only" for the whole session.
daliProxyMock.GetGroup.mockResolvedValue({});
await store.load();
expect(store.isAwaitingMembers).toBe(true);
expect(store.objectStore).toBeDefined();

daliProxyMock.GetGroup.mockResolvedValue({ properties: { min_level: {} } });
await store.load();
expect(daliProxyMock.GetGroup).toHaveBeenCalledTimes(2);
expect(store.isAwaitingMembers).toBe(false);
});

test('a loaded group stays loaded: no refetch once parameters arrived', async () => {
daliProxyMock.GetGroup.mockResolvedValue({ properties: { min_level: {} } });
await store.load();
vi.clearAllMocks();

await store.load();
expect(daliProxyMock.GetGroup).not.toHaveBeenCalled();
});
});
4 changes: 3 additions & 1 deletion frontend/src/stores/dali/group-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ describe('GroupStore', () => {
});

test('skips if already loaded', async () => {
daliProxyMock.GetGroup.mockResolvedValue({ config: {}, schema: {} });
// A non-empty schema: an empty one means "members still initializing"
// and is deliberately not cached (see the awaiting-members test file).
daliProxyMock.GetGroup.mockResolvedValue({ properties: { min_level: {} } });
await store.load();
vi.clearAllMocks();

Expand Down
14 changes: 13 additions & 1 deletion frontend/src/stores/dali/group-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,20 @@ export class GroupStore extends BaseItemStore {
saveParam: action,
isLoading: observable,
error: observable,
isAwaitingMembers: observable,
});
}

/**
* GetGroup merges parameters over the group's members that have finished
* initializing; while none has, it legitimately answers an empty schema.
* That answer is a moment, not a fact — it must never be cached as the
* group's real (absent) configuration.
*/
isAwaitingMembers = false;

async load() {
if (this.objectStore) {
if (this.objectStore && !this.isAwaitingMembers) {
return;
}
this.isLoading = true;
Expand All @@ -36,6 +45,9 @@ export class GroupStore extends BaseItemStore {
this.translator.addTranslations(schema.translations);
this.objectStore = new ObjectStore(schema, {}, false, new StoreBuilder());
this.objectStore.setDefault();
runInAction(() => {
this.isAwaitingMembers = !Object.keys((data as unknown as { properties?: object })?.properties ?? {}).length;
});
this.setError(null);
} catch (error) {
this.setError(error);
Expand Down