Skip to content

Commit e57e49b

Browse files
committed
feat: add support for group exposes
1 parent b04e673 commit e57e49b

File tree

30 files changed

+272
-82
lines changed

30 files changed

+272
-82
lines changed

src/components/device/DeviceCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const DeviceCard = memo(
3636
featureWrapperClass,
3737
children,
3838
}: Props) => {
39-
const { t } = useTranslation(["zigbee", "devicePage"]);
39+
const { t } = useTranslation(["zigbee", "common"]);
4040
const endpointName = endpoint != null ? device.endpoints[endpoint]?.name : undefined;
4141
const displayedFeatures = useMemo(() => {
4242
const elements: JSX.Element[] = [];
@@ -99,7 +99,7 @@ const DeviceCard = memo(
9999
</div>
100100
<div className="flex flex-row justify-end mb-2">
101101
<Link to={`/device/${sourceIdx}/${device.ieee_address}/exposes`} className="btn btn-xs">
102-
{t(($) => $.exposes, { ns: "devicePage" })} <FontAwesomeIcon icon={faRightLong} size="lg" />
102+
{t(($) => $.exposes, { ns: "common" })} <FontAwesomeIcon icon={faRightLong} size="lg" />
103103
</Link>
104104
</div>
105105
</div>
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { useCallback, useMemo } from "react";
2+
import { useTranslation } from "react-i18next";
3+
import { useShallow } from "zustand/react/shallow";
4+
import { useAppStore } from "../../../store.js";
5+
import type { FeatureWithAnySubFeatures, Group } from "../../../types.js";
6+
import { sendMessage } from "../../../websocket/WebSocketManager.js";
7+
import Feature from "../../features/Feature.js";
8+
import FeatureWrapper from "../../features/FeatureWrapper.js";
9+
import { getFeatureKey } from "../../features/index.js";
10+
11+
type ExposesProps = {
12+
sourceIdx: number;
13+
group: Group;
14+
};
15+
16+
/**
17+
* Use non-clashing Map keys.
18+
* NOTE: fallback to `type` because despite the ZHC typing, `name` isn't actually always defined
19+
*/
20+
const getFeatureId = (feature: FeatureWithAnySubFeatures) =>
21+
feature.property ? `__property_${feature.property}` : feature.name ? `__name_${feature.name}` : `__type_${feature.type}`;
22+
23+
/**
24+
* Recursively find common features to a reference set (including sub-features if any).
25+
* Returns a list of cloned features, with possible sub-features.
26+
*/
27+
const findCommonFeatures = (
28+
refFeatures: FeatureWithAnySubFeatures[],
29+
otherFeatureMaps: Map<string, FeatureWithAnySubFeatures>[],
30+
): FeatureWithAnySubFeatures[] => {
31+
const common: FeatureWithAnySubFeatures[] = [];
32+
33+
for (const refFeature of refFeatures) {
34+
const refId = getFeatureId(refFeature);
35+
const matchingFeatures: FeatureWithAnySubFeatures[] = [];
36+
37+
// check if feature exists in all other devices
38+
for (const otherFeatureMap of otherFeatureMaps) {
39+
const match = otherFeatureMap.get(refId);
40+
41+
if (!match) {
42+
continue;
43+
}
44+
45+
matchingFeatures.push(match);
46+
}
47+
48+
if (matchingFeatures.length === 0) {
49+
continue;
50+
}
51+
52+
// clone the feature
53+
const commonFeature = { ...refFeature };
54+
55+
// if feature has sub-features, recursively find common ones
56+
if ("features" in refFeature && refFeature.features.length > 0) {
57+
const otherSubFeaturesMaps: Map<string, FeatureWithAnySubFeatures>[] = [];
58+
59+
for (const matchingFeature of matchingFeatures) {
60+
if ("features" in matchingFeature) {
61+
otherSubFeaturesMaps.push(new Map((matchingFeature.features as FeatureWithAnySubFeatures[]).map((f) => [getFeatureId(f), f])));
62+
}
63+
}
64+
65+
const commonSubFeatures = findCommonFeatures(refFeature.features as FeatureWithAnySubFeatures[], otherSubFeaturesMaps);
66+
67+
// only include feature if it has common sub-features
68+
if (commonSubFeatures.length > 0) {
69+
// check is superfluous, already done in wrapping if, just with the original (only required by typing)
70+
if ("features" in commonFeature) {
71+
commonFeature.features = commonSubFeatures;
72+
}
73+
74+
common.push(commonFeature);
75+
}
76+
} else {
77+
common.push(commonFeature);
78+
}
79+
}
80+
81+
return common;
82+
};
83+
84+
export default function Exposes({ sourceIdx, group }: ExposesProps) {
85+
const { t } = useTranslation("common");
86+
const devices = useAppStore(useShallow((state) => state.devices[sourceIdx]));
87+
const deviceScenesFeatures = useAppStore(useShallow((state) => state.deviceScenesFeatures[sourceIdx]));
88+
const firstMember = group.members.length > 0 ? group.members[0] : undefined;
89+
const refDevice = firstMember ? devices.find((d) => d.ieee_address === firstMember.ieee_address) : undefined;
90+
const refEndpointName = refDevice && firstMember ? refDevice.endpoints[firstMember.endpoint]?.name : undefined;
91+
92+
const groupData = useMemo(() => {
93+
if (!refDevice) {
94+
return [];
95+
}
96+
97+
const refFeatures = deviceScenesFeatures[refDevice.ieee_address] ?? [];
98+
99+
if (refFeatures.length === 0) {
100+
return [];
101+
}
102+
103+
const refEndpointFeatures = refFeatures.filter(
104+
// XXX: show if feature has no endpoint?
105+
(f) => !f.endpoint || Number(f.endpoint) === firstMember?.endpoint || f.endpoint === refEndpointName,
106+
);
107+
108+
if (group.members.length === 1) {
109+
return refEndpointFeatures;
110+
}
111+
112+
const otherMembersFeatures: Map<string, FeatureWithAnySubFeatures>[] = [];
113+
114+
for (let i = 1; i < group.members.length; i++) {
115+
const member = group.members[i];
116+
const memberFeatures = deviceScenesFeatures[member.ieee_address];
117+
118+
if (!memberFeatures || memberFeatures.length === 0) {
119+
return [];
120+
}
121+
122+
const memberEndpointFeaturesMap = new Map<string, FeatureWithAnySubFeatures>();
123+
124+
for (const memberFeature of memberFeatures) {
125+
// XXX: show if feature has no endpoint?
126+
if (
127+
!memberFeature.endpoint ||
128+
Number(memberFeature.endpoint) === member.endpoint ||
129+
memberFeature.endpoint === refDevice.endpoints[member.endpoint]?.name
130+
) {
131+
memberEndpointFeaturesMap.set(getFeatureId(memberFeature), memberFeature);
132+
}
133+
}
134+
135+
otherMembersFeatures.push(memberEndpointFeaturesMap);
136+
}
137+
138+
return findCommonFeatures(refEndpointFeatures, otherMembersFeatures);
139+
}, [firstMember?.endpoint, refDevice, refEndpointName, deviceScenesFeatures, group.members]);
140+
141+
const onChange = useCallback(
142+
async (value: Record<string, unknown>) => {
143+
await sendMessage<"{friendlyNameOrId}/set">(
144+
sourceIdx,
145+
// @ts-expect-error templated API endpoint
146+
`${group.id}/set`,
147+
value,
148+
);
149+
},
150+
[sourceIdx, group.id],
151+
);
152+
153+
const onRead = useCallback(
154+
async (value: Record<string, unknown>) => {
155+
await sendMessage<"{friendlyNameOrId}/get">(
156+
sourceIdx,
157+
// @ts-expect-error templated API endpoint
158+
`${group.id}/get`,
159+
value,
160+
);
161+
},
162+
[sourceIdx, group.id],
163+
);
164+
165+
return refDevice && groupData.length > 0 ? (
166+
<div className="list bg-base-100">
167+
{groupData.map((expose) => (
168+
<Feature
169+
key={getFeatureKey(expose)}
170+
feature={expose}
171+
device={refDevice}
172+
deviceState={{}}
173+
onChange={onChange}
174+
onRead={onRead}
175+
featureWrapperClass={FeatureWrapper}
176+
parentFeatures={[]}
177+
/>
178+
))}
179+
</div>
180+
) : (
181+
t(($) => $.empty_exposes_definition)
182+
);
183+
}

src/i18n/locales/bg.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@
9696
"mismatching_types": "Несъвпадащи типове",
9797
"sync": "Синхр.",
9898
"clear_self_as_source": "Премахни себе си като източник",
99-
"sync_reporting": "Синхр. отчитане"
99+
"sync_reporting": "Синхр. отчитане",
100+
"exposes": "Експонирани функции"
100101
},
101102
"devicePage": {
102103
"about": "Относно",
@@ -109,7 +110,6 @@
109110
"state": "Състояние",
110111
"groups": "Групи",
111112
"scene": "Сцена",
112-
"exposes": "Експонирани функции",
113113
"select_a_device": "Изберете устройство"
114114
},
115115
"groups": {
@@ -572,4 +572,4 @@
572572
"definitions-group-qos": "QoS група",
573573
"definitions-group-off_state": "Кога OFF/CLOSE"
574574
}
575-
}
575+
}

src/i18n/locales/ca.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@
9696
"mismatching_types": "Tipus no coincidents",
9797
"sync": "Sincron.",
9898
"clear_self_as_source": "Esborra't com a origen",
99-
"sync_reporting": "Sincron. informes"
99+
"sync_reporting": "Sincron. informes",
100+
"exposes": "Funcions exposades"
100101
},
101102
"devicePage": {
102103
"about": "Quant a",
@@ -109,7 +110,6 @@
109110
"state": "Estat",
110111
"groups": "Grups",
111112
"scene": "Escena",
112-
"exposes": "Funcions exposades",
113113
"select_a_device": "Seleccioneu un dispositiu"
114114
},
115115
"groups": {
@@ -572,4 +572,4 @@
572572
"definitions-group-qos": "QoS grup",
573573
"definitions-group-off_state": "Quan publicar OFF/CLOSE"
574574
}
575-
}
575+
}

