Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
4 changes: 3 additions & 1 deletion front/src/components/boxs/device-in-room/DeviceCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const isLightBinaryFeature = (feature, featureSelectors) => {
};

const DeviceCard = ({ children, ...props }) => {
const { boxTitle, roomLightStatus, loading, deviceFeatures = [], box = {} } = props;
const { boxTitle, roomLightStatus, loading, deviceFeatures = [], lastStateChanges = {}, box = {} } = props;
const { device_features: featureSelectors = [] } = box;

const hasAtLeastTwoLightBinaryFeature = countLightBinaryFeature(deviceFeatures, featureSelectors) >= 2;
Expand Down Expand Up @@ -70,6 +70,8 @@ const DeviceCard = ({ children, ...props }) => {
y={props.y}
device={deviceFeature.device}
deviceFeature={deviceFeature}
displayLastStateChange={box.display_last_state_change === true}
lastStateChange={lastStateChanges[deviceFeature.selector]}
roomIndex={props.roomIndex}
deviceFeatureIndex={deviceFeatureIndex}
updateValue={props.updateValue}
Expand Down
4 changes: 4 additions & 0 deletions front/src/components/boxs/device-in-room/DeviceRow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ const DeviceRow = ({ children, ...props }) => {
user={props.user}
device={device}
deviceFeature={deviceFeature}
displayLastStateChange={props.displayLastStateChange}
lastStateChange={props.lastStateChange}
rowName={rowName}
intl={props.intl}
/>
Expand All @@ -147,6 +149,8 @@ const DeviceRow = ({ children, ...props }) => {
user={props.user}
device={device}
deviceFeature={deviceFeature}
displayLastStateChange={props.displayLastStateChange}
lastStateChange={props.lastStateChange}
rowName={rowName}
intl={props.intl}
/>
Expand Down
104 changes: 92 additions & 12 deletions front/src/components/boxs/device-in-room/DevicesBox.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import debounce from 'debounce';
const isTextSelectFeature = feature =>
feature.category === DEVICE_FEATURE_CATEGORIES.TEXT && feature.type === DEVICE_FEATURE_TYPES.TEXT.SELECT;

// Any read-only binary feature (opening sensor, motion sensor, presence, leak...) can display
// the date at which its current state was reached.
const isBinarySensorFeature = feature =>
feature.read_only === true && feature.type === DEVICE_FEATURE_TYPES.SENSOR.BINARY;

const updateDeviceFeatures = (deviceFeatures, deviceFeatureSelector, lastValue, lastValueChange) => {
return deviceFeatures.map(feature => {
if (feature.selector === deviceFeatureSelector) {
Expand All @@ -39,6 +44,39 @@ const updateDeviceFeatures = (deviceFeatures, deviceFeatureSelector, lastValue,
});
};

// A new state is a state change only when the value actually differs from the one displayed,
// so a sensor re-publishing the same value never resets the displayed date.
const getUpdatedLastStateChanges = (
lastStateChanges,
deviceFeatures,
deviceFeatureSelector,
lastValue,
lastValueChange
) => {
const feature = deviceFeatures.find(
f => f.selector === deviceFeatureSelector && isBinarySensorFeature(f) && f.last_value !== lastValue
);
if (!feature) {
return lastStateChanges;
}
return { ...lastStateChanges, [deviceFeatureSelector]: lastValueChange };
};

// A websocket state change can land while the history request is in flight: the fetched snapshot
// was taken before it, so replacing the whole map would put back a date older than the one already
// displayed. Each selector keeps the most recent of the two dates instead.
const mergeLastStateChanges = (currentLastStateChanges, fetchedLastStateChanges) => {
const mergedLastStateChanges = { ...currentLastStateChanges };
Object.keys(fetchedLastStateChanges).forEach(selector => {
const currentDate = mergedLastStateChanges[selector];
const fetchedDate = fetchedLastStateChanges[selector];
if (!currentDate || (fetchedDate && new Date(fetchedDate) > new Date(currentDate))) {
mergedLastStateChanges[selector] = fetchedDate;
}
});
return mergedLastStateChanges;
};

const updateDeviceFeaturesString = (deviceFeatures, deviceFeatureSelector, lastValueString, lastValueChange) => {
return deviceFeatures.map(feature => {
if (feature.selector === deviceFeatureSelector) {
Expand All @@ -57,6 +95,7 @@ class DevicesComponent extends Component {
super(props);
this.state = {
deviceFeatures: [],
lastStateChanges: {},
status: RequestStatus.Getting
};
this.wasDisconnected = false;
Expand Down Expand Up @@ -102,27 +141,63 @@ class DevicesComponent extends Component {
deviceFeatures: deviceFeaturesSorted,
status: RequestStatus.Success
});
this.getLastStateChanges(deviceFeaturesSorted);
} catch (e) {
this.setState({
status: RequestStatus.Error
});
}
};

updateDeviceStateWebsocket = payload => {
let { deviceFeatures } = this.state;
if (deviceFeatures) {
deviceFeatures = updateDeviceFeatures(
deviceFeatures,
payload.device_feature_selector,
payload.last_value,
payload.last_value_changed
);
this.setState({
deviceFeatures
// `last_value_changed` is refreshed on every state report, even when the device re-publishes
// the value it already had, so the real date of the last state change is asked to the server,
// which reads it from the state history.
getLastStateChanges = async deviceFeatures => {
if (!this.props.box.display_last_state_change) {
return;
}
const binarySensorSelectors = deviceFeatures.filter(isBinarySensorFeature).map(feature => feature.selector);
if (binarySensorSelectors.length === 0) {
return;
}
try {
const lastStateChanges = await this.props.httpClient.get('/api/v1/device_feature/last_state_changes', {
device_feature_selectors: binarySensorSelectors.join(',')
});
this.setState(previousState => ({
lastStateChanges: mergeLastStateChanges(previousState.lastStateChanges, lastStateChanges)
}));
} catch (e) {
console.error(e);
}
};

// Read through a functional update: the history request can resolve in between, and its merged
// result would be clobbered by a map computed from an older state.
updateDeviceStateWebsocket = payload => {
this.setState(previousState => {
const { deviceFeatures } = previousState;
if (!deviceFeatures) {
return null;
}
return {
deviceFeatures: updateDeviceFeatures(
deviceFeatures,
payload.device_feature_selector,
payload.last_value,
payload.last_value_changed
),
lastStateChanges: getUpdatedLastStateChanges(
previousState.lastStateChanges,
deviceFeatures,
payload.device_feature_selector,
payload.last_value,
payload.last_value_changed
)
};
});
};

updateDeviceTextWebsocket = payload => {
let { deviceFeatures } = this.state;
if (deviceFeatures) {
Expand Down Expand Up @@ -211,8 +286,12 @@ class DevicesComponent extends Component {

componentDidUpdate(previousProps) {
const deviceFeaturesChanged = get(previousProps, 'box.device_features') !== get(this.props, 'box.device_features');
const displayLastStateChangeChanged =
get(previousProps, 'box.display_last_state_change') !== get(this.props, 'box.display_last_state_change');
if (deviceFeaturesChanged) {
this.refreshData();
} else if (displayLastStateChangeChanged) {
this.getLastStateChanges(this.state.deviceFeatures);
}
}

Expand All @@ -228,7 +307,7 @@ class DevicesComponent extends Component {
this.props.session.dispatcher.removeListener('websocket.connected', this.handleWebsocketConnected);
}

render(props, { deviceFeatures, status }) {
render(props, { deviceFeatures, lastStateChanges, status }) {
const boxTitle = props.box.name;
const loading = status === RequestStatus.Getting;
const roomLightStatus = this.getLightStatus();
Expand All @@ -239,6 +318,7 @@ class DevicesComponent extends Component {
loading={loading}
boxTitle={boxTitle}
deviceFeatures={deviceFeatures}
lastStateChanges={lastStateChanges}
roomLightStatus={roomLightStatus}
updateValue={this.updateValue}
updateValueWithDebounce={this.updateValueWithDebounce}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Text } from 'preact-i18n';

/**
* Shared editor option of the "devices" and "devices in room" boxes: when enabled, every
* binary sensor of the box displays, under its state, the date at which this state was
* reached (door opened/closed, motion detected...).
*/
const DisplayLastStateChangeOption = ({ box, x, y, updateDisplayLastStateChange }) => {
// The edit dashboard displays every box at once, so a hardcoded id would be duplicated as soon
// as two devices boxes are on the dashboard, and clicking the label of one would toggle the
// checkbox of the other. The box coordinates make it unique.
const inputId = `displayLastStateChange-${x}-${y}`;
return (
<div class="form-group">
<label class="custom-switch">
<input
type="checkbox"
id={inputId}
name="displayLastStateChange"
class="custom-switch-input"
checked={box.display_last_state_change === true}
onClick={updateDisplayLastStateChange}
/>
<span class="custom-switch-indicator" />
<span class="custom-switch-description">
<Text id="dashboard.boxes.devicesInRoom.displayLastStateChangeLabel" />
</span>
</label>
<p class="mt-2">
<small class="text-muted">
<Text id="dashboard.boxes.devicesInRoom.displayLastStateChangeDescription" />
</small>
</p>
</div>
);
};

export default DisplayLastStateChangeOption;
13 changes: 13 additions & 0 deletions front/src/components/boxs/device-in-room/EditDeviceInRoom.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ import RoomSelector from '../../house/RoomSelector';
import { getDeviceFeatureName } from '../../../utils/device';
import withIntlAsProp from '../../../utils/withIntlAsProp';
import { isSupportedFeature } from './SupportedFeatureTypes';
import DisplayLastStateChangeOption from './DisplayLastStateChangeOption';

class EditDeviceInRoom extends Component {
updateBoxRoom = room => {
this.props.updateBoxConfig(this.props.x, this.props.y, { room: room.selector, device_features: [] });
};

updateDisplayLastStateChange = e => {
this.props.updateBoxConfig(this.props.x, this.props.y, {
display_last_state_change: e.target.checked
});
};

updateDeviceFeatures = selectedDeviceFeaturesOptions => {
selectedDeviceFeaturesOptions = selectedDeviceFeaturesOptions || [];
const deviceFeatures = selectedDeviceFeaturesOptions.map(option => option.value);
Expand Down Expand Up @@ -111,6 +118,12 @@ class EditDeviceInRoom extends Component {
/>
</div>
)}
<DisplayLastStateChangeOption
box={props.box}
x={props.x}
y={props.y}
updateDisplayLastStateChange={this.updateDisplayLastStateChange}
/>
</div>
</div>
</BaseEditBox>
Expand Down
13 changes: 13 additions & 0 deletions front/src/components/boxs/device-in-room/EditDevices.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import BaseEditBox from '../baseEditBox';
import { getDeviceFeatureName } from '../../../utils/device';
import { DeviceListWithDragAndDrop } from '../../drag-and-drop/DeviceListWithDragAndDrop';
import withIntlAsProp from '../../../utils/withIntlAsProp';
import DisplayLastStateChangeOption from './DisplayLastStateChangeOption';

class EditDevices extends Component {
addDeviceFeature = async selectedDeviceFeatureOption => {
Expand All @@ -15,6 +16,12 @@ class EditDevices extends Component {
this.refreshDeviceFeaturesNames();
};

updateDisplayLastStateChange = e => {
this.props.updateBoxConfig(this.props.x, this.props.y, {
display_last_state_change: e.target.checked
Comment thread
cursor[bot] marked this conversation as resolved.
});
};

updateName = e => {
this.props.updateBoxConfig(this.props.x, this.props.y, {
name: e.target.value
Expand Down Expand Up @@ -220,6 +227,12 @@ class EditDevices extends Component {
/>
</div>
)}
<DisplayLastStateChangeOption
box={props.box}
x={props.x}
y={props.y}
updateDisplayLastStateChange={this.updateDisplayLastStateChange}
/>
</div>
</div>
</BaseEditBox>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { createElement } from 'preact';
import { Text } from 'preact-i18n';
import get from 'get-value';

import { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } from '../../../../../../../server/utils/constants';
import { DeviceFeatureCategoriesIcon } from '../../../../../utils/consts';
import RelativeTime from '../../../../device/RelativeTime';

import BatteryLevelFeature from './BatteryLevelFeature';
import BinaryDeviceValue from './BinaryDeviceValue';
Expand Down Expand Up @@ -89,9 +91,22 @@ const DEVICE_FEATURES_WITHOUT_EXPIRATION = [
];

const SensorDeviceType = ({ children, ...props }) => {
const { deviceFeature: feature } = props;
const { deviceFeature: feature, displayLastStateChange, lastStateChange, user } = props;
const { category, type } = feature;

// Enabled per box in the box editor. A binary state alone does not tell when the door was
// opened: the date of the last state change is displayed right under the state.
// Restricted to read-only features: writable binary features (a switch, a child lock...) also
// reach this component through DeviceRow, and DevicesBox does not request any date for them.
// `lastStateChange` is undefined while the request is in flight, and stays undefined for a
// feature the server left out (unknown, or not keeping any history), so nothing is displayed
// until an answer is known: null then really means "no change found in the history".
const showLastStateChange =
displayLastStateChange === true &&
feature.read_only === true &&
type === DEVICE_FEATURE_TYPES.SENSOR.BINARY &&
lastStateChange !== undefined;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
let elementType = get(DISPLAY_BY_FEATURE_CATEGORY_AND_TYPE, `${category}.${type}`);

if (!elementType) {
Expand All @@ -118,7 +133,18 @@ const SensorDeviceType = ({ children, ...props }) => {
<i class={`mr-2 fe fe-${get(DeviceFeatureCategoriesIcon, `${category}.${type}`)}`} />
</td>
<td>{props.rowName}</td>
<td class="text-right">{createElement(elementType, props)}</td>
<td class="text-right">
{createElement(elementType, props)}
{showLastStateChange && (
<div class="small text-muted">
{lastStateChange ? (
<RelativeTime datetime={lastStateChange} language={user ? user.language : null} futureDisabled />
) : (
<Text id="dashboard.boxes.devicesInRoom.noLastStateChange" />
)}
</div>
Comment thread
cursor[bot] marked this conversation as resolved.
)}
Comment on lines +136 to +146

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pierre already flagged this layout as not clean, and merging Horizon (#2902) makes it worse: .device-list-table pills are a single row (icon | name | badge), td is vertical-align: middle, and a wrapping small text-muted under the badge stretches the right cell while the name stays one line. French “Aucun changement d’état enregistré” / “il y a 3 heures” will wrap in that narrow column.

Gladys’s existing relative-time patterns do not stack under a badge: User Presence puts the time inside the badge, Last Seen is the value. On a Horizon pill the quiet place for this is a muted caption under the name, with the Opened/Closed badge staying a single line on the right — same structure as scene rows.

Please restyle against a real devices box on Horizon (including a long French relative time) before this ships. “Beautiful by default” is a dashboard contract.

</td>
</tr>
);
};
Expand Down
5 changes: 4 additions & 1 deletion front/src/config/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,10 @@
"doorbellRinging": "Klingelt",
"pushButton": "Schieben",
"cameraPresetPlaceholder": "Preset aufrufen...",
"vacuumDock": "Zurück zur Ladestation"
"vacuumDock": "Zurück zur Ladestation",
"displayLastStateChangeLabel": "Datum der letzten Zustandsänderung anzeigen",
"displayLastStateChangeDescription": "Für jeden binären Sensor dieser Box (Öffnung, Bewegung, Anwesenheit...) wird unter seinem Zustand das Datum angezeigt, an dem dieser Zustand erreicht wurde.",
"noLastStateChange": "Keine Zustandsänderung aufgezeichnet"
},
"devices": {
"editDeviceFeaturesLabel": "Wähle die Geräte aus, die du anzeigen möchtest:",
Expand Down
5 changes: 4 additions & 1 deletion front/src/config/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,10 @@
"doorbellRinging": "Ringing",
"pushButton": "Push",
"cameraPresetPlaceholder": "Recall a preset...",
"vacuumDock": "Return to Dock"
"vacuumDock": "Return to Dock",
"displayLastStateChangeLabel": "Display the date of the last state change",
"displayLastStateChangeDescription": "For each binary sensor of this box (door, motion, presence...), display under its state the date at which this state was reached.",
"noLastStateChange": "No state change recorded"
},
"devices": {
"editDeviceFeaturesLabel": "Select the devices you want to display:",
Expand Down
5 changes: 4 additions & 1 deletion front/src/config/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,10 @@
"doorbellRinging": "Sonne",
"pushButton": "Appuyer",
"cameraPresetPlaceholder": "Rappeler une position...",
"vacuumDock": "Retour à la base"
"vacuumDock": "Retour à la base",
"displayLastStateChangeLabel": "Afficher la date du dernier changement d'état",
"displayLastStateChangeDescription": "Pour chaque capteur binaire de cette boîte (ouverture, mouvement, présence...), affiche sous son état la date à laquelle cet état a été atteint.",
"noLastStateChange": "Aucun changement d'état enregistré"
},
"devices": {
"editDeviceFeaturesLabel": "Vous pouvez modifier le nom affiché ici :",
Expand Down
Loading
Loading