Skip to content
Open
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
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 @@ -72,6 +72,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 @@ -112,6 +119,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 @@ -221,6 +228,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 @@ -453,7 +453,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 @@ -453,7 +453,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 @@ -453,7 +453,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