src/i18n/locales/cs.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@
9696
"mismatching_types": "Nesouhlasící typy",
9797
"sync": "Synchron.",
9898
"clear_self_as_source": "Odebrat sebe jako zdroj",
99-
"sync_reporting": "Synchron. reportování"
99+
"sync_reporting": "Synchron. reportování",
100+
"exposes": "Hlavní přehled"
100101
},
101102
"devicePage": {
102103
"about": "O zařízení",
@@ -109,7 +110,6 @@
109110
"state": "Stav",
110111
"groups": "Skupiny",
111112
"scene": "Scény",
112-
"exposes": "Hlavní přehled",
113113
"select_a_device": "Vybrat zařízení"
114114
},
115115
"groups": {
@@ -572,4 +572,4 @@
572572
"definitions-group-qos": "Úroveň QoS (Quality of Service) pro MQTT zprávy této skupiny.",
573573
"definitions-group-off_state": "Určuje, kdy má být pro skupinu publikován stav OFF nebo CLOSE. all_members_off: pouze pokud jsou všechna zařízení ve stavu OFF/CLOSE. last_member_state: pokud alespoň jedno zařízení přejde do stavu OFF."
574574
}
575-
}
575+
}

src/i18n/locales/da.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@
9696
"mismatching_types": "Uoverensstemmende typer",
9797
"sync": "Synk.",
9898
"clear_self_as_source": "Fjern dig selv som kilde",
99-
"sync_reporting": "Synk. rapportering"
99+
"sync_reporting": "Synk. rapportering",
100+
"exposes": "Eksponerede funktioner"
100101
},
101102
"devicePage": {
102103
"about": "Om",
@@ -109,7 +110,6 @@
109110
"state": "Tilstand",
110111
"groups": "Grupper",
111112
"scene": "Scene",
112-
"exposes": "Eksponerede funktioner",
113113
"select_a_device": "Vælg en enhed"
114114
},
115115
"groups": {
@@ -572,4 +572,4 @@
572572
"definitions-group-qos": "Gruppe QoS",
573573
"definitions-group-off_state": "Hvornår OFF/CLOSE"
574574
}
575-
}
575+
}

