Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
57 changes: 55 additions & 2 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 Down Expand Up @@ -57,6 +62,7 @@ class DevicesComponent extends Component {
super(props);
this.state = {
deviceFeatures: [],
lastStateChanges: {},
status: RequestStatus.Getting
};
this.wasDisconnected = false;
Expand Down Expand Up @@ -102,27 +108,69 @@ class DevicesComponent extends Component {
deviceFeatures: deviceFeaturesSorted,
status: RequestStatus.Success
});
this.getLastStateChanges(deviceFeaturesSorted);
} catch (e) {
this.setState({
status: RequestStatus.Error
});
}
};

// `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({ lastStateChanges });
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
} catch (e) {
console.error(e);
}
};

updateDeviceStateWebsocket = payload => {
let { deviceFeatures } = this.state;
if (deviceFeatures) {
const lastStateChanges = this.getUpdatedLastStateChanges(
deviceFeatures,
payload.device_feature_selector,
payload.last_value,
payload.last_value_changed
);
deviceFeatures = updateDeviceFeatures(
deviceFeatures,
payload.device_feature_selector,
payload.last_value,
payload.last_value_changed
);
this.setState({
deviceFeatures
deviceFeatures,
lastStateChanges
});
}
};

// 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.
getUpdatedLastStateChanges = (deviceFeatures, deviceFeatureSelector, lastValue, lastValueChange) => {
const { lastStateChanges } = this.state;
const feature = deviceFeatures.find(
f => f.selector === deviceFeatureSelector && isBinarySensorFeature(f) && f.last_value !== lastValue
);
if (!feature) {
return lastStateChanges;
}
return { ...lastStateChanges, [deviceFeatureSelector]: lastValueChange };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
};
updateDeviceTextWebsocket = payload => {
let { deviceFeatures } = this.state;
if (deviceFeatures) {
Expand Down Expand Up @@ -211,8 +259,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 +280,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 +291,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,32 @@
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, updateDisplayLastStateChange }) => (
<div class="form-group">
<label class="custom-switch">
<input
type="checkbox"
id="displayLastStateChange"
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
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;
11 changes: 11 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,10 @@ class EditDeviceInRoom extends Component {
/>
</div>
)}
<DisplayLastStateChangeOption
box={props.box}
updateDisplayLastStateChange={this.updateDisplayLastStateChange}
/>
</div>
</div>
</BaseEditBox>
Expand Down
11 changes: 11 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,10 @@ class EditDevices extends Component {
/>
</div>
)}
<DisplayLastStateChangeOption
box={props.box}
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,13 @@ 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.
const showLastStateChange = displayLastStateChange === true && type === DEVICE_FEATURE_TYPES.SENSOR.BINARY;

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 +124,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
12 changes: 12 additions & 0 deletions server/api/controllers/device.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,17 @@ module.exports = function DeviceController(gladys) {
res.json(states);
}

/**
* @api {get} /api/v1/device_feature/last_state_changes getLastStateChanges
* @apiName getLastStateChanges
* @apiGroup Device
*/
async function getLastStateChanges(req, res) {
const deviceFeatureSelectors = (req.query.device_feature_selectors || '').split(',').filter((selector) => selector);
const lastStateChanges = await gladys.device.getLastStateChanges(deviceFeatureSelectors);
res.json(lastStateChanges);
}

/**
* @api {get} /api/v1/device_feature/energy_consumption getConsumptionByDates
* @apiName getConsumptionByDates
Expand Down Expand Up @@ -198,6 +209,7 @@ module.exports = function DeviceController(gladys) {
setValueFeature: asyncMiddleware(setValueFeature),
getDeviceFeaturesAggregated: asyncMiddleware(getDeviceFeaturesAggregated),
getDeviceStatesHistory: asyncMiddleware(getDeviceStatesHistory),
getLastStateChanges: asyncMiddleware(getLastStateChanges),
getConsumptionByDates: asyncMiddleware(getConsumptionByDates),
purgeAllSqliteStates: asyncMiddleware(purgeAllSqliteStates),
getDuckDbMigrationState: asyncMiddleware(getDuckDbMigrationState),
Expand Down
4 changes: 4 additions & 0 deletions server/api/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@ function getRoutes(gladys) {
authenticated: true,
controller: deviceController.getDeviceStatesHistory,
},
'get /api/v1/device_feature/last_state_changes': {
authenticated: true,
controller: deviceController.getLastStateChanges,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
'get /api/v1/device_feature/energy_consumption': {
authenticated: true,
controller: deviceController.getConsumptionByDates,
Expand Down
Loading
Loading