Files
sensors/client/src/App.js
T
2026-06-16 21:52:51 +00:00

930 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { ComposedChart, Bar, Label, LineChart, ReferenceLine, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import Datetime from 'react-datetime';
import 'react-datetime/css/react-datetime.css';
import axios from 'axios';
import moment from 'moment-timezone';
import './App.css';
let tzcache = {};
const durations = [
{id: 0, len: 'Day', win: '10m', full: '10 min', delta: [1, 'days'], format: 'HH'},
{id: 1, len: 'Day', win: '1h', full: '1 hour', delta: [1, 'days'], format: 'HH'},
{id: 2, len: 'Week', win: '1h', full: '1 hour', delta: [7, 'days'], format: 'HH'},
{id: 3, len: 'Week', win: '1d', full: '1 day', delta: [7, 'days'], format: 'D'},
{id: 4, len: 'Month', win: '1d', full: '1 day', delta: [1, 'months'], format: 'D'},
{id: 5, len: 'Month', win: '7d', full: '7 day', delta: [1, 'months'], format: 'D'},
{id: 6, len: 'Year', win: '1d', full: '1 day', delta: [1, 'years'], format: 'M/D'},
{id: 7, len: 'Year', win: '30d', full: '30 day', delta: [1, 'years'], format: 'M'},
];
const units = {
'PM10': ' ug/m³',
'PM2.5': ' ug/m³',
'VOC': ' / 500',
'CO2': ' ppm',
'Energy': ' kWh',
'Power': ' W',
'Temperature': ' °C',
'Humidity': '%',
'Setpoint': ' °C',
'State': '',
'Lux': ' lx',
'Soil': '',
};
function useSensor(measurement, name, end, duration) {
const [data, setData] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
const get = async() => {
setLoading(true);
try {
const api_key = localStorage.getItem('api_key', 'null');
const params = { end: end.unix(), duration: duration.len.toLowerCase(), window: duration.win, api_key: api_key };
const res = await axios.get(
'https://sensors-api.dns.t0.vc/history/'+measurement+'/'+name,
{ params: params },
);
setData((d) => (res.data));
setLoading(false);
} catch (error) {
console.log(error);
}
};
get();
}, [end, duration]);
const memoConvertTZ = (isDST, timeStr, format) => {
if (!timeStr) return '?';
let lookUp, result = null;
const date = timeStr.slice(5, 10);
const hours = timeStr.slice(11, 13);
const minutes = timeStr.slice(14, 16);
if (format === 'HH') {
lookUp = [isDST, hours, format];
} else {
lookUp = [isDST, date, format];
}
if (tzcache[lookUp] != undefined ) {
result = tzcache[lookUp];
} else {
result = moment(timeStr).tz('America/Edmonton').format(format);
tzcache[lookUp] = result;
}
if (format === 'HH') {
return result + ':' + minutes;
} else {
return result;
}
};
const isDST = end.tz('America/Edmonton').isDST();
const tickFormatter = (timeStr) => memoConvertTZ(isDST, timeStr, duration.format);
return [data, loading, tickFormatter];
};
function ChartContainer({name, data, lastFormatter, loading, children, topMargin}) {
topMargin = topMargin || 5;
if (!data) {
return (
<>
<h2>{name}</h2>
<p>Loading...</p>
</>
);
}
if (data.length === 0) {
return false;
}
const dataGood = (x) => !['undefined', 'null'].some(y => String(lastFormatter(x)).includes(y));
let last = null;
if (data.length) {
const data_end = data.slice(-2);
if (dataGood(data_end[1])) {
last = lastFormatter(data_end[1]);
} else if (dataGood(data_end[0])) {
last = lastFormatter(data_end[0]);
}
}
return (
<div className='chart' id={name.replace(/ /g,'_')}>
<h2>{name}: {loading ? 'Loading...' : last || 'No data'}</h2>
<ResponsiveContainer width='100%' height={300}>
<ComposedChart syncId={1} data={data} margin={{ top: topMargin, left: 0, right: 30, bottom: 0 }}>
{children}
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
function SolarPower({end, duration}) {
const [data, loading, tickFormatter] = useSensor('solar', 'Solar', end, duration);
return (
<ChartContainer
name='Solar Power'
data={data}
lastFormatter={(x) => x.actual_total + ' W'}
loading={loading}
topMargin={25}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='right'
domain={[0, 10]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='left'
domain={[0, 6000]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue'>
<Label value='Midnight' offset={7} position='top' />
</ReferenceLine>
<Bar
yAxisId='right'
type='monotone'
dataKey='lifetime_energy'
name='Energy'
fill='green'
isAnimationActive={false}
/>
<Line
yAxisId='left'
type='monotone'
dataKey='actual_total'
name='Power'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function Temperature({name, sensorName, end, duration, yDomain, showHumidity, showFreezingLine}) {
const [data, loading, tickFormatter] = useSensor('temperature', sensorName, end, duration);
return (
<ChartContainer
name={name}
data={data}
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
{showHumidity &&
<YAxis
yAxisId='right'
domain={[0, 100]}
orientation='right'
hide={true}
/>
}
<YAxis
yAxisId={showHumidity ? 'left' : undefined}
domain={yDomain}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
{showFreezingLine &&
<ReferenceLine yAxisId={showHumidity ? 'left' : undefined} y={0} stroke='purple'>
<Label value='Freezing' offset={7} position='bottom' />
</ReferenceLine>
}
<ReferenceLine yAxisId={showHumidity ? 'left' : undefined} x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
yAxisId={showHumidity ? 'left' : undefined}
type='monotone'
dataKey='temperature_C'
name='Temperature'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
{showHumidity &&
<Line
yAxisId='right'
type='monotone'
dataKey='humidity'
name='Humidity'
stroke='blue'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
}
</ChartContainer>
);
}
function Thermostat({end, duration}) {
const [data, loading, tickFormatter] = useSensor('thermostat', 'Venstar', end, duration);
return (
<ChartContainer
name='Nook Thermostat'
data={data}
lastFormatter={(x) => x.spacetemp?.toFixed(1) + ' °C'}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='right'
domain={[0, 6]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='left'
domain={[12, 30]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
yAxisId='right'
type='monotone'
dataKey='state'
name='State'
stroke='green'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
yAxisId='left'
type='monotone'
dataKey='heattemp'
name='Setpoint'
stroke='red'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
yAxisId='left'
type='monotone'
dataKey='spacetemp'
name='Temperature'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function Gas({end, duration}) {
const [data, loading, tickFormatter] = useSensor('ertscm', 'Gas', end, duration);
return (
<ChartContainer
name='Gas Usage'
data={data}
lastFormatter={(x) => (x.max / 1000)?.toFixed(1) + ' GJ'}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='right'
domain={data.length ? [data[0].consumption_data, data.slice(-1)[0].consumption_data] : [100, 0]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='left'
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={v => v.toFixed(1) + ' MJ'}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='right' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Bar
yAxisId='left'
type='monotone'
dataKey='delta'
name='Delta'
fill='green'
isAnimationActive={false}
/>
<Line
yAxisId='right'
type='monotone'
dataKey='max'
name='Total'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function Water({end, duration}) {
const [data, loading, tickFormatter] = useSensor('ertscm', 'Water', end, duration);
return (
<ChartContainer
name='Water Usage'
data={data}
lastFormatter={(x) => (x.max / 1000)?.toFixed(1) + ' m³'}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='right'
domain={data.length ? [data[0].consumption_data, data.slice(-1)[0].consumption_data] : [100, 0]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='left'
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={v => v + ' L'}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='right' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Bar
yAxisId='left'
type='monotone'
dataKey='delta'
name='Delta'
fill='green'
isAnimationActive={false}
/>
<Line
yAxisId='right'
type='monotone'
dataKey='max'
name='Total'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function LivingRoomDust({end, duration}) {
const [data, loading, tickFormatter] = useSensor('dust', 'Living Room', end, duration);
return (
<ChartContainer
name='Living Room Dust'
data={data}
lastFormatter={(x) => x.max_p10?.toFixed(1) + ' ug/m³'}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
domain={[0, 20]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={v => v.toFixed(1) + ' ug/m³'}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
type='monotone'
dataKey='max_p10'
name='PM10'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function Air({name, sensorName, end, duration}) {
const [data, loading, tickFormatter] = useSensor('air', sensorName, end, duration);
return (
<ChartContainer
name={name}
data={data}
lastFormatter={(x) => x.max_p10?.toFixed(1) + ' ug/m³'}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='co2'
domain={[400, 1000]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='voc'
domain={[0, 250]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='pm'
domain={[0, 20]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={(v, name) => v + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='pm' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
yAxisId='pm'
type='monotone'
dataKey='max_p10'
name='PM10'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
yAxisId='pm'
type='monotone'
dataKey='max_p25'
name='PM2.5'
stroke='red'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
yAxisId='co2'
type='monotone'
dataKey='max_co2'
name='CO2'
stroke='blue'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
yAxisId='voc'
type='monotone'
dataKey='max_voc'
name='VOC'
stroke='green'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function BedroomSleep({end, duration}) {
const [data, loading, tickFormatter] = useSensor('sleep', 'Bedroom', end, duration);
return (
<ChartContainer
name='Sleep Movement'
data={data}
lastFormatter={(x) => x.max_mag?.toFixed(1) + ' m/s²'}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
domain={[5, 20]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={v => v.toFixed(1) + ' m/s²'}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
type='monotone'
dataKey='max_mag'
name='Movement'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function Lux({name, sensorName, end, duration}) {
const [data, loading, tickFormatter] = useSensor('lux', sensorName, end, duration);
return (
<ChartContainer
name={name}
data={data}
lastFormatter={(x) => x.lux?.toFixed(1) + ' lx'}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='lux'
domain={[0, 250]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='lux' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
yAxisId='lux'
type='monotone'
dataKey='lux'
name='Lux'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function Soil({name, sensorName, end, duration}) {
const [data, loading, tickFormatter] = useSensor('soil', sensorName, end, duration);
return (
<ChartContainer
name={name}
data={data}
lastFormatter={(x) => x.soil?.toFixed(1)}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='soil'
domain={[0, 1000]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='soil' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
yAxisId='soil'
type='monotone'
dataKey='soil'
name='Soil'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function WH51Soil({name, sensorName, end, duration}) {
const [data, loading, tickFormatter] = useSensor('soil', sensorName, end, duration);
return (
<ChartContainer
name={name}
data={data}
lastFormatter={(x) => x.moisture?.toFixed(1)}
loading={loading}
>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='soil'
domain={[0, 100]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId='soil' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
yAxisId='soil'
type='monotone'
dataKey='moisture'
name='Soil'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ChartContainer>
);
}
function Graphs({end, duration}) {
const api_key = localStorage.getItem('api_key', false);
const handleSubmit = (e) => {
e.preventDefault();
const api_key = e.target[0].value;
localStorage.setItem('api_key', api_key);
}
return (
<div className='container'>
<SolarPower end={end} duration={duration} />
<LivingRoomDust end={end} duration={duration} />
<Air name='Living Room Air' sensorName='Living Room' end={end} duration={duration} />
<Air name='Kitchen Air' sensorName='Kitchen' end={end} duration={duration} />
<Air name='Bedroom Air' sensorName='Bedroom' end={end} duration={duration} />
<Air name='Laundry Room Air' sensorName='Laundry Room' end={end} duration={duration} />
<Temperature name='Outside Temperature' sensorName='Outside' end={end} duration={duration} yDomain={[-40, 40]} showHumidity={false} showFreezingLine={true} />
<Temperature name='Bedroom Temperature' sensorName='Bedroom' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Temperature name='Nook Temperature' sensorName='Nook' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Temperature name='Garden Temperature' sensorName='Seeds' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Temperature name='Misc Temperature' sensorName='Misc' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Temperature name='Basement Temperature' sensorName='Basement' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Thermostat end={end} duration={duration} />
<Gas end={end} duration={duration} />
<Water end={end} duration={duration} />
<BedroomSleep end={end} duration={duration} />
<Lux name='Living Room Lux' sensorName='Living Room' end={end} duration={duration} />
<Lux name='Kitchen Lux' sensorName='Kitchen' end={end} duration={duration} />
<Lux name='Bedroom Lux' sensorName='Bedroom' end={end} duration={duration} />
<Lux name='Laundry Room Lux' sensorName='Laundry Room' end={end} duration={duration} />
<WH51Soil name='Side Garden Soil Moisture' sensorName='Side Garden' end={end} duration={duration} />
<Soil name='Dumb Cane Soil Moisture' sensorName='Dumb Cane' end={end} duration={duration} />
<Soil name='Kitchen Pothos Soil Moisture' sensorName='Kitchen Pothos' end={end} duration={duration} />
<Soil name='Dracaena Soil Moisture' sensorName='Dracaena' end={end} duration={duration} />
{!!api_key ||
<div>
<form onSubmit={handleSubmit}>
<p>
<input placeholder='API key' />
</p>
</form>
</div>
}
</div>
);
}
function Menu({duration, setDuration, end, setEnd}) {
const [submenu, setSubmenu] = useState(false);
const chooseDuration = (x) => {
setSubmenu(false);
setDuration(x);
};
const chooseEnd = (x) => {
setSubmenu(false);
const newEnd = x.add(...duration.delta);
setEnd(newEnd);
};
const chooseNow = (x) => {
setSubmenu(false);
setEnd(moment());
};
const next = () => {
setSubmenu(false);
setEnd(prevEnd => moment(prevEnd).add(...duration.delta));
}
const prev = () => {
setSubmenu(false);
setEnd(prevEnd => moment(prevEnd).subtract(...duration.delta));
}
return (
<div className='menu'>
{!!submenu &&<div className='submenu'>
{submenu === 'end' &&
<>
<div className='submenu-header'>
<h2>Choose start date:</h2>
<button onClick={() => setSubmenu(false)}>×</button>
</div>
<div className='datepicker'>
<Datetime
input={false}
timeFormat={false}
onChange={(x) => chooseEnd(x)}
/>
</div>
<button onClick={chooseNow}>Jump to Now</button>
</>
}
{submenu === 'duration' &&
<>
<div className='submenu-header'>
<h2>Choose duration:</h2>
<button onClick={() => setSubmenu(false)}>×</button>
</div>
{durations.map(x =>
<button key={x.id} onClick={() => chooseDuration(x)}>Last {x.len} / {x.full} data</button>
)}
</>
}
</div>}
<div className='menu-container'>
<button onClick={() => prev()}>&lt;</button>
<button
onClick={() => setSubmenu('end')}
className={submenu === 'end' ? 'active' : ''}
>
{moment(end).subtract(duration.delta[0], duration.delta[1]).format('ddd MMM DD')}
</button>
<button
onClick={() => setSubmenu('duration')}
className={submenu === 'duration' ? 'active' : ''}
>
{duration.len} / {duration.win}
</button>
<button onClick={() => next()}>&gt;</button>
</div>
</div>
);
}
function App() {
const [duration, setDuration] = useState(durations[0]);
const [end, setEnd] = useState(moment());
useEffect(() => {
const updateEnd = () => {
setEnd(prevEnd => moment(prevEnd).add(1, 'minutes'));
}
const interval = setInterval(updateEnd, 60000);
return () => clearInterval(interval);
}, []);
return (
<div>
<Menu
duration={duration}
setDuration={setDuration}
end={end}
setEnd={setEnd}
/>
<Graphs
end={end}
duration={duration}
/>
</div>
);
}
export default App;