@iobroker/dm-gui-components 10.0.20 → 10.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/build/DeviceCard.d.ts +46 -45
- package/build/DeviceCard.js +130 -163
- package/build/DeviceCard.js.map +1 -1
- package/build/DeviceFields.d.ts +75 -0
- package/build/DeviceFields.js +190 -0
- package/build/DeviceFields.js.map +1 -0
- package/build/DeviceList.d.ts +59 -7
- package/build/DeviceList.js +260 -87
- package/build/DeviceList.js.map +1 -1
- package/build/InstanceActionButton.js +3 -1
- package/build/InstanceActionButton.js.map +1 -1
- package/build/LazyRender.d.ts +69 -0
- package/build/LazyRender.js +128 -0
- package/build/LazyRender.js.map +1 -0
- package/build/StateOrObjectHandler.d.ts +3 -0
- package/build/StateOrObjectHandler.js +57 -7
- package/build/StateOrObjectHandler.js.map +1 -1
- package/build/TooltipButton.d.ts +2 -0
- package/build/TooltipButton.js +2 -2
- package/build/TooltipButton.js.map +1 -1
- package/build/Utils.d.ts +7 -0
- package/build/Utils.js +13 -0
- package/build/Utils.js.map +1 -1
- package/build/hooks.js +22 -3
- package/build/hooks.js.map +1 -1
- package/build/i18n/de.json +1 -0
- package/build/i18n/en.json +1 -0
- package/build/i18n/es.json +1 -0
- package/build/i18n/fr.json +1 -0
- package/build/i18n/it.json +1 -0
- package/build/i18n/nl.json +1 -0
- package/build/i18n/pl.json +1 -0
- package/build/i18n/pt.json +1 -0
- package/build/i18n/ru.json +1 -0
- package/build/i18n/uk.json +1 -0
- package/build/i18n/zh-cn.json +1 -0
- package/package.json +16 -15
package/README.md
CHANGED
|
@@ -197,6 +197,18 @@ const indicator = {
|
|
|
197
197
|
-->
|
|
198
198
|
|
|
199
199
|
## Changelog
|
|
200
|
+
### 10.0.22 (2026-09-18)
|
|
201
|
+
- (@GermanBluefox) Updated packages
|
|
202
|
+
- (@GermanBluefox) Renamed `Config manager` to `Konfig-Manager` (DE)
|
|
203
|
+
- (@GermanBluefox) Text colors in toolbar was changed
|
|
204
|
+
|
|
205
|
+
### 10.0.21 (2026-08-31)
|
|
206
|
+
- (@GermanBluefox) Improved: device list performance with many devices (ioBroker/ioBroker.matter#864) — cards are only rendered while they are near the viewport, a reload no longer unmounts and remounts the whole list, and a second load requested while one is running is no longer executed in parallel
|
|
207
|
+
- (@GermanBluefox) Changed: filtering moved from the cards into the list, so filtered out devices no longer stay mounted with all their subscriptions
|
|
208
|
+
- (@GermanBluefox) Changed: the device fields that may be bound to a state or an object are resolved centrally through one shared subscription handler instead of one handler per card
|
|
209
|
+
- (@GermanBluefox) Corrected: subscriptions leaked if a component was unmounted while `addListener` was still pending
|
|
210
|
+
- (@GermanBluefox) Corrected: the device list triggered reloads and state changes as a side effect of `render()`
|
|
211
|
+
|
|
200
212
|
### 10.0.20 (2026-08-28)
|
|
201
213
|
- (@GermanBluefox) Changed the card layout
|
|
202
214
|
|
package/build/DeviceCard.d.ts
CHANGED
|
@@ -1,13 +1,31 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { PureComponent, type JSX } from 'react';
|
|
2
2
|
import { type Connection, type IobTheme, type ThemeName, type ThemeType } from '@iobroker/gui-components';
|
|
3
|
-
import type { ActionBase, ControlBase, ControlState, DeviceDetails, DeviceControl, DeviceInfo, DeviceId
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import type { ActionBase, ControlBase, ControlState, DeviceDetails, DeviceControl, DeviceInfo, DeviceId } from './protocol/api';
|
|
4
|
+
import type { StateOrObjectHandler } from './StateOrObjectHandler';
|
|
5
|
+
import type { ResolvedDeviceFields } from './DeviceFields';
|
|
6
|
+
export type { DeviceFilterField } from './DeviceFields';
|
|
7
|
+
/**
|
|
8
|
+
* Footprint of a card. The list needs it to give a not yet rendered card a placeholder of exactly
|
|
9
|
+
* the same size, so that neither the layout nor the length of the scrollbar changes.
|
|
10
|
+
*/
|
|
11
|
+
export declare const CARD_WIDTH = 300;
|
|
12
|
+
export declare const CARD_MIN_HEIGHT = 280;
|
|
13
|
+
export declare const CARD_MARGIN = 10;
|
|
14
|
+
export declare const SMALL_CARD_WIDTH = 200;
|
|
15
|
+
export declare const SMALL_CARD_MIN_HEIGHT = 200;
|
|
16
|
+
export declare const SMALL_CARD_MARGIN = 5;
|
|
6
17
|
interface DeviceCardProps {
|
|
7
|
-
filter?: string;
|
|
8
18
|
id: DeviceId;
|
|
9
19
|
identifierLabel: ioBroker.StringOrTranslated;
|
|
10
20
|
device: DeviceInfo;
|
|
21
|
+
/**
|
|
22
|
+
* The device fields resolved by the list. They may be bound to a state or an object, and the
|
|
23
|
+
* list resolves them centrally: a card that is not rendered cannot resolve its own name, and
|
|
24
|
+
* the filter of the list needs the values of all devices, not only of the rendered ones.
|
|
25
|
+
*/
|
|
26
|
+
fields: ResolvedDeviceFields;
|
|
27
|
+
/** Handler of the whole list, so that identical states/objects are only subscribed once */
|
|
28
|
+
stateOrObjectHandler: StateOrObjectHandler;
|
|
11
29
|
instanceId: string;
|
|
12
30
|
socket: Connection;
|
|
13
31
|
uploadImagesToInstance?: string;
|
|
@@ -15,63 +33,47 @@ interface DeviceCardProps {
|
|
|
15
33
|
controlHandler: (deviceId: DeviceId, control: ControlBase, state: ControlState) => () => Promise<ioBroker.State | null>;
|
|
16
34
|
controlStateHandler: (deviceId: DeviceId, control: ControlBase) => () => Promise<ioBroker.State | null>;
|
|
17
35
|
smallCards?: boolean;
|
|
36
|
+
/** The card is rendered inside a container that already has the footprint of a card */
|
|
37
|
+
fillContainer?: boolean;
|
|
18
38
|
alive: boolean;
|
|
19
39
|
themeName: ThemeName;
|
|
20
40
|
themeType: ThemeType;
|
|
21
41
|
theme: IobTheme;
|
|
22
42
|
isFloatComma: boolean;
|
|
23
43
|
dateFormat: string;
|
|
24
|
-
/** If true, only devices that have an available update are shown */
|
|
25
|
-
onlyUpdatable?: boolean;
|
|
26
|
-
/** If true, only devices that have a battery problem (empty/low battery) are shown */
|
|
27
|
-
onlyBatteryProblem?: boolean;
|
|
28
|
-
/** Device field the text filter applies to. Default `name` */
|
|
29
|
-
filterField?: DeviceFilterField;
|
|
30
44
|
/** IDs of configurable indicators the user has switched off */
|
|
31
45
|
hiddenIndicators?: string[];
|
|
32
|
-
/** Reports the resolved model value of this device up to the list (used to build the model filter dropdown) */
|
|
33
|
-
onModel?: (deviceId: DeviceId, model: string | undefined) => void;
|
|
34
46
|
}
|
|
35
47
|
interface DeviceCardState {
|
|
36
48
|
open: boolean;
|
|
37
49
|
details: DeviceDetails | null;
|
|
38
50
|
data: Record<string, any>;
|
|
39
51
|
showControlDialog: boolean;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
hasDetails?: boolean;
|
|
43
|
-
icon?: string;
|
|
44
|
-
backgroundColor?: string;
|
|
45
|
-
color?: string;
|
|
46
|
-
manufacturer?: string;
|
|
47
|
-
model?: string;
|
|
48
|
-
connectionType?: ConfigConnectionType;
|
|
49
|
-
enabled?: boolean;
|
|
50
|
-
updateAvailable?: boolean;
|
|
51
|
-
batteryProblem?: boolean;
|
|
52
|
+
/** Icon read from the file storage or picked by the user. Overrides `fields.icon` */
|
|
53
|
+
localIcon?: string;
|
|
52
54
|
}
|
|
53
55
|
/**
|
|
54
|
-
* Device Card Component
|
|
56
|
+
* Device Card Component.
|
|
57
|
+
*
|
|
58
|
+
* A `PureComponent`: the list re-renders on every loading step, on every filter change and on every
|
|
59
|
+
* resolved device value. Without the shallow property comparison all cards would be re-rendered
|
|
60
|
+
* every time, so all properties the list passes down are kept stable there.
|
|
55
61
|
*/
|
|
56
|
-
export default class DeviceCard extends
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
/** Separate subscription for the nested `device.update.available` field (used for the update indicator and the "only updatable" filter) */
|
|
60
|
-
private updateAvailableSubscription?;
|
|
61
|
-
/** Separate subscription for the nested battery status (used for the "battery problem" filter) */
|
|
62
|
-
private batteryProblemSubscription?;
|
|
62
|
+
export default class DeviceCard extends PureComponent<DeviceCardProps, DeviceCardState> {
|
|
63
|
+
/** True as long as the component is mounted; guards the asynchronous icon loading */
|
|
64
|
+
private mounted;
|
|
63
65
|
constructor(props: DeviceCardProps);
|
|
66
|
+
/**
|
|
67
|
+
* Key of the icon in the file storage, or `null` if the device brings its own icon.
|
|
68
|
+
*
|
|
69
|
+
* The file is named after manufacturer and model, both of which may be bound to a state or an
|
|
70
|
+
* object and therefore arrive only after the first render.
|
|
71
|
+
*/
|
|
72
|
+
private static iconCacheKey;
|
|
64
73
|
fetchIcon(): Promise<void>;
|
|
65
|
-
componentDidMount():
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
private getBatteryItem;
|
|
69
|
-
/** A battery problem is an explicit battery warning (`false`) or a charge level below 30 % */
|
|
70
|
-
private static isBatteryProblem;
|
|
71
|
-
private subscribeBatteryProblem;
|
|
72
|
-
private addStateOrObjectListener;
|
|
73
|
-
componentDidUpdate(prevProps: DeviceCardProps, prevState: DeviceCardState): Promise<void>;
|
|
74
|
-
componentWillUnmount(): Promise<void>;
|
|
74
|
+
componentDidMount(): void;
|
|
75
|
+
componentDidUpdate(prevProps: DeviceCardProps): void;
|
|
76
|
+
componentWillUnmount(): void;
|
|
75
77
|
/**
|
|
76
78
|
* Load the device details
|
|
77
79
|
*/
|
|
@@ -106,10 +108,9 @@ export default class DeviceCard extends Component<DeviceCardProps, DeviceCardSta
|
|
|
106
108
|
render(): JSX.Element;
|
|
107
109
|
}
|
|
108
110
|
type DeviceCardSkeletonProps = Pick<DeviceCardProps, 'smallCards' | 'theme'>;
|
|
109
|
-
export declare class DeviceCardSkeleton extends
|
|
111
|
+
export declare class DeviceCardSkeleton extends PureComponent<DeviceCardSkeletonProps> {
|
|
110
112
|
render(): JSX.Element;
|
|
111
113
|
renderSmall(): JSX.Element;
|
|
112
114
|
renderBig(): JSX.Element;
|
|
113
115
|
getCardHeaderStyle(theme: IobTheme, maxWidth?: number): React.CSSProperties;
|
|
114
116
|
}
|
|
115
|
-
export {};
|
package/build/DeviceCard.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { PureComponent } from 'react';
|
|
2
2
|
import { Close as CloseIcon, VideogameAsset as ControlIcon, MoreVert as MoreVertIcon, ExpandMore, } from '@mui/icons-material';
|
|
3
3
|
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Fab, IconButton, Paper, Skeleton, Tooltip, Typography, Accordion, AccordionSummary, AccordionDetails, } from '@mui/material';
|
|
4
|
-
import { DeviceTypeIcon,
|
|
4
|
+
import { DeviceTypeIcon, Utils, Icon, } from '@iobroker/gui-components';
|
|
5
5
|
import DeviceActionButton from './DeviceActionButton';
|
|
6
6
|
import DeviceControlComponent from './DeviceControl';
|
|
7
7
|
import DeviceImageUpload from './DeviceImageUpload';
|
|
8
8
|
import DeviceStatusComponent from './DeviceStatus';
|
|
9
9
|
import JsonConfig from './JsonConfig';
|
|
10
10
|
import { StatusIndicators } from './StatusIndicator';
|
|
11
|
-
import { getTranslation } from './Utils';
|
|
12
|
-
import { StateOrObjectHandler } from './StateOrObjectHandler';
|
|
11
|
+
import { getText, getTranslation } from './Utils';
|
|
13
12
|
/** Reserved action names (this is copied from https://github.com/ioBroker/dm-utils/blob/main/src/types/base.ts as we can only have type references to dm-utils) */
|
|
14
13
|
const ACTIONS = {
|
|
15
14
|
/** This action will be called when the user clicks on the connection icon */
|
|
@@ -21,11 +20,33 @@ const ACTIONS = {
|
|
|
21
20
|
/** This action will be called when the user clicks on the battery indicator. The battery indicator is shown only if the node status has the "battery" property */
|
|
22
21
|
BATTERY: 'battery',
|
|
23
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Footprint of a card. The list needs it to give a not yet rendered card a placeholder of exactly
|
|
25
|
+
* the same size, so that neither the layout nor the length of the scrollbar changes.
|
|
26
|
+
*/
|
|
27
|
+
export const CARD_WIDTH = 300;
|
|
28
|
+
export const CARD_MIN_HEIGHT = 280;
|
|
29
|
+
export const CARD_MARGIN = 10;
|
|
30
|
+
export const SMALL_CARD_WIDTH = 200;
|
|
31
|
+
export const SMALL_CARD_MIN_HEIGHT = 200;
|
|
32
|
+
export const SMALL_CARD_MARGIN = 5;
|
|
33
|
+
const cardSize = { width: CARD_WIDTH, minHeight: CARD_MIN_HEIGHT, margin: CARD_MARGIN };
|
|
34
|
+
const smallCardSize = {
|
|
35
|
+
width: SMALL_CARD_WIDTH,
|
|
36
|
+
minHeight: SMALL_CARD_MIN_HEIGHT,
|
|
37
|
+
margin: SMALL_CARD_MARGIN,
|
|
38
|
+
};
|
|
39
|
+
/** A card that sits in a container which already has the footprint of a card */
|
|
40
|
+
const filledCardSize = { width: '100%', margin: 0 };
|
|
41
|
+
/**
|
|
42
|
+
* Icons read from the file storage of the adapter, keyed by `<instance>/<file>`.
|
|
43
|
+
*
|
|
44
|
+
* A card is unmounted and mounted again while scrolling, and without this cache every one of those
|
|
45
|
+
* would repeat the `readFile` round trip for an icon that is very often not there at all.
|
|
46
|
+
*/
|
|
47
|
+
const fileIconCache = new Map();
|
|
24
48
|
const styles = {
|
|
25
49
|
cardStyle: (theme) => ({
|
|
26
|
-
width: 300,
|
|
27
|
-
minHeight: 280,
|
|
28
|
-
margin: '10px',
|
|
29
50
|
overflow: 'hidden',
|
|
30
51
|
display: 'flex',
|
|
31
52
|
flexDirection: 'column',
|
|
@@ -84,46 +105,62 @@ function NoImageIcon(props) {
|
|
|
84
105
|
return (React.createElement("svg", { viewBox: "0 0 24 24", width: "24", height: "24", style: props.style, className: props.className },
|
|
85
106
|
React.createElement("path", { fill: "currentColor", d: "M21.9,21.9l-8.49-8.49l0,0L3.59,3.59l0,0L2.1,2.1L0.69,3.51L3,5.83V19c0,1.1,0.9,2,2,2h13.17l2.31,2.31L21.9,21.9z M5,18 l3.5-4.5l2.5,3.01L12.17,15l3,3H5z M21,18.17L5.83,3H19c1.1,0,2,0.9,2,2V18.17z" })));
|
|
86
107
|
}
|
|
87
|
-
function getText(text) {
|
|
88
|
-
if (typeof text === 'object') {
|
|
89
|
-
return text[I18n.getLanguage()] || text.en;
|
|
90
|
-
}
|
|
91
|
-
return text;
|
|
92
|
-
}
|
|
93
108
|
/**
|
|
94
|
-
* Device Card Component
|
|
109
|
+
* Device Card Component.
|
|
110
|
+
*
|
|
111
|
+
* A `PureComponent`: the list re-renders on every loading step, on every filter change and on every
|
|
112
|
+
* resolved device value. Without the shallow property comparison all cards would be re-rendered
|
|
113
|
+
* every time, so all properties the list passes down are kept stable there.
|
|
95
114
|
*/
|
|
96
|
-
export default class DeviceCard extends
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
/** Separate subscription for the nested `device.update.available` field (used for the update indicator and the "only updatable" filter) */
|
|
100
|
-
updateAvailableSubscription;
|
|
101
|
-
/** Separate subscription for the nested battery status (used for the "battery problem" filter) */
|
|
102
|
-
batteryProblemSubscription;
|
|
115
|
+
export default class DeviceCard extends PureComponent {
|
|
116
|
+
/** True as long as the component is mounted; guards the asynchronous icon loading */
|
|
117
|
+
mounted = false;
|
|
103
118
|
constructor(props) {
|
|
104
119
|
super(props);
|
|
120
|
+
const cacheKey = DeviceCard.iconCacheKey(props);
|
|
105
121
|
this.state = {
|
|
106
122
|
open: false,
|
|
107
123
|
details: null,
|
|
108
124
|
data: {},
|
|
109
125
|
showControlDialog: false,
|
|
126
|
+
// Take a known icon over directly, so a card that is scrolled back into view does not flicker
|
|
127
|
+
localIcon: cacheKey ? fileIconCache.get(cacheKey) : undefined,
|
|
110
128
|
};
|
|
111
|
-
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Key of the icon in the file storage, or `null` if the device brings its own icon.
|
|
132
|
+
*
|
|
133
|
+
* The file is named after manufacturer and model, both of which may be bound to a state or an
|
|
134
|
+
* object and therefore arrive only after the first render.
|
|
135
|
+
*/
|
|
136
|
+
static iconCacheKey(props) {
|
|
137
|
+
if (props.device.icon) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const manufacturer = props.fields.manufacturer;
|
|
141
|
+
const model = props.fields.model;
|
|
142
|
+
const fileName = `${manufacturer ? `${manufacturer}_` : ''}${model || JSON.stringify(props.device.id)}`;
|
|
143
|
+
return `${props.instanceId.replace('system.adapter.', '')}/${fileName}.webp`;
|
|
112
144
|
}
|
|
113
145
|
async fetchIcon() {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const file = await this.props.socket.readFile(this.props.instanceId.replace('system.adapter.', ''), `${fileName}.webp`, true);
|
|
121
|
-
if (file) {
|
|
122
|
-
this.setState({ icon: `data:${file.mimeType};base64,${file.file}` });
|
|
146
|
+
const cacheKey = DeviceCard.iconCacheKey(this.props);
|
|
147
|
+
if (cacheKey) {
|
|
148
|
+
const cached = fileIconCache.get(cacheKey);
|
|
149
|
+
if (cached !== undefined) {
|
|
150
|
+
if (this.state.localIcon !== cached) {
|
|
151
|
+
this.setState({ localIcon: cached });
|
|
123
152
|
}
|
|
124
|
-
|
|
125
|
-
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const [adapter, ...rest] = cacheKey.split('/');
|
|
156
|
+
try {
|
|
157
|
+
const file = await this.props.socket.readFile(adapter, rest.join('/'), true);
|
|
158
|
+
const localIcon = file ? `data:${file.mimeType};base64,${file.file}` : '';
|
|
159
|
+
fileIconCache.set(cacheKey, localIcon);
|
|
160
|
+
if (!this.mounted) {
|
|
161
|
+
return;
|
|
126
162
|
}
|
|
163
|
+
this.setState({ localIcon });
|
|
127
164
|
// const response = await fetch(url);
|
|
128
165
|
// if (response.ok) {
|
|
129
166
|
// const blob = await response.blob();
|
|
@@ -137,89 +174,28 @@ export default class DeviceCard extends Component {
|
|
|
137
174
|
// }
|
|
138
175
|
}
|
|
139
176
|
catch {
|
|
140
|
-
|
|
141
|
-
|
|
177
|
+
fileIconCache.set(cacheKey, '');
|
|
178
|
+
if (this.mounted && this.state.localIcon) {
|
|
179
|
+
this.setState({ localIcon: '' });
|
|
142
180
|
}
|
|
143
181
|
}
|
|
144
182
|
}
|
|
145
183
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
await this.addStateOrObjectListener('hasDetails');
|
|
150
|
-
await this.addStateOrObjectListener('icon');
|
|
151
|
-
await this.addStateOrObjectListener('backgroundColor');
|
|
152
|
-
await this.addStateOrObjectListener('color');
|
|
153
|
-
await this.addStateOrObjectListener('manufacturer', getText);
|
|
154
|
-
await this.addStateOrObjectListener('model', getText);
|
|
155
|
-
await this.addStateOrObjectListener('connectionType');
|
|
156
|
-
await this.addStateOrObjectListener('enabled');
|
|
157
|
-
await this.subscribeUpdateAvailable();
|
|
158
|
-
await this.subscribeBatteryProblem();
|
|
159
|
-
await this.fetchIcon().catch(e => console.error(e));
|
|
160
|
-
}
|
|
161
|
-
async subscribeUpdateAvailable() {
|
|
162
|
-
this.updateAvailableSubscription = await this.stateOrObjectHandler.addListener(this.props.device.update?.available, value => this.setState({ updateAvailable: !!value }));
|
|
163
|
-
}
|
|
164
|
-
/** Extract the battery value (literal or state/object reference) from the device status */
|
|
165
|
-
getBatteryItem() {
|
|
166
|
-
const status = this.props.device.status;
|
|
167
|
-
if (!status || typeof status === 'string') {
|
|
168
|
-
return undefined;
|
|
169
|
-
}
|
|
170
|
-
const list = Array.isArray(status) ? status : [status];
|
|
171
|
-
for (const entry of list) {
|
|
172
|
-
if (typeof entry !== 'string' && entry.battery !== undefined) {
|
|
173
|
-
return entry.battery;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return undefined;
|
|
177
|
-
}
|
|
178
|
-
/** A battery problem is an explicit battery warning (`false`) or a charge level below 30 % */
|
|
179
|
-
static isBatteryProblem(value) {
|
|
180
|
-
if (value === false) {
|
|
181
|
-
return true;
|
|
182
|
-
}
|
|
183
|
-
return typeof value === 'number' && value < 30;
|
|
184
|
-
}
|
|
185
|
-
async subscribeBatteryProblem() {
|
|
186
|
-
this.batteryProblemSubscription = await this.stateOrObjectHandler.addListener(this.getBatteryItem(), value => this.setState({ batteryProblem: DeviceCard.isBatteryProblem(value) }));
|
|
184
|
+
componentDidMount() {
|
|
185
|
+
this.mounted = true;
|
|
186
|
+
void this.fetchIcon().catch(e => console.error(e));
|
|
187
187
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
this.
|
|
195
|
-
}
|
|
196
|
-
for (const [key, { subscription, transform }] of [...this.subscriptions]) {
|
|
197
|
-
const newItem = this.props.device[key];
|
|
198
|
-
const prevItem = prevProps.device[key];
|
|
199
|
-
if (newItem !== prevItem) {
|
|
200
|
-
console.log(`${key} of device ${JSON.stringify(this.props.device.id)} updated`, prevItem, newItem);
|
|
201
|
-
this.subscriptions.delete(key);
|
|
202
|
-
await this.addStateOrObjectListener(key, transform);
|
|
203
|
-
await subscription.unsubscribe();
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
if (this.props.device.update?.available !== prevProps.device.update?.available) {
|
|
207
|
-
await this.updateAvailableSubscription?.unsubscribe();
|
|
208
|
-
await this.subscribeUpdateAvailable();
|
|
209
|
-
}
|
|
210
|
-
if (this.props.device.status !== prevProps.device.status) {
|
|
211
|
-
await this.batteryProblemSubscription?.unsubscribe();
|
|
212
|
-
await this.subscribeBatteryProblem();
|
|
188
|
+
componentDidUpdate(prevProps) {
|
|
189
|
+
// The icon is looked up in the file storage under `<manufacturer>_<model>`. Both may be bound
|
|
190
|
+
// to a state or an object and therefore arrive only after the first render.
|
|
191
|
+
if (prevProps.fields.manufacturer !== this.props.fields.manufacturer ||
|
|
192
|
+
prevProps.fields.model !== this.props.fields.model ||
|
|
193
|
+
prevProps.device.icon !== this.props.device.icon) {
|
|
194
|
+
void this.fetchIcon().catch(e => console.error(e));
|
|
213
195
|
}
|
|
214
196
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
await subscription.unsubscribe();
|
|
218
|
-
}
|
|
219
|
-
this.subscriptions.clear();
|
|
220
|
-
await this.updateAvailableSubscription?.unsubscribe();
|
|
221
|
-
await this.batteryProblemSubscription?.unsubscribe();
|
|
222
|
-
this.props.onModel?.(this.props.device.id, undefined);
|
|
197
|
+
componentWillUnmount() {
|
|
198
|
+
this.mounted = false;
|
|
223
199
|
}
|
|
224
200
|
/**
|
|
225
201
|
* Load the device details
|
|
@@ -234,7 +210,7 @@ export default class DeviceCard extends Component {
|
|
|
234
210
|
* Copy the device ID to the clipboard
|
|
235
211
|
*/
|
|
236
212
|
copyToClipboard = () => {
|
|
237
|
-
const textToCopy = this.
|
|
213
|
+
const textToCopy = this.props.fields.identifier;
|
|
238
214
|
if (!textToCopy) {
|
|
239
215
|
return;
|
|
240
216
|
}
|
|
@@ -275,7 +251,7 @@ export default class DeviceCard extends Component {
|
|
|
275
251
|
const allControls = this.props.device.controls || [];
|
|
276
252
|
return (React.createElement(Dialog, { open: !0, onClose: () => this.setState({ showControlDialog: false }) },
|
|
277
253
|
React.createElement(DialogTitle, { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
|
|
278
|
-
this.
|
|
254
|
+
this.props.fields.name,
|
|
279
255
|
React.createElement(IconButton, { onClick: () => this.setState({ showControlDialog: false }) },
|
|
280
256
|
React.createElement(CloseIcon, null))),
|
|
281
257
|
React.createElement(DialogContent, { style: { display: 'flex', flexDirection: 'column' } }, this.renderControlItems(allControls, allControls, colors))));
|
|
@@ -348,7 +324,7 @@ export default class DeviceCard extends Component {
|
|
|
348
324
|
if (!indicators?.length && !statusActions.length) {
|
|
349
325
|
return null;
|
|
350
326
|
}
|
|
351
|
-
return (React.createElement(StatusIndicators, { indicators: indicators, theme: this.props.theme, stateOrObjectHandler: this.stateOrObjectHandler, disabled: !this.props.alive, resolveAction: this.resolveIndicatorAction, style: { marginTop: small ? 2 : 4 } }, statusActions.map(action => (React.createElement(DeviceActionButton, { disabled: !this.props.alive, key: action.id, deviceId: this.props.device.id, action: action, deviceHandler: this.props.deviceHandler })))));
|
|
327
|
+
return (React.createElement(StatusIndicators, { indicators: indicators, theme: this.props.theme, stateOrObjectHandler: this.props.stateOrObjectHandler, disabled: !this.props.alive, resolveAction: this.resolveIndicatorAction, style: { marginTop: small ? 2 : 4 } }, statusActions.map(action => (React.createElement(DeviceActionButton, { disabled: !this.props.alive, key: action.id, deviceId: this.props.device.id, action: action, deviceHandler: this.props.deviceHandler })))));
|
|
352
328
|
}
|
|
353
329
|
renderActions() {
|
|
354
330
|
const statusActionIds = this.getStatusActionIds();
|
|
@@ -363,20 +339,21 @@ export default class DeviceCard extends Component {
|
|
|
363
339
|
: Array.isArray(this.props.device.status)
|
|
364
340
|
? this.props.device.status
|
|
365
341
|
: [this.props.device.status];
|
|
366
|
-
const
|
|
342
|
+
const iconSrc = this.state.localIcon || this.props.fields.icon;
|
|
343
|
+
const icon = iconSrc ? (React.createElement(DeviceTypeIcon, { src: iconSrc, style: styles.imgStyle })) : (React.createElement(NoImageIcon, { style: styles.imgStyle }));
|
|
367
344
|
const headerStyle = this.getCardHeaderStyle(this.props.theme);
|
|
368
345
|
const title = this.state.details?.data?.name || this.props.device.name || '';
|
|
369
|
-
return (React.createElement(Paper, { style:
|
|
346
|
+
return (React.createElement(Paper, { style: this.props.fillContainer ? filledCardSize : smallCardSize, sx: styles.cardStyle },
|
|
370
347
|
React.createElement(Box, { sx: headerStyle, style: { ...styles.headerStyle, minHeight: 48 } },
|
|
371
348
|
React.createElement("div", { style: { ...styles.imgAreaStyle, height: 32, width: 32 } },
|
|
372
|
-
this.props.uploadImagesToInstance ? (React.createElement(DeviceImageUpload, { uploadImagesToInstance: this.props.uploadImagesToInstance, deviceId: this.props.device.id, manufacturer: this.
|
|
349
|
+
this.props.uploadImagesToInstance ? (React.createElement(DeviceImageUpload, { uploadImagesToInstance: this.props.uploadImagesToInstance, deviceId: this.props.device.id, manufacturer: this.props.fields.manufacturer, model: this.props.fields.model, onImageSelect: (imageData) => {
|
|
373
350
|
if (imageData) {
|
|
374
|
-
this.setState({
|
|
351
|
+
this.setState({ localIcon: imageData });
|
|
375
352
|
}
|
|
376
353
|
}, socket: this.props.socket })) : null,
|
|
377
354
|
icon),
|
|
378
|
-
React.createElement(Box, { style: { ...styles.titleStyle, fontSize: 14 }, title: title.length > 15 ? title : undefined, sx: theme => ({ color: headerStyle.color || theme.palette.secondary.contrastText }) }, this.state.details?.data?.name || this.
|
|
379
|
-
this.
|
|
355
|
+
React.createElement(Box, { style: { ...styles.titleStyle, fontSize: 14 }, title: title.length > 15 ? title : undefined, sx: theme => ({ color: headerStyle.color || theme.palette.secondary.contrastText }) }, this.state.details?.data?.name || this.props.fields.name),
|
|
356
|
+
this.props.fields.hasDetails ? (React.createElement(Fab, { disabled: !this.props.alive, size: "small", style: styles.detailsButtonStyle, onClick: () => {
|
|
380
357
|
if (!this.state.open) {
|
|
381
358
|
this.loadDetails().catch(console.error);
|
|
382
359
|
this.setState({ open: true });
|
|
@@ -384,19 +361,19 @@ export default class DeviceCard extends Component {
|
|
|
384
361
|
}, color: "primary" },
|
|
385
362
|
React.createElement(MoreVertIcon, null))) : null),
|
|
386
363
|
React.createElement("div", { style: { ...styles.statusStyle, height: 'auto', padding: '8px 15px 0 15px' } },
|
|
387
|
-
status.map((s, i) => (React.createElement(DeviceStatusComponent, { key: i, socket: this.props.socket, deviceId: this.props.device.id, connectionType: this.
|
|
364
|
+
status.map((s, i) => (React.createElement(DeviceStatusComponent, { key: i, socket: this.props.socket, deviceId: this.props.device.id, connectionType: this.props.fields.connectionType, status: s, enabled: this.props.fields.enabled, statusAction: this.props.device.actions?.find(a => a.id === ACTIONS.STATUS), disableEnableAction: this.props.device.actions?.find(a => a.id === ACTIONS.ENABLE_DISABLE), update: i === 0 ? this.props.device.update : undefined, updateAction: i === 0 ? this.props.device.actions?.find(a => a.id === ACTIONS.UPDATE) : undefined, batteryAction: this.props.device.actions?.find(a => a.id === ACTIONS.BATTERY), deviceHandler: this.props.deviceHandler, theme: this.props.theme, stateOrObjectHandler: this.props.stateOrObjectHandler }))),
|
|
388
365
|
this.renderIndicators(true)),
|
|
389
366
|
React.createElement("div", { style: styles.bodyStyle },
|
|
390
367
|
React.createElement(Typography, { variant: "body2", style: { ...styles.deviceInfoStyle, padding: '10px 10px 0 10px' } },
|
|
391
|
-
this.
|
|
368
|
+
this.props.fields.identifier ? (React.createElement("div", { onClick: this.copyToClipboard, style: { textOverflow: 'ellipsis', overflow: 'hidden' } },
|
|
392
369
|
React.createElement("b", null,
|
|
393
370
|
getText(this.props.identifierLabel),
|
|
394
371
|
":"),
|
|
395
|
-
React.createElement("span", { style: { marginLeft: 4 } }, this.
|
|
396
|
-
this.
|
|
397
|
-
React.createElement("div", null, this.
|
|
398
|
-
this.
|
|
399
|
-
React.createElement("div", null, this.
|
|
372
|
+
React.createElement("span", { style: { marginLeft: 4 } }, this.props.fields.identifier))) : null,
|
|
373
|
+
this.props.fields.manufacturer ? (React.createElement(Tooltip, { title: getTranslation('manufacturer'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
|
|
374
|
+
React.createElement("div", null, this.props.fields.manufacturer))) : null,
|
|
375
|
+
this.props.fields.model ? (React.createElement(Tooltip, { title: getTranslation('model'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
|
|
376
|
+
React.createElement("div", null, this.props.fields.model))) : null),
|
|
400
377
|
this.props.device.customInfo ? (React.createElement("div", { style: { padding: '0 10px 10px' } },
|
|
401
378
|
React.createElement(JsonConfig, { instanceId: this.props.instanceId, socket: this.props.socket, schema: this.props.device.customInfo.schema, data: this.props.device.customInfo.data || {}, onChange: (_data) => {
|
|
402
379
|
/* ignore */
|
|
@@ -417,28 +394,30 @@ export default class DeviceCard extends Component {
|
|
|
417
394
|
this.renderControlDialog()));
|
|
418
395
|
}
|
|
419
396
|
getCardHeaderStyle(theme, maxWidth) {
|
|
420
|
-
const backgroundColor = this.
|
|
397
|
+
const backgroundColor = this.props.fields.backgroundColor === 'primary'
|
|
421
398
|
? theme.palette.primary.main
|
|
422
|
-
: this.
|
|
399
|
+
: this.props.fields.backgroundColor === 'secondary'
|
|
423
400
|
? theme.palette.secondary.main
|
|
424
|
-
: this.
|
|
401
|
+
: this.props.fields.backgroundColor || theme.palette.secondary.main;
|
|
425
402
|
let color;
|
|
426
|
-
if (this.
|
|
403
|
+
if (this.props.fields.color &&
|
|
404
|
+
this.props.fields.color !== 'primary' &&
|
|
405
|
+
this.props.fields.color !== 'secondary') {
|
|
427
406
|
// Color was directly defined
|
|
428
|
-
color = this.
|
|
407
|
+
color = this.props.fields.color;
|
|
429
408
|
}
|
|
430
|
-
else if (this.
|
|
409
|
+
else if (this.props.fields.color === 'primary') {
|
|
431
410
|
color = theme.palette.primary.main;
|
|
432
411
|
}
|
|
433
|
-
else if (this.
|
|
412
|
+
else if (this.props.fields.color === 'secondary') {
|
|
434
413
|
color = theme.palette.secondary.main;
|
|
435
414
|
}
|
|
436
415
|
else {
|
|
437
416
|
// Color was not defined
|
|
438
|
-
if (this.
|
|
417
|
+
if (this.props.fields.backgroundColor === 'primary') {
|
|
439
418
|
color = theme.palette.primary.contrastText;
|
|
440
419
|
}
|
|
441
|
-
else if (this.
|
|
420
|
+
else if (this.props.fields.backgroundColor === 'secondary' || !this.props.fields.backgroundColor) {
|
|
442
421
|
color = theme.palette.secondary.contrastText;
|
|
443
422
|
}
|
|
444
423
|
else {
|
|
@@ -457,20 +436,21 @@ export default class DeviceCard extends Component {
|
|
|
457
436
|
: Array.isArray(this.props.device.status)
|
|
458
437
|
? this.props.device.status
|
|
459
438
|
: [this.props.device.status];
|
|
460
|
-
const
|
|
439
|
+
const iconSrc = this.state.localIcon || this.props.fields.icon;
|
|
440
|
+
const icon = iconSrc ? (React.createElement(DeviceTypeIcon, { src: iconSrc, style: styles.imgStyle })) : (React.createElement(NoImageIcon, { style: styles.imgStyle }));
|
|
461
441
|
const headerStyle = this.getCardHeaderStyle(this.props.theme);
|
|
462
442
|
const title = this.state.details?.data?.name || this.props.device.name || '';
|
|
463
|
-
return (React.createElement(Paper, {
|
|
443
|
+
return (React.createElement(Paper, { style: this.props.fillContainer ? filledCardSize : cardSize, sx: styles.cardStyle },
|
|
464
444
|
React.createElement(Box, { sx: headerStyle, style: styles.headerStyle },
|
|
465
445
|
React.createElement("div", { style: styles.imgAreaStyle },
|
|
466
|
-
this.props.uploadImagesToInstance ? (React.createElement(DeviceImageUpload, { uploadImagesToInstance: this.props.uploadImagesToInstance, deviceId: this.props.device.id, manufacturer: this.
|
|
446
|
+
this.props.uploadImagesToInstance ? (React.createElement(DeviceImageUpload, { uploadImagesToInstance: this.props.uploadImagesToInstance, deviceId: this.props.device.id, manufacturer: this.props.fields.manufacturer, model: this.props.fields.model, onImageSelect: (imageData) => {
|
|
467
447
|
if (imageData) {
|
|
468
|
-
this.setState({
|
|
448
|
+
this.setState({ localIcon: imageData });
|
|
469
449
|
}
|
|
470
450
|
}, socket: this.props.socket })) : null,
|
|
471
451
|
icon),
|
|
472
|
-
React.createElement(Box, { style: styles.titleStyle, title: title.length > 20 ? title : undefined, sx: theme => ({ color: headerStyle.color || theme.palette.secondary.contrastText }) }, this.state.details?.data?.name || this.
|
|
473
|
-
this.
|
|
452
|
+
React.createElement(Box, { style: styles.titleStyle, title: title.length > 20 ? title : undefined, sx: theme => ({ color: headerStyle.color || theme.palette.secondary.contrastText }) }, this.state.details?.data?.name || this.props.fields.name),
|
|
453
|
+
this.props.fields.hasDetails ? (React.createElement(Fab, { disabled: !this.props.alive, size: "small", style: styles.detailsButtonStyle, onClick: () => {
|
|
474
454
|
if (!this.state.open) {
|
|
475
455
|
this.loadDetails().catch(console.error);
|
|
476
456
|
this.setState({ open: true });
|
|
@@ -478,25 +458,25 @@ export default class DeviceCard extends Component {
|
|
|
478
458
|
}, color: "primary" },
|
|
479
459
|
React.createElement(MoreVertIcon, null))) : null),
|
|
480
460
|
React.createElement("div", { style: styles.statusStyle },
|
|
481
|
-
status.map((s, i) => (React.createElement(DeviceStatusComponent, { key: i, socket: this.props.socket, deviceId: this.props.device.id, connectionType: this.
|
|
461
|
+
status.map((s, i) => (React.createElement(DeviceStatusComponent, { key: i, socket: this.props.socket, deviceId: this.props.device.id, connectionType: this.props.fields.connectionType, status: s, enabled: this.props.fields.enabled, statusAction: this.props.device.actions?.find(a => a.id === ACTIONS.STATUS), disableEnableAction: this.props.device.actions?.find(a => a.id === ACTIONS.ENABLE_DISABLE), update: i === 0 ? this.props.device.update : undefined, updateAction: i === 0 ? this.props.device.actions?.find(a => a.id === ACTIONS.UPDATE) : undefined, batteryAction: this.props.device.actions?.find(a => a.id === ACTIONS.BATTERY), deviceHandler: this.props.deviceHandler, theme: this.props.theme, stateOrObjectHandler: this.props.stateOrObjectHandler }))),
|
|
482
462
|
this.renderIndicators()),
|
|
483
463
|
React.createElement("div", { style: styles.bodyStyle },
|
|
484
464
|
React.createElement(Typography, { variant: "body1", style: styles.deviceInfoStyle },
|
|
485
|
-
this.
|
|
465
|
+
this.props.fields.identifier ? (React.createElement("div", { onClick: this.copyToClipboard },
|
|
486
466
|
React.createElement("b", { style: { marginRight: 4 } },
|
|
487
467
|
getText(this.props.identifierLabel),
|
|
488
468
|
":"),
|
|
489
|
-
this.
|
|
490
|
-
this.
|
|
469
|
+
this.props.fields.identifier)) : null,
|
|
470
|
+
this.props.fields.manufacturer ? (React.createElement("div", null,
|
|
491
471
|
React.createElement("b", { style: { marginRight: 4 } },
|
|
492
472
|
getTranslation('manufacturer'),
|
|
493
473
|
":"),
|
|
494
|
-
this.
|
|
495
|
-
this.
|
|
474
|
+
this.props.fields.manufacturer)) : null,
|
|
475
|
+
this.props.fields.model ? (React.createElement("div", null,
|
|
496
476
|
React.createElement("b", { style: { marginRight: 4 } },
|
|
497
477
|
getTranslation('model'),
|
|
498
478
|
":"),
|
|
499
|
-
this.
|
|
479
|
+
this.props.fields.model)) : null),
|
|
500
480
|
this.props.device.customInfo ? (React.createElement("div", { style: { padding: '0 16px 16px' } },
|
|
501
481
|
React.createElement(JsonConfig, { instanceId: this.props.instanceId, socket: this.props.socket, schema: this.props.device.customInfo.schema, data: this.props.device.customInfo.data || {}, onChange: (_data) => {
|
|
502
482
|
/* ignore */
|
|
@@ -517,26 +497,13 @@ export default class DeviceCard extends Component {
|
|
|
517
497
|
this.renderControlDialog()));
|
|
518
498
|
}
|
|
519
499
|
render() {
|
|
520
|
-
if (this.props.filter) {
|
|
521
|
-
const field = this.props.filterField ?? 'name';
|
|
522
|
-
const value = String(this.state[field] ?? '').toLowerCase();
|
|
523
|
-
if (!value.includes(this.props.filter.toLowerCase())) {
|
|
524
|
-
return React.createElement(React.Fragment, null);
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
if (this.props.onlyUpdatable && !this.state.updateAvailable) {
|
|
528
|
-
return React.createElement(React.Fragment, null);
|
|
529
|
-
}
|
|
530
|
-
if (this.props.onlyBatteryProblem && !this.state.batteryProblem) {
|
|
531
|
-
return React.createElement(React.Fragment, null);
|
|
532
|
-
}
|
|
533
500
|
if (this.props.smallCards) {
|
|
534
501
|
return this.renderSmall();
|
|
535
502
|
}
|
|
536
503
|
return this.renderBig();
|
|
537
504
|
}
|
|
538
505
|
}
|
|
539
|
-
export class DeviceCardSkeleton extends
|
|
506
|
+
export class DeviceCardSkeleton extends PureComponent {
|
|
540
507
|
render() {
|
|
541
508
|
if (this.props.smallCards) {
|
|
542
509
|
return this.renderSmall();
|
|
@@ -545,7 +512,7 @@ export class DeviceCardSkeleton extends Component {
|
|
|
545
512
|
}
|
|
546
513
|
renderSmall() {
|
|
547
514
|
const headerStyle = this.getCardHeaderStyle(this.props.theme);
|
|
548
|
-
return (React.createElement(Paper, { sx: styles.cardStyle, style:
|
|
515
|
+
return (React.createElement(Paper, { sx: styles.cardStyle, style: smallCardSize },
|
|
549
516
|
React.createElement(Box, { sx: headerStyle, style: { ...styles.headerStyle, minHeight: 48 } },
|
|
550
517
|
React.createElement("div", { style: { ...styles.imgAreaStyle, height: 32, width: 32 } },
|
|
551
518
|
React.createElement(Skeleton, { variant: "rounded", width: 24, height: 24 })),
|
|
@@ -565,7 +532,7 @@ export class DeviceCardSkeleton extends Component {
|
|
|
565
532
|
}
|
|
566
533
|
renderBig() {
|
|
567
534
|
const headerStyle = this.getCardHeaderStyle(this.props.theme);
|
|
568
|
-
return (React.createElement(Paper, { sx: styles.cardStyle },
|
|
535
|
+
return (React.createElement(Paper, { sx: styles.cardStyle, style: cardSize },
|
|
569
536
|
React.createElement(Box, { sx: headerStyle, style: styles.headerStyle },
|
|
570
537
|
React.createElement("div", { style: styles.imgAreaStyle },
|
|
571
538
|
React.createElement(Skeleton, { variant: "rounded", width: 24, height: 24 })),
|