diff --git a/debian/changelog b/debian/changelog index 20c062be0..9d81078c7 100644 --- a/debian/changelog +++ b/debian/changelog @@ -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 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 diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9fb850218..1e91ccbff 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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": { diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 1bba1e560..992ae4093 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -1124,6 +1124,8 @@ "monitor-col-command": "Команда", "monitor-col-response": "Ответ", "monitor-foreign": "сторонний", + "group-load-failed": "Не удалось прочитать настройки группы — возможно, шина занята.", + "group-members-initializing": "Устройства группы ещё инициализируются — настройки появятся, как только будет прочитан хотя бы один участник.", "monitor-broadcast": "Широковещательные" }, "buttons": { diff --git a/frontend/src/pages/settings/configs/dali/components/group-tab-content/group-tab-content.tsx b/frontend/src/pages/settings/configs/dali/components/group-tab-content/group-tab-content.tsx index fb076e14d..b0567baec 100644 --- a/frontend/src/pages/settings/configs/dali/components/group-tab-content/group-tab-content.tsx +++ b/frontend/src/pages/settings/configs/dali/components/group-tab-content/group-tab-content.tsx @@ -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'; @@ -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 (
@@ -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 ( + +
+ {t('dali.labels.group-load-failed')} +
+
+ ); + } + + if (store.isAwaitingMembers) { + return {t('dali.labels.group-members-initializing')}; } const params = store.objectStore.params.filter((p) => !p.hidden); diff --git a/frontend/src/pages/settings/configs/dali/components/group-tab-content/styles.css b/frontend/src/pages/settings/configs/dali/components/group-tab-content/styles.css index dab5a1df4..1bfc19e74 100644 --- a/frontend/src/pages/settings/configs/dali/components/group-tab-content/styles.css +++ b/frontend/src/pages/settings/configs/dali/components/group-tab-content/styles.css @@ -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; +} diff --git a/frontend/src/stores/dali/group-store-awaiting-members.test.ts b/frontend/src/stores/dali/group-store-awaiting-members.test.ts new file mode 100644 index 000000000..c5678ef48 --- /dev/null +++ b/frontend/src/stores/dali/group-store-awaiting-members.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/stores/dali/group-store.test.ts b/frontend/src/stores/dali/group-store.test.ts index 4ccd5e0b6..631d6ef10 100644 --- a/frontend/src/stores/dali/group-store.test.ts +++ b/frontend/src/stores/dali/group-store.test.ts @@ -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(); diff --git a/frontend/src/stores/dali/group-store.ts b/frontend/src/stores/dali/group-store.ts index a411b7f7b..10f4ebc40 100644 --- a/frontend/src/stores/dali/group-store.ts +++ b/frontend/src/stores/dali/group-store.ts @@ -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; @@ -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);