diff --git a/.env b/.env index d81b945..1fd866f 100644 --- a/.env +++ b/.env @@ -1,6 +1,7 @@ TITLE= BACKEND_URL= DEVICE_BACKEND_URL= +MQTT_BACKEND_URL= MQTT_URL= OAUTH_CLIENT_ID= VARIANT= diff --git a/.env.development b/.env.development index 27b9481..2289a13 100644 --- a/.env.development +++ b/.env.development @@ -1,6 +1,7 @@ TITLE=Fauna BACKEND_URL=http://localhost:3000/ DEVICE_BACKEND_URL=http://localhost:4000/ +MQTT_BACKEND_URL=https://mqtt.initlab.org/ MQTT_URL=ws://localhost:1083/mqtt OAUTH_CLIENT_ID=hCOEcK3ntyBym-uLQkogX6w8457kicVlZbY0PQZJusw VARIANT=initlab diff --git a/.env.production b/.env.production index a2da9a2..7713512 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,5 @@ BACKEND_URL=https://fauna.initlab.org/ DEVICE_BACKEND_URL=https://portier.initlab.org/ +MQTT_BACKEND_URL=https://mqtt.initlab.org/ MQTT_URL=wss://spitfire.initlab.org:8083/mqtt OAUTH_CLIENT_ID=YueB6ct6SKPN8Ar72G0LC1QFxW9meUDQIOHdAu5mfCE diff --git a/src/app/store.js b/src/app/store.js index 3c462f6..b1da81a 100644 --- a/src/app/store.js +++ b/src/app/store.js @@ -1,20 +1,24 @@ import { configureStore } from '@reduxjs/toolkit'; import { setupListeners } from '@reduxjs/toolkit/query'; -import { anonymousApiSlice, authenticatedApiSlice, authenticatedDeviceApiSlice } from '../features/apiSlice'; -import { sensorSlice } from '../features/sensorSlice'; +import { + anonymousApiSlice, + anonymousMqttApiSlice, + authenticatedApiSlice, + authenticatedDeviceApiSlice +} from '../features/apiSlice'; import { doorSlice } from '../features/doorSlice.js'; export const store = configureStore({ reducer: { [anonymousApiSlice.reducerPath]: anonymousApiSlice.reducer, + [anonymousMqttApiSlice.reducerPath]: anonymousMqttApiSlice.reducer, [authenticatedApiSlice.reducerPath]: authenticatedApiSlice.reducer, [authenticatedDeviceApiSlice.reducerPath]: authenticatedDeviceApiSlice.reducer, - [sensorSlice.name]: sensorSlice.reducer, [doorSlice.name]: doorSlice.reducer, }, middleware: getDefaultMiddleware => - getDefaultMiddleware().concat(anonymousApiSlice.middleware).concat(authenticatedApiSlice.middleware) - .concat(authenticatedDeviceApiSlice.middleware), + getDefaultMiddleware().concat(anonymousApiSlice.middleware).concat(anonymousMqttApiSlice.middleware) + .concat(authenticatedApiSlice.middleware).concat(authenticatedDeviceApiSlice.middleware), }); setupListeners(store.dispatch); diff --git a/src/config.js b/src/config.js index 2990166..022db2e 100644 --- a/src/config.js +++ b/src/config.js @@ -1,18 +1,5 @@ export const mqtt = { url: import.meta.env.MQTT_URL, - sensors: [{ - type: 'Temperature', - label: 'Big room', - topic: 'sensors-xiaomi-ble/big-room/temperature', - }, { - type: 'Temperature', - label: 'Small room', - topic: 'sensors-xiaomi-ble/small-room/temperature', - }, { - type: 'Temperature', - label: 'Kitchen', - topic: 'sensors-xiaomi-ble/kitchen/temperature', - }], doorStates: [{ type: 'locked', topic: 'NetControl/initLab/out/ch28', @@ -28,6 +15,21 @@ export const mqtt = { }], }; +export const sensors = { + 'sensors-xiaomi-ble/big-room/temperature': { + type: 'Temperature', + label: 'Big room', + }, + 'sensors-xiaomi-ble/small-room/temperature': { + type: 'Temperature', + label: 'Small room', + }, + 'sensors-xiaomi-ble/kitchen/temperature': { + type: 'Temperature', + label: 'Kitchen', + }, +}; + export const grafana = { dashboard: { id: 'SGAb0ZXMk', diff --git a/src/features/apiSlice.js b/src/features/apiSlice.js index daa8efa..30566bc 100644 --- a/src/features/apiSlice.js +++ b/src/features/apiSlice.js @@ -4,6 +4,7 @@ import { refreshTokenIfNeeded } from '../oauth.js'; const apiBaseUrl = import.meta.env.BACKEND_URL + 'api/'; const deviceApiBaseUrl = import.meta.env.DEVICE_BACKEND_URL + 'api/'; +const mqttApiBaseUrl = import.meta.env.MQTT_BACKEND_URL; const anonymousBaseQuery = fetchBaseQuery({ baseUrl: apiBaseUrl, @@ -14,6 +15,10 @@ const anonymousBaseQuery = fetchBaseQuery({ }, }); +const anonymousMqttBaseQuery = fetchBaseQuery({ + baseUrl: mqttApiBaseUrl, +}); + const authenticatedBaseQuery = fetchBaseQuery({ baseUrl: apiBaseUrl, prepareHeaders: headers => { @@ -84,6 +89,14 @@ export const anonymousApiSlice = createApi({ }), }); +export const anonymousMqttApiSlice = createApi({ + reducerPath: 'anonymousMqttApi', + baseQuery: anonymousMqttBaseQuery, + endpoints: builder => ({ + getStatus: query(builder)('status'), + }), +}); + export const authenticatedApiSlice = createApi({ reducerPath: 'authenticatedApi', baseQuery: authenticatedBaseQueryWithReauth, @@ -114,6 +127,10 @@ export const { useGetPresentUsersQuery, } = anonymousApiSlice; +export const { + useGetStatusQuery, +} = anonymousMqttApiSlice; + export const { useGetCurrentUserQuery, } = authenticatedApiSlice; diff --git a/src/features/sensorSlice.js b/src/features/sensorSlice.js deleted file mode 100644 index 302a044..0000000 --- a/src/features/sensorSlice.js +++ /dev/null @@ -1,25 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit'; - -export const sensorSlice = createSlice({ - name: 'sensor', - initialState: {}, - reducers: { - setSensor: (state, { - payload: { - topic, - timestamp, - value, - message, - } - }) => { - state[topic] = { - timestamp, - value, - message, - }; - }, - }, -}); - -export const sensorSelector = topic => state => state[sensorSlice.name][topic]; -export const {setSensor} = sensorSlice.actions; diff --git a/src/mqtt.js b/src/mqtt.js index 25bb500..d06a4be 100644 --- a/src/mqtt.js +++ b/src/mqtt.js @@ -1,39 +1,20 @@ import { mqtt } from './config'; -import { setSensor } from './features/sensorSlice'; import { store } from './app/store'; import { connect } from 'precompiled-mqtt'; import { setState } from './features/doorSlice.js'; -const sensorTopics = mqtt.sensors.map(sensor => sensor.topic); const doorStateTopics = mqtt.doorStates.map(state => state.topic); const client = connect(mqtt.url); client.on('connect', function () { - sensorTopics.concat(doorStateTopics).forEach(function (topic) { + doorStateTopics.forEach(function (topic) { client.subscribe(topic); }); }); -client.on('message', function (topic, data, message) { +client.on('message', function (topic, data) { data = data.toString(); - if (sensorTopics.indexOf(topic) > -1) { - const { - timestamp, - value, - } = JSON.parse(data); - - store.dispatch(setSensor({ - topic, - timestamp, - value, - message: { - ...message, - payload: message.payload.toJSON(), - }, - })); - } - const doorState = mqtt.doorStates.filter(state => state.topic === topic).shift(); if (doorState) { diff --git a/src/widgets/SensorReading/SensorReading.jsx b/src/widgets/SensorReading/SensorReading.jsx index a972335..973d376 100644 --- a/src/widgets/SensorReading/SensorReading.jsx +++ b/src/widgets/SensorReading/SensorReading.jsx @@ -1,9 +1,7 @@ import { Card, Col, Container, Row } from 'react-bootstrap'; import PropTypes from 'prop-types'; import './SensorReading.css'; -import { useSelector } from 'react-redux'; -import { sensorSelector } from '../../features/sensorSlice'; -import LoadingIcon from '../icons/LoadingIcon'; +import { useDateTimeFormatter } from '../../utils/useDateTimeFormatter.js'; const units = { Temperature: ['°C', 1], @@ -15,15 +13,19 @@ const thresholds = [18, 24, 26, 32]; const SensorReading = ({ type, label, - topic, + timestamp, + value, }) => { const { - timestamp, - value, - } = useSelector(sensorSelector(topic)) || {}; + formatDefault, + formatDistanceToNow, + } = useDateTimeFormatter(); + const lastUpdate = new Date(timestamp); + const formattedTimestamp = formatDefault(lastUpdate) + ' (' + formatDistanceToNow(lastUpdate) + ')'; const unit = units[type]; - const formattedValue = value && value.toFixed(unit[1]) + unit[0]; + const formattedValue = value.toFixed(unit[1]) + unit[0]; const isCurrent = timestamp && Date.now() - timestamp <= 3_600_000; + // TODO: only for type === Temperature const thermometerState = thresholds.filter(threshold => threshold < value).length; return ( @@ -35,8 +37,8 @@ const SensorReading = ({ -
{formattedValue || }
-
{label}
+
{formattedValue}
+
{label}
@@ -48,7 +50,8 @@ const SensorReading = ({ SensorReading.propTypes = { type: PropTypes.string.isRequired, label: PropTypes.string.isRequired, - topic: PropTypes.string.isRequired, + timestamp: PropTypes.number.isRequired, + value: PropTypes.number.isRequired, }; export default SensorReading; diff --git a/src/widgets/SensorReadingsWrapper/SensorReadingsWrapper.jsx b/src/widgets/SensorReadingsWrapper/SensorReadingsWrapper.jsx index b90ff3c..1aef1d6 100644 --- a/src/widgets/SensorReadingsWrapper/SensorReadingsWrapper.jsx +++ b/src/widgets/SensorReadingsWrapper/SensorReadingsWrapper.jsx @@ -1,10 +1,22 @@ import { Col, Row } from 'react-bootstrap'; -import { mqtt } from '../../config'; +import { sensors } from '../../config'; import SensorReading from '../SensorReading/SensorReading'; import { useTranslation } from 'react-i18next'; +import { useGetStatusQuery } from '../../features/apiSlice.js'; +import LoadingIcon from '../icons/LoadingIcon.jsx'; +import ErrorMessage from '../ErrorMessage.jsx'; const SensorReadingsWrapper = () => { const { t } = useTranslation(); + const { + data, + isLoading, + isSuccess, + isError, + error, + } = useGetStatusQuery(undefined, { + pollingInterval: 60_000, + }); return (<> @@ -12,11 +24,21 @@ const SensorReadingsWrapper = () => {

{t('views.dashboard.sensor_readings')}

- - {mqtt.sensors.map((sensor, idx) => - + {isLoading && + + + + } + {isError && + + + + } + {isSuccess && + {Object.entries(sensors).map(([topic, sensor]) => + )} - + } ); }; diff --git a/vite.config.js b/vite.config.js index 80f9ef6..c6984cc 100644 --- a/vite.config.js +++ b/vite.config.js @@ -14,6 +14,7 @@ export default defineConfig({ 'TITLE', 'BACKEND_URL', 'DEVICE_BACKEND_URL', + 'MQTT_BACKEND_URL', 'MQTT_URL', 'OAUTH_CLIENT_ID', 'VARIANT',