src/i18n/locales/de.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@
9696
"mismatching_types": "Nicht übereinstimmende Typen",
9797
"sync": "Synch.",
9898
"clear_self_as_source": "Dich selbst als Quelle entfernen",
99-
"sync_reporting": "Synch. Berichte"
99+
"sync_reporting": "Synch. Berichte",
100+
"exposes": "Expose-Funktionen"
100101
},
101102
"devicePage": {
102103
"about": "Info",
@@ -109,7 +110,6 @@
109110
"state": "Status",
110111
"groups": "Gruppen",
111112
"scene": "Szene",
112-
"exposes": "Expose-Funktionen",
113113
"select_a_device": "Ein Gerät auswählen"
114114
},
115115
"groups": {
@@ -572,4 +572,4 @@
572572
"definitions-group-qos": "QoS-Level für MQTT-Nachrichten dieser Gruppe",
573573
"definitions-group-off_state": "Steuerung wann OFF/CLOSE für eine Gruppe veröffentlicht wird"
574574
}
575-
}
575+
}

src/i18n/locales/en.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@
9696
"mismatching_types": "Mismatching types",
9797
"sync": "Sync",
9898
"clear_self_as_source": "Clear self as source",
99-
"sync_reporting": "Sync reporting"
99+
"sync_reporting": "Sync reporting",
100+
"exposes": "Exposes"
100101
},
101102
"devicePage": {
102103
"about": "About",
@@ -109,7 +110,6 @@
109110
"state": "State",
110111
"groups": "Groups",
111112
"scene": "Scene",
112-
"exposes": "Exposes",
113113
"select_a_device": "Select a device"
114114
},
115115
"groups": {
@@ -574,4 +574,4 @@
574574
"definitions-group-off_state": "Control when to publish state OFF or CLOSE for a group. 'all_members_off': only publish state OFF/CLOSE when all group members are in state OFF/CLOSE, 'last_member_state': publish state OFF whenever one of its members changes to OFF",
575575
"definitions-group-homeassistant-name": "Name of the group in Home Assistant"
576576
}
577-
}
577+
}

src/i18n/locales/es.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@
9696
"mismatching_types": "Tipos no coincidentes",
9797
"sync": "Sincron.",
9898
"clear_self_as_source": "Quitarme como fuente",
99-
"sync_reporting": "Sincron. informes"
99+
"sync_reporting": "Sincron. informes",
100+
"exposes": "Funciones expuestas"
100101
},
101102
"devicePage": {
102103
"about": "Acerca de",
@@ -109,7 +110,6 @@
109110
"state": "Estado",
110111
"groups": "Grupos",
111112
"scene": "Escena",
112-
"exposes": "Funciones expuestas",
113113
"select_a_device": "Seleccione un dispositivo"
114114
},
115115
"groups": {
@@ -572,4 +572,4 @@
572572
"definitions-group-qos": "QoS para este grupo",
573573
"definitions-group-off_state": "Controlar cuándo publicar estado OFF/CLOSE"
574574
}
575-
}
575+
}

0 commit comments

Comments
 (0)