Compare commits
65 Commits
43535d0a95
...
master
Author | SHA1 | Date | |
---|---|---|---|
25d6a8757b | |||
b585a39dd0 | |||
a3c7f85302 | |||
646ca1268e | |||
562c7cb6eb | |||
cb8129cbba | |||
502ae2b982 | |||
1f744216ec | |||
ec7fbed514 | |||
3d927c18ce | |||
f309c0af00 | |||
7e0eddaf38 | |||
1875d7b4e7 | |||
959e1d85d0 | |||
2be0dd1c3d | |||
0708301396 | |||
fbc15bb371 | |||
ca3202f9b7 | |||
435db835e9 | |||
87e706c223 | |||
478dca185e | |||
b295c3fef0 | |||
13b35e1c00 | |||
2adc0a9fcb | |||
0a02db9a8d | |||
bdc2921bc0 | |||
9dd772839b | |||
4bc88e5ce9 | |||
6c7dff2d8f | |||
21cec132a7 | |||
51031e7b20 | |||
81880a6a0a | |||
44dcc1b8ad | |||
17b1f979a9 | |||
00d9ee362f | |||
578bed681a | |||
816624ec44 | |||
1052cf9bb9 | |||
3c8393b14c | |||
d5f5e08a3c | |||
f93e6d2323 | |||
24bada26a4 | |||
4202e1a19d | |||
ecd1dab005 | |||
54e169bdd2 | |||
49f9ee120b | |||
fa8f2cddb5 | |||
7b15b39d5f | |||
e5dbb0af39 | |||
e549afce96 | |||
45272a6242 | |||
61fd657952 | |||
88dbba168c | |||
ba630b6fb9 | |||
34f0444de7 | |||
8abb15cdd3 | |||
1346171618 | |||
e38164fd43 | |||
b275305434 | |||
1d5f63f86d | |||
c693d30394 | |||
a7ca48dacf | |||
08b7196c26 | |||
29ac0345c6 | |||
b393b88127 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -104,3 +104,4 @@ ENV/
|
|||||||
|
|
||||||
settings.py
|
settings.py
|
||||||
*.csv
|
*.csv
|
||||||
|
.aider*
|
||||||
|
@@ -31,6 +31,7 @@ const units = {
|
|||||||
'Setpoint': ' °C',
|
'Setpoint': ' °C',
|
||||||
'State': '',
|
'State': '',
|
||||||
'Lux': ' lx',
|
'Lux': ' lx',
|
||||||
|
'Soil': '',
|
||||||
};
|
};
|
||||||
|
|
||||||
function useSensor(measurement, name, end, duration) {
|
function useSensor(measurement, name, end, duration) {
|
||||||
@@ -41,7 +42,8 @@ function useSensor(measurement, name, end, duration) {
|
|||||||
const get = async() => {
|
const get = async() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = { end: end.unix(), duration: duration.len.toLowerCase(), window: duration.win };
|
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(
|
const res = await axios.get(
|
||||||
'https://sensors-api.dns.t0.vc/history/'+measurement+'/'+name,
|
'https://sensors-api.dns.t0.vc/history/'+measurement+'/'+name,
|
||||||
{ params: params },
|
{ params: params },
|
||||||
@@ -107,7 +109,7 @@ function ChartContainer({name, data, lastFormatter, loading, children, topMargin
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataGood = (x) => !['undefined', 'null'].some(y => lastFormatter(x).includes(y));
|
const dataGood = (x) => !['undefined', 'null'].some(y => String(lastFormatter(x)).includes(y));
|
||||||
let last = null;
|
let last = null;
|
||||||
if (data.length) {
|
if (data.length) {
|
||||||
const data_end = data.slice(-2);
|
const data_end = data.slice(-2);
|
||||||
@@ -194,12 +196,12 @@ function SolarPower({end, duration}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OutsideTemperature({end, duration}) {
|
function Temperature({name, sensorName, end, duration, yDomain, showHumidity, showFreezingLine}) {
|
||||||
const [data, loading, tickFormatter] = useSensor('temperature', 'Outside', end, duration);
|
const [data, loading, tickFormatter] = useSensor('temperature', sensorName, end, duration);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
name='Outside Temperature'
|
name={name}
|
||||||
data={data}
|
data={data}
|
||||||
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
|
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -209,77 +211,35 @@ function OutsideTemperature({end, duration}) {
|
|||||||
minTickGap={10}
|
minTickGap={10}
|
||||||
tickFormatter={tickFormatter}
|
tickFormatter={tickFormatter}
|
||||||
/>
|
/>
|
||||||
|
{showHumidity &&
|
||||||
<YAxis
|
<YAxis
|
||||||
domain={[-40, 40]}
|
yAxisId='right'
|
||||||
|
domain={[0, 100]}
|
||||||
|
orientation='right'
|
||||||
|
hide={true}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<YAxis
|
||||||
|
yAxisId={showHumidity ? 'left' : undefined}
|
||||||
|
domain={yDomain}
|
||||||
/>
|
/>
|
||||||
<CartesianGrid strokeDasharray='3 3'/>
|
<CartesianGrid strokeDasharray='3 3'/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={v => v.toFixed(1) + ' °C'}
|
formatter={(v, name) => v.toFixed(1) + units[name]}
|
||||||
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
|
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
|
||||||
separator=': '
|
separator=': '
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReferenceLine y={0} stroke='purple'>
|
{showFreezingLine &&
|
||||||
|
<ReferenceLine yAxisId={showHumidity ? 'left' : undefined} y={0} stroke='purple'>
|
||||||
<Label value='Freezing' offset={7} position='bottom' />
|
<Label value='Freezing' offset={7} position='bottom' />
|
||||||
</ReferenceLine>
|
</ReferenceLine>
|
||||||
|
|
||||||
<ReferenceLine x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
|
|
||||||
|
|
||||||
<Line
|
|
||||||
type='monotone'
|
|
||||||
dataKey='temperature_C'
|
|
||||||
name='Temperature'
|
|
||||||
stroke='black'
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</ChartContainer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function NookTemperature({end, duration}) {
|
<ReferenceLine yAxisId={showHumidity ? 'left' : undefined} x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
|
||||||
const [data, loading, tickFormatter] = useSensor('temperature', 'Nook', end, duration);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChartContainer
|
|
||||||
name='Nook Temperature'
|
|
||||||
data={data}
|
|
||||||
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
<XAxis
|
|
||||||
dataKey='time'
|
|
||||||
minTickGap={10}
|
|
||||||
tickFormatter={tickFormatter}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<YAxis
|
|
||||||
yAxisId='right'
|
|
||||||
domain={[0, 100]}
|
|
||||||
orientation='right'
|
|
||||||
hide={true}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId='left'
|
|
||||||
domain={[15, 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' y={0} stroke='blue'>
|
|
||||||
<Label value='Freezing' offset={7} position='bottom' />
|
|
||||||
</ReferenceLine>
|
|
||||||
|
|
||||||
<ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
|
|
||||||
|
|
||||||
<Line
|
<Line
|
||||||
yAxisId='left'
|
yAxisId={showHumidity ? 'left' : undefined}
|
||||||
type='monotone'
|
type='monotone'
|
||||||
dataKey='temperature_C'
|
dataKey='temperature_C'
|
||||||
name='Temperature'
|
name='Temperature'
|
||||||
@@ -288,7 +248,7 @@ function NookTemperature({end, duration}) {
|
|||||||
dot={false}
|
dot={false}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
|
{showHumidity &&
|
||||||
<Line
|
<Line
|
||||||
yAxisId='right'
|
yAxisId='right'
|
||||||
type='monotone'
|
type='monotone'
|
||||||
@@ -299,196 +259,11 @@ function NookTemperature({end, duration}) {
|
|||||||
dot={false}
|
dot={false}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BedroomTemperature({end, duration}) {
|
|
||||||
const [data, loading, tickFormatter] = useSensor('temperature', 'Bedroom', end, duration);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChartContainer
|
|
||||||
name='Bedroom Temperature'
|
|
||||||
data={data}
|
|
||||||
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
<XAxis
|
|
||||||
dataKey='time'
|
|
||||||
minTickGap={10}
|
|
||||||
tickFormatter={tickFormatter}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<YAxis
|
|
||||||
yAxisId='right'
|
|
||||||
domain={[0, 100]}
|
|
||||||
orientation='right'
|
|
||||||
hide={true}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId='left'
|
|
||||||
domain={[15, 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' y={0} stroke='blue'>
|
|
||||||
<Label value='Freezing' offset={7} position='bottom' />
|
|
||||||
</ReferenceLine>
|
|
||||||
|
|
||||||
<ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
|
|
||||||
|
|
||||||
<Line
|
|
||||||
yAxisId='left'
|
|
||||||
type='monotone'
|
|
||||||
dataKey='temperature_C'
|
|
||||||
name='Temperature'
|
|
||||||
stroke='black'
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Line
|
|
||||||
yAxisId='right'
|
|
||||||
type='monotone'
|
|
||||||
dataKey='humidity'
|
|
||||||
name='Humidity'
|
|
||||||
stroke='blue'
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</ChartContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MiscTemperature({end, duration}) {
|
|
||||||
const [data, loading, tickFormatter] = useSensor('temperature', 'Misc', end, duration);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChartContainer
|
|
||||||
name='Misc Temperature'
|
|
||||||
data={data}
|
|
||||||
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
<XAxis
|
|
||||||
dataKey='time'
|
|
||||||
minTickGap={10}
|
|
||||||
tickFormatter={tickFormatter}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<YAxis
|
|
||||||
yAxisId='right'
|
|
||||||
domain={[0, 100]}
|
|
||||||
orientation='right'
|
|
||||||
hide={true}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId='left'
|
|
||||||
domain={[-40, 40]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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
|
|
||||||
type='monotone'
|
|
||||||
dataKey='temperature_C'
|
|
||||||
yAxisId='left'
|
|
||||||
name='Temperature'
|
|
||||||
stroke='black'
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Line
|
|
||||||
type='monotone'
|
|
||||||
dataKey='humidity'
|
|
||||||
yAxisId='right'
|
|
||||||
name='Humidity'
|
|
||||||
stroke='blue'
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</ChartContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SeedsTemperature({end, duration}) {
|
|
||||||
const [data, loading, tickFormatter] = useSensor('temperature', 'Seeds', end, duration);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChartContainer
|
|
||||||
name='Garden Temperature'
|
|
||||||
data={data}
|
|
||||||
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
<XAxis
|
|
||||||
dataKey='time'
|
|
||||||
minTickGap={10}
|
|
||||||
tickFormatter={tickFormatter}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<YAxis
|
|
||||||
yAxisId='right'
|
|
||||||
domain={[0, 100]}
|
|
||||||
orientation='right'
|
|
||||||
hide={true}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId='left'
|
|
||||||
domain={[15, 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
|
|
||||||
type='monotone'
|
|
||||||
dataKey='temperature_C'
|
|
||||||
yAxisId='left'
|
|
||||||
name='Temperature'
|
|
||||||
stroke='black'
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Line
|
|
||||||
type='monotone'
|
|
||||||
dataKey='humidity'
|
|
||||||
yAxisId='right'
|
|
||||||
name='Humidity'
|
|
||||||
stroke='blue'
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</ChartContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Thermostat({end, duration}) {
|
function Thermostat({end, duration}) {
|
||||||
const [data, loading, tickFormatter] = useSensor('thermostat', 'Venstar', end, duration);
|
const [data, loading, tickFormatter] = useSensor('thermostat', 'Venstar', end, duration);
|
||||||
@@ -718,12 +493,12 @@ function LivingRoomDust({end, duration}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LivingRoomAir({end, duration}) {
|
function Air({name, sensorName, end, duration}) {
|
||||||
const [data, loading, tickFormatter] = useSensor('air', 'Living Room', end, duration);
|
const [data, loading, tickFormatter] = useSensor('air', sensorName, end, duration);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
name='Living Room Air'
|
name={name}
|
||||||
data={data}
|
data={data}
|
||||||
lastFormatter={(x) => x.max_p10?.toFixed(1) + ' ug/m³'}
|
lastFormatter={(x) => x.max_p10?.toFixed(1) + ' ug/m³'}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -807,6 +582,8 @@ function LivingRoomAir({end, duration}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function BedroomSleep({end, duration}) {
|
function BedroomSleep({end, duration}) {
|
||||||
const [data, loading, tickFormatter] = useSensor('sleep', 'Bedroom', end, duration);
|
const [data, loading, tickFormatter] = useSensor('sleep', 'Bedroom', end, duration);
|
||||||
|
|
||||||
@@ -847,12 +624,12 @@ function BedroomSleep({end, duration}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LivingRoomLux({end, duration}) {
|
function Lux({name, sensorName, end, duration}) {
|
||||||
const [data, loading, tickFormatter] = useSensor('lux', 'Living Room', end, duration);
|
const [data, loading, tickFormatter] = useSensor('lux', sensorName, end, duration);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
name='Living Room Lux'
|
name={name}
|
||||||
data={data}
|
data={data}
|
||||||
lastFormatter={(x) => x.lux?.toFixed(1) + ' lx'}
|
lastFormatter={(x) => x.lux?.toFixed(1) + ' lx'}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -892,22 +669,93 @@ function LivingRoomLux({end, duration}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 Graphs({end, duration}) {
|
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 (
|
return (
|
||||||
<div className='container'>
|
<div className='container'>
|
||||||
<SolarPower end={end} duration={duration} />
|
<SolarPower end={end} duration={duration} />
|
||||||
<LivingRoomDust end={end} duration={duration} />
|
<LivingRoomDust end={end} duration={duration} />
|
||||||
<LivingRoomAir end={end} duration={duration} />
|
<Air name='Living Room Air' sensorName='Living Room' end={end} duration={duration} />
|
||||||
<OutsideTemperature end={end} duration={duration} />
|
<Air name='Kitchen Air' sensorName='Kitchen' end={end} duration={duration} />
|
||||||
<BedroomTemperature end={end} duration={duration} />
|
<Air name='Bedroom Air' sensorName='Bedroom' end={end} duration={duration} />
|
||||||
<NookTemperature end={end} duration={duration} />
|
<Temperature name='Outside Temperature' sensorName='Outside' end={end} duration={duration} yDomain={[-40, 40]} showHumidity={false} showFreezingLine={true} />
|
||||||
<SeedsTemperature end={end} duration={duration} />
|
<Temperature name='Bedroom Temperature' sensorName='Bedroom' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
|
||||||
<MiscTemperature end={end} duration={duration} />
|
<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} />
|
<Thermostat end={end} duration={duration} />
|
||||||
<Gas end={end} duration={duration} />
|
<Gas end={end} duration={duration} />
|
||||||
<Water end={end} duration={duration} />
|
<Water end={end} duration={duration} />
|
||||||
<BedroomSleep end={end} duration={duration} />
|
<BedroomSleep end={end} duration={duration} />
|
||||||
<LivingRoomLux 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} />
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
7507
client/yarn.lock
7507
client/yarn.lock
File diff suppressed because it is too large
Load Diff
1
export/.gitignore
vendored
1
export/.gitignore
vendored
@@ -23,3 +23,4 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
image.png
|
image.png
|
||||||
|
data/
|
||||||
|
@@ -1,19 +1,77 @@
|
|||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
from selenium.webdriver.chrome.options import Options
|
from selenium.webdriver.chrome.options import Options
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
|
from selenium.common.exceptions import NoSuchElementException, WebDriverException
|
||||||
|
from webdriver_manager.chrome import ChromeDriverManager
|
||||||
|
import undetected_chromedriver as uc
|
||||||
|
|
||||||
chrome_options = Options()
|
print('Sleeping 10s before loading page...')
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
ser = Service('/usr/lib/chromium-browser/chromedriver')
|
||||||
|
chrome_options = uc.ChromeOptions()
|
||||||
chrome_options.add_argument('--headless')
|
chrome_options.add_argument('--headless')
|
||||||
|
chrome_options.add_argument('start-maximized')
|
||||||
chrome_options.add_argument('--no-sandbox')
|
chrome_options.add_argument('--no-sandbox')
|
||||||
driver = webdriver.Chrome('/usr/lib/chromium-browser/chromedriver', options=chrome_options)
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
driver = uc.Chrome(service=ser, options=chrome_options)
|
||||||
|
except WebDriverException as e:
|
||||||
|
print('Wrong chrome driver version, extracting correct version...')
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
version_string = tb.split('Current browser version is ')[1]
|
||||||
|
major_version = version_string.split('.')[0]
|
||||||
|
print('Trying version:', major_version)
|
||||||
|
|
||||||
|
chrome_options = uc.ChromeOptions()
|
||||||
|
chrome_options.add_argument('--headless')
|
||||||
|
chrome_options.add_argument('start-maximized')
|
||||||
|
chrome_options.add_argument('--no-sandbox')
|
||||||
|
driver = uc.Chrome(service=ser, options=chrome_options, version_main=int(major_version))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
driver.get('https://sensors.dns.t0.vc')
|
driver.get('https://sensors.dns.t0.vc')
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
element = driver.find_element(By.ID, 'Solar_Power')
|
driver.execute_script("return document.getElementsByClassName('menu')[0].remove();")
|
||||||
|
|
||||||
with open('image.png', 'wb') as f:
|
graphs = [
|
||||||
|
'Solar_Power',
|
||||||
|
'Living_Room_Air',
|
||||||
|
'Outside_Temperature',
|
||||||
|
'Bedroom_Temperature',
|
||||||
|
'Nook_Temperature',
|
||||||
|
#'Misc_Temperature',
|
||||||
|
'Basement_Temperature',
|
||||||
|
'Nook_Thermostat',
|
||||||
|
'Gas_Usage',
|
||||||
|
'Water_Usage',
|
||||||
|
'Living_Room_Lux',
|
||||||
|
]
|
||||||
|
|
||||||
|
for graph in graphs:
|
||||||
|
print('Capturing', graph, 'graph...')
|
||||||
|
|
||||||
|
try:
|
||||||
|
element = driver.find_element(By.ID, graph)
|
||||||
|
except NoSuchElementException:
|
||||||
|
print('Graph not found, skipping.')
|
||||||
|
continue
|
||||||
|
|
||||||
|
driver.execute_script('arguments[0].scrollIntoView({block: "center"});', element)
|
||||||
|
|
||||||
|
with open('/home/tanner/sensors/export/data/{}.png'.format(graph), 'wb') as f:
|
||||||
f.write(element.screenshot_as_png)
|
f.write(element.screenshot_as_png)
|
||||||
|
|
||||||
|
finally:
|
||||||
driver.close()
|
driver.close()
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
|
print('done.')
|
||||||
|
|
||||||
|
24
export/requirements.txt
Normal file
24
export/requirements.txt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
async-generator==1.10
|
||||||
|
attrs==22.1.0
|
||||||
|
certifi==2022.6.15
|
||||||
|
cffi==1.15.1
|
||||||
|
charset-normalizer==3.1.0
|
||||||
|
cryptography==37.0.4
|
||||||
|
h11==0.13.0
|
||||||
|
idna==3.3
|
||||||
|
outcome==1.2.0
|
||||||
|
packaging==23.1
|
||||||
|
pycparser==2.21
|
||||||
|
pyOpenSSL==22.0.0
|
||||||
|
PySocks==1.7.1
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
requests==2.28.2
|
||||||
|
selenium==4.8.3
|
||||||
|
sniffio==1.2.0
|
||||||
|
sortedcontainers==2.4.0
|
||||||
|
tqdm==4.65.0
|
||||||
|
trio==0.21.0
|
||||||
|
trio-websocket==0.9.2
|
||||||
|
urllib3==1.26.11
|
||||||
|
webdriver-manager==3.8.6
|
||||||
|
wsproto==1.1.0
|
144
main.py
144
main.py
@@ -19,7 +19,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
import requests
|
import requests
|
||||||
from aiohttp import web, ClientSession, ClientError
|
from aiohttp import web, ClientSession, ClientError
|
||||||
from asyncio_mqtt import Client
|
import aiomqtt
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import pytz
|
import pytz
|
||||||
TIMEZONE = pytz.timezone('America/Edmonton')
|
TIMEZONE = pytz.timezone('America/Edmonton')
|
||||||
@@ -33,6 +33,7 @@ solar_client = InfluxDBClient('localhost', 8086, database='solar2')
|
|||||||
PORT = 6903 if PROD else 6904
|
PORT = 6903 if PROD else 6904
|
||||||
|
|
||||||
def controller_message(message):
|
def controller_message(message):
|
||||||
|
logging.info('Sending controller message: %s', message)
|
||||||
payload = dict(home=message)
|
payload = dict(home=message)
|
||||||
r = requests.post('https://tbot.tannercollin.com/message', data=payload, timeout=10)
|
r = requests.post('https://tbot.tannercollin.com/message', data=payload, timeout=10)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
@@ -78,8 +79,10 @@ class Sensor():
|
|||||||
value = {}
|
value = {}
|
||||||
prev_value = {}
|
prev_value = {}
|
||||||
bad_keys = []
|
bad_keys = []
|
||||||
last_update = time.time()
|
last_update = None
|
||||||
update_period = None
|
update_period = None
|
||||||
|
skip_if_hasnt_changed = False
|
||||||
|
skip_cooldown = 1.0
|
||||||
|
|
||||||
def __init__(self, id_, name):
|
def __init__(self, id_, name):
|
||||||
self.id_ = id_
|
self.id_ = id_
|
||||||
@@ -102,11 +105,25 @@ class Sensor():
|
|||||||
return str(before) != str(after)
|
return str(before) != str(after)
|
||||||
|
|
||||||
def log(self):
|
def log(self):
|
||||||
if not self.value or not self.changed():
|
if not self.value:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.changed() and self.skip_if_hasnt_changed:
|
||||||
|
logging.debug('Skipping writing %s, data hasn\'t changed', self)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.last_update and self.skip_cooldown and time.time() - self.last_update < self.skip_cooldown:
|
||||||
|
logging.debug('Skipping writing %s, cooldown limit', self)
|
||||||
return
|
return
|
||||||
|
|
||||||
data = self.value.copy()
|
data = self.value.copy()
|
||||||
|
|
||||||
|
try:
|
||||||
self.transform(data)
|
self.transform(data)
|
||||||
|
except BaseException as e:
|
||||||
|
logging.exception('Problem transforming sensor data: {} - {}'.format(e.__class__.__name__, str(e)))
|
||||||
|
logging.error('Data: %s', str(data))
|
||||||
|
return
|
||||||
|
|
||||||
for key in self.bad_keys:
|
for key in self.bad_keys:
|
||||||
data.pop(key, None)
|
data.pop(key, None)
|
||||||
@@ -123,21 +140,26 @@ class Sensor():
|
|||||||
'tags': {'id': self.id_, 'name': self.name},
|
'tags': {'id': self.id_, 'name': self.name},
|
||||||
'fields': data,
|
'fields': data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
sensors_client.write_points([point])
|
sensors_client.write_points([point])
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
logging.exception('Error connecting to InfluxDB!')
|
||||||
|
return
|
||||||
|
|
||||||
logging.info('Wrote %s data to InfluxDB: %s', self, data)
|
logging.info('Wrote %s data to InfluxDB: %s', self, data)
|
||||||
|
|
||||||
def check_update(self):
|
def check_update(self):
|
||||||
if self.update_period:
|
if self.update_period and self.last_update:
|
||||||
if time.time() - self.last_update > self.update_period:
|
if time.time() - self.last_update > self.update_period:
|
||||||
logging.warning('Missed expected update from %s.', self)
|
logging.warning('Missed expected update from %s.', self)
|
||||||
self.last_update = time.time()
|
self.last_update = time.time()
|
||||||
|
|
||||||
def update(self, data):
|
def update(self, data):
|
||||||
self.last_update = time.time()
|
|
||||||
self.prev_value = self.value
|
self.prev_value = self.value
|
||||||
self.value = data
|
self.value = data
|
||||||
self.log()
|
self.log()
|
||||||
|
self.last_update = time.time()
|
||||||
|
|
||||||
async def poll(self):
|
async def poll(self):
|
||||||
return
|
return
|
||||||
@@ -156,6 +178,7 @@ class ThermostatSensor(Sensor):
|
|||||||
'dehum_setpoint'
|
'dehum_setpoint'
|
||||||
]
|
]
|
||||||
update_period = 300
|
update_period = 300
|
||||||
|
skip_if_hasnt_changed = True
|
||||||
|
|
||||||
def __init__(self, id_, ip, name):
|
def __init__(self, id_, ip, name):
|
||||||
self.id_ = id_
|
self.id_ = id_
|
||||||
@@ -174,6 +197,11 @@ class ERTSCMSensor(Sensor):
|
|||||||
]
|
]
|
||||||
update_period = 60*60
|
update_period = 60*60
|
||||||
|
|
||||||
|
def transform(self, data):
|
||||||
|
# new gas meter
|
||||||
|
if 'Consumption' in data:
|
||||||
|
data['consumption_data'] = data['Consumption']
|
||||||
|
|
||||||
class OwnTracksSensor(Sensor):
|
class OwnTracksSensor(Sensor):
|
||||||
type_ = 'owntracks'
|
type_ = 'owntracks'
|
||||||
bad_keys = [
|
bad_keys = [
|
||||||
@@ -183,6 +211,7 @@ class OwnTracksSensor(Sensor):
|
|||||||
'created_at',
|
'created_at',
|
||||||
]
|
]
|
||||||
update_period = 90
|
update_period = 90
|
||||||
|
skip_cooldown = False
|
||||||
|
|
||||||
class DustSensor(Sensor):
|
class DustSensor(Sensor):
|
||||||
type_ = 'dust'
|
type_ = 'dust'
|
||||||
@@ -220,6 +249,18 @@ class SleepSensor(Sensor):
|
|||||||
except TypeError:
|
except TypeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class SoilSensor(Sensor):
|
||||||
|
type_ = 'soil'
|
||||||
|
update_period = 90
|
||||||
|
|
||||||
|
def transform(self, data):
|
||||||
|
for key, value in data.items():
|
||||||
|
# what happens if you do this to a timestamp?
|
||||||
|
try:
|
||||||
|
data[key] = float(round(value, 1))
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
|
||||||
class SolarSensor(Sensor):
|
class SolarSensor(Sensor):
|
||||||
type_ = 'solar'
|
type_ = 'solar'
|
||||||
|
|
||||||
@@ -239,7 +280,7 @@ class Acurite606TX(Sensor):
|
|||||||
self.offset = offset
|
self.offset = offset
|
||||||
|
|
||||||
def transform(self, data):
|
def transform(self, data):
|
||||||
if data['battery_ok'] != 1:
|
if data.get('battery_ok', None) != 1:
|
||||||
logging.error('%s battery not ok!', self)
|
logging.error('%s battery not ok!', self)
|
||||||
data['temperature_C'] = float(data['temperature_C']) + self.offset
|
data['temperature_C'] = float(data['temperature_C']) + self.offset
|
||||||
|
|
||||||
@@ -254,16 +295,29 @@ class AcuRite6002RM(Sensor):
|
|||||||
update_period = 40
|
update_period = 40
|
||||||
offset = 0.0
|
offset = 0.0
|
||||||
|
|
||||||
def __init__(self, id_, name, offset=0.0):
|
def __init__(self, id_, name, temp_offset=0.0, hum_offset=0.0):
|
||||||
self.id_ = id_
|
self.id_ = id_
|
||||||
self.name = name
|
self.name = name
|
||||||
self.offset = offset
|
self.temp_offset = temp_offset
|
||||||
|
self.hum_offset = hum_offset
|
||||||
|
|
||||||
def transform(self, data):
|
def transform(self, data):
|
||||||
if data['battery_ok'] != 1:
|
if data.get('battery_ok', None) != 1:
|
||||||
logging.error('%s battery not ok!', self)
|
logging.error('%s battery not ok!', self)
|
||||||
data['temperature_C'] = float(data['temperature_C']) + self.offset
|
|
||||||
data['humidity'] = float(data['humidity'])
|
data['temperature_C'] = float(data['temperature_C']) + self.temp_offset
|
||||||
|
data['humidity'] = float(data['humidity']) + self.hum_offset
|
||||||
|
|
||||||
|
|
||||||
|
class QotMotionSensor(Sensor):
|
||||||
|
type_ = 'qotmotion'
|
||||||
|
update_period = False
|
||||||
|
|
||||||
|
def transform(self, data):
|
||||||
|
split = data['data'].split(',')
|
||||||
|
data['battery'] = int(split[0])
|
||||||
|
data['boots'] = int(split[1])
|
||||||
|
data['motion'] = True # useful to distinguish if I eventually add a heartbeat
|
||||||
|
|
||||||
|
|
||||||
async def poll_sensors():
|
async def poll_sensors():
|
||||||
@@ -280,8 +334,12 @@ async def process_data(data):
|
|||||||
sensor.update(data)
|
sensor.update(data)
|
||||||
|
|
||||||
async def process_mqtt(message):
|
async def process_mqtt(message):
|
||||||
|
try:
|
||||||
text = message.payload.decode()
|
text = message.payload.decode()
|
||||||
topic = message.topic
|
except UnicodeDecodeError:
|
||||||
|
return
|
||||||
|
|
||||||
|
topic = message.topic.value
|
||||||
logging.debug('MQTT topic: %s, message: %s', topic, text)
|
logging.debug('MQTT topic: %s, message: %s', topic, text)
|
||||||
|
|
||||||
if topic.startswith('test'):
|
if topic.startswith('test'):
|
||||||
@@ -299,11 +357,21 @@ async def process_mqtt(message):
|
|||||||
await process_data(data)
|
await process_data(data)
|
||||||
|
|
||||||
async def fetch_mqtt():
|
async def fetch_mqtt():
|
||||||
async with Client('localhost') as client:
|
await asyncio.sleep(3)
|
||||||
async with client.filtered_messages('#') as messages:
|
|
||||||
|
# from https://sbtinstruments.github.io/aiomqtt/reconnection.html
|
||||||
|
# modified to make new client since their code didn't work
|
||||||
|
# https://github.com/sbtinstruments/aiomqtt/issues/269
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with aiomqtt.Client('localhost') as client:
|
||||||
await client.subscribe('#')
|
await client.subscribe('#')
|
||||||
async for message in messages:
|
async for message in client.messages:
|
||||||
await process_mqtt(message)
|
await process_mqtt(message)
|
||||||
|
except aiomqtt.MqttError:
|
||||||
|
logging.info('MQTT connection lost, reconnecting in 5 seconds...')
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
async def owntracks(request):
|
async def owntracks(request):
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
@@ -321,9 +389,15 @@ async def owntracks(request):
|
|||||||
return web.Response()
|
return web.Response()
|
||||||
|
|
||||||
async def history(request):
|
async def history(request):
|
||||||
|
api_key = request.rel_url.query.get('api_key', '')
|
||||||
|
authed = api_key == settings.SENSORS_API_KEY
|
||||||
|
|
||||||
measurement = request.match_info.get('measurement')
|
measurement = request.match_info.get('measurement')
|
||||||
name = request.match_info.get('name')
|
name = request.match_info.get('name')
|
||||||
|
|
||||||
|
if not authed and measurement in ['owntracks', 'sleep']:
|
||||||
|
return web.json_response([])
|
||||||
|
|
||||||
if name not in [x.name for x in sensors.sensors]:
|
if name not in [x.name for x in sensors.sensors]:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -356,7 +430,7 @@ async def history(request):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
window = request.rel_url.query.get('window', window)
|
window = request.rel_url.query.get('window', window)
|
||||||
if window not in ['10m', '1h', '1d', '7d', '30d']:
|
if window not in ['1m', '3m', '10m', '1h', '2h', '1d', '7d', '30d']:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if name == 'Water':
|
if name == 'Water':
|
||||||
@@ -384,6 +458,9 @@ async def history(request):
|
|||||||
elif measurement == 'air':
|
elif measurement == 'air':
|
||||||
client = sensors_client
|
client = sensors_client
|
||||||
q = 'select max("pm10") as max_p10, max("pm25") as max_p25, max("co2") as max_co2, max("voc_idx") as max_voc from air where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
|
q = 'select max("pm10") as max_p10, max("pm25") as max_p25, max("co2") as max_co2, max("voc_idx") as max_voc from air where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
|
||||||
|
elif measurement == 'soil':
|
||||||
|
client = sensors_client
|
||||||
|
q = 'select mean("soil") as soil from soil where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
|
||||||
elif measurement == 'lux':
|
elif measurement == 'lux':
|
||||||
client = sensors_client
|
client = sensors_client
|
||||||
q = 'select mean("lux") as lux from air where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
|
q = 'select mean("lux") as lux from air where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
|
||||||
@@ -393,6 +470,9 @@ async def history(request):
|
|||||||
elif measurement == 'solar':
|
elif measurement == 'solar':
|
||||||
client = solar_client
|
client = solar_client
|
||||||
q = 'select max("actual_total") as actual_total, last("lifetime_energy")-first("lifetime_energy") as lifetime_energy from ecu where time >= {}s and time < {}s group by time({}) fill(linear)'.format(start, end, window)
|
q = 'select max("actual_total") as actual_total, last("lifetime_energy")-first("lifetime_energy") as lifetime_energy from ecu where time >= {}s and time < {}s group by time({}) fill(linear)'.format(start, end, window)
|
||||||
|
elif measurement == 'owntracks':
|
||||||
|
client = sensors_client
|
||||||
|
q = 'select first("lat") as lat, first("lon") as lon from owntracks where "acc" < 100 and "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(previous)'.format(name, start, end, window)
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -411,7 +491,7 @@ async def latest(request):
|
|||||||
if sensor.type_ in ['solar']:
|
if sensor.type_ in ['solar']:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not authed and sensor.type_ in ['owntracks']:
|
if not authed and sensor.type_ in ['owntracks', 'sleep']:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
q = 'select * from {} where "name" = \'{}\' order by desc limit 1'.format(sensor.type_, sensor.name)
|
q = 'select * from {} where "name" = \'{}\' order by desc limit 1'.format(sensor.type_, sensor.name)
|
||||||
@@ -426,7 +506,7 @@ async def latest(request):
|
|||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
|
|
||||||
async def index(request):
|
async def index(request):
|
||||||
return web.Response(text='hello world', content_type='text/html')
|
return web.Response(text='sensors api', content_type='text/html')
|
||||||
|
|
||||||
async def run_webserver():
|
async def run_webserver():
|
||||||
#web.run_app(app, port=PORT, loop=loop)
|
#web.run_app(app, port=PORT, loop=loop)
|
||||||
@@ -444,7 +524,10 @@ def task_died(future):
|
|||||||
logging.error('Sensors server task died!')
|
logging.error('Sensors server task died!')
|
||||||
else:
|
else:
|
||||||
logging.error('Sensors server task died! Waiting 60s and exiting...')
|
logging.error('Sensors server task died! Waiting 60s and exiting...')
|
||||||
|
try:
|
||||||
controller_message('Sensors server task died! Waiting 60s and exiting...')
|
controller_message('Sensors server task died! Waiting 60s and exiting...')
|
||||||
|
except: # we want this to succeed no matter what
|
||||||
|
pass
|
||||||
time.sleep(60)
|
time.sleep(60)
|
||||||
exit()
|
exit()
|
||||||
|
|
||||||
@@ -456,16 +539,31 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
sensors.add(ThermostatSensor('thermostat2', '192.168.69.152', 'Venstar'))
|
sensors.add(ThermostatSensor('thermostat2', '192.168.69.152', 'Venstar'))
|
||||||
sensors.add(ERTSCMSensor('31005493', 'Water'))
|
sensors.add(ERTSCMSensor('31005493', 'Water'))
|
||||||
sensors.add(ERTSCMSensor('41249312', 'Gas'))
|
sensors.add(ERTSCMSensor('78628180', 'Gas'))
|
||||||
sensors.add(OwnTracksSensor('owntracks1', 'OwnTracks'))
|
sensors.add(OwnTracksSensor('owntracks1', 'OwnTracks'))
|
||||||
sensors.add(AirSensor('air1', 'Living Room'))
|
sensors.add(AirSensor('air1', 'Living Room'))
|
||||||
sensors.add(Acurite606TX('59', 'Outside'))
|
sensors.add(AirSensor('air2', 'Bedroom'))
|
||||||
|
sensors.add(AirSensor('air3', 'Kitchen'))
|
||||||
|
sensors.add(Acurite606TX('185', 'Outside', 0.0))
|
||||||
sensors.add(AcuRite6002RM('999999', 'Seeds', 0.0)) # A
|
sensors.add(AcuRite6002RM('999999', 'Seeds', 0.0)) # A
|
||||||
sensors.add(AcuRite6002RM('5613', 'Misc', 0.0)) # A
|
sensors.add(AcuRite6002RM('999998', 'Misc', 0.7, -1.0)) # A
|
||||||
sensors.add(AcuRite6002RM('5109', 'Nook', 0.4)) # B
|
sensors.add(AcuRite6002RM('12516', 'Basement', 0.7, -1.0)) # A
|
||||||
sensors.add(AcuRite6002RM('11087', 'Bedroom', -0.3)) # C
|
sensors.add(AcuRite6002RM('5109', 'Nook', 0.2, -1.0)) # B
|
||||||
|
sensors.add(AcuRite6002RM('11087', 'Bedroom', -0.7, 1.0)) # C
|
||||||
sensors.add(SleepSensor('sleep1', 'Bedroom'))
|
sensors.add(SleepSensor('sleep1', 'Bedroom'))
|
||||||
sensors.add(SolarSensor('solar', 'Solar'))
|
sensors.add(SolarSensor('solar', 'Solar'))
|
||||||
|
sensors.add(SoilSensor('soil1', 'Dumb Cane'))
|
||||||
|
|
||||||
|
sensors.add(QotMotionSensor('qot_dc3c', 'Bedroom'))
|
||||||
|
sensors.add(QotMotionSensor('qot_88c3', 'Lower Stairs Hi'))
|
||||||
|
sensors.add(QotMotionSensor('qot_7c3c', 'Theatre'))
|
||||||
|
sensors.add(QotMotionSensor('qot_54e6', 'Lab'))
|
||||||
|
sensors.add(QotMotionSensor('qot_10f4', 'Office'))
|
||||||
|
sensors.add(QotMotionSensor('qot_74c3', 'Guest Bathroom'))
|
||||||
|
sensors.add(QotMotionSensor('qot_706f', 'Nook'))
|
||||||
|
sensors.add(QotMotionSensor('qot_8c1c', 'Kitchen S'))
|
||||||
|
sensors.add(QotMotionSensor('qot_a83b', 'Kitchen N'))
|
||||||
|
sensors.add(QotMotionSensor('qot_28c3', 'Side Entrance'))
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
a = loop.create_task(poll_sensors()).add_done_callback(task_died)
|
a = loop.create_task(poll_sensors()).add_done_callback(task_died)
|
||||||
|
23
mapper/.gitignore
vendored
Normal file
23
mapper/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
40
mapper/package.json
Normal file
40
mapper/package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "mapper",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@testing-library/jest-dom": "^5.11.4",
|
||||||
|
"@testing-library/react": "^11.1.0",
|
||||||
|
"@testing-library/user-event": "^12.1.10",
|
||||||
|
"axios": "^0.21.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet-polylinedecorator": "^1.6.0",
|
||||||
|
"moment": "^2.29.1",
|
||||||
|
"moment-timezone": "^0.5.34",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-datetime": "^3.1.1",
|
||||||
|
"react-dom": "^17.0.2",
|
||||||
|
"react-is": "^17.0.2",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-range-slider-input": "^3.0.7",
|
||||||
|
"react-scripts": "4.0.3",
|
||||||
|
"web-vitals": "^1.0.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
]
|
||||||
|
}
|
42
mapper/public/index.html
Normal file
42
mapper/public/index.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>Tanner's Mapper</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet/dist/leaflet.css"
|
||||||
|
crossorigin=""/>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||||
|
crossorigin=""></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
195
mapper/src/App.css
Normal file
195
mapper/src/App.css
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 38em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: normal;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharts-wrapper p {
|
||||||
|
color: initial;
|
||||||
|
font-size: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #333;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slider {
|
||||||
|
padding: 1em 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range {
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu {
|
||||||
|
background-color: #666;
|
||||||
|
max-width: 40em;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu h2 {
|
||||||
|
color: white;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-container {
|
||||||
|
max-width: 40em;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button {
|
||||||
|
background-color: #333;
|
||||||
|
height: 2.5rem;
|
||||||
|
min-width: 3rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: white;
|
||||||
|
border-radius: 0;
|
||||||
|
border: 0;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button:hover {
|
||||||
|
background-color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button.active {
|
||||||
|
background-color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu button {
|
||||||
|
background-color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-checkbox-label {
|
||||||
|
/* Make it look like a button */
|
||||||
|
background-color: #666;
|
||||||
|
height: 2.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: sans-serif;
|
||||||
|
|
||||||
|
/* Center content */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-checkbox-label:hover {
|
||||||
|
background-color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-checkbox-label input[type="checkbox"] {
|
||||||
|
/* Reset default styles */
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
background-color: transparent;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
/* Custom checkbox style */
|
||||||
|
font: inherit;
|
||||||
|
color: currentColor;
|
||||||
|
width: 0.75em;
|
||||||
|
height: 0.75em;
|
||||||
|
border: 0.1em solid currentColor;
|
||||||
|
border-radius: 0;
|
||||||
|
transform: translateY(-0.075em);
|
||||||
|
|
||||||
|
/* For the checkmark */
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for Firefox Mobile rendering a black background on checked state,
|
||||||
|
especially when extensions like Dark Reader are active. */
|
||||||
|
.submenu-checkbox-label input[type="checkbox"]:checked {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-checkbox-label input[type="checkbox"]::before {
|
||||||
|
content: "";
|
||||||
|
width: 0.75em;
|
||||||
|
height: 0.75em;
|
||||||
|
transform: scale(0);
|
||||||
|
transition: 120ms transform ease-in-out;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-checkbox-label input[type="checkbox"]:checked::before {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker .rdtPicker {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker th:hover,
|
||||||
|
.datepicker td:hover {
|
||||||
|
background-color: #999!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-actions {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-group span {
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-group button {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
562
mapper/src/App.js
Normal file
562
mapper/src/App.js
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
import * as leaflet from 'leaflet';
|
||||||
|
import 'leaflet-polylinedecorator';
|
||||||
|
import { MapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet';
|
||||||
|
import Datetime from 'react-datetime';
|
||||||
|
import 'react-datetime/css/react-datetime.css';
|
||||||
|
import axios from 'axios';
|
||||||
|
import moment from 'moment-timezone';
|
||||||
|
import RangeSlider from 'react-range-slider-input';
|
||||||
|
import './App.css';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import 'react-range-slider-input/dist/style.css';
|
||||||
|
|
||||||
|
|
||||||
|
// num: number of steps per duration
|
||||||
|
// secs: number of seconds per step
|
||||||
|
const durations = [
|
||||||
|
{id: 0, len: 'Day', win: '1m', full: '1 min', delta: [1, 'days'], format: 'HH', num: 1440, secs: 60},
|
||||||
|
{id: 1, len: 'Week', win: '3m', full: '3 min', delta: [7, 'days'], format: 'HH', num: 3360, secs: 180},
|
||||||
|
{id: 2, len: 'Month', shortLen: 'Mth', win: '10m', full: '10 min', delta: [1, 'months'], format: 'D', num: 4380, secs: 600},
|
||||||
|
{id: 3, len: 'Year', win: '2h', full: '2 hour', delta: [1, 'years'], format: 'M/D', num: 4380, secs: 7200},
|
||||||
|
];
|
||||||
|
|
||||||
|
const parseSlider = (end, duration, slider) => {
|
||||||
|
//console.log(slider);
|
||||||
|
// good luck remembering how this works
|
||||||
|
const lowOffset = slider[0] * duration.secs - duration.num * duration.secs;
|
||||||
|
const highOffset = slider[1] * duration.secs - duration.num * duration.secs;
|
||||||
|
|
||||||
|
const low = moment.unix(end.unix() + lowOffset);
|
||||||
|
const high = moment.unix(end.unix() + highOffset);
|
||||||
|
|
||||||
|
const lowStr = low.utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
|
||||||
|
const highStr = high.utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
|
||||||
|
|
||||||
|
//console.log(lowStr, highStr);
|
||||||
|
return [lowStr, highStr];
|
||||||
|
};
|
||||||
|
|
||||||
|
//async function sha256(source) {
|
||||||
|
// const sourceBytes = new TextEncoder().encode(source);
|
||||||
|
// const digest = await crypto.subtle.digest('SHA-26', sourceBytes);
|
||||||
|
// const resultBytes = [...new Uint8Array(digest)];
|
||||||
|
// return resultBytes.map(x => x.toString(16).padStart(2, '0')).join('');
|
||||||
|
//}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return [data, loading];
|
||||||
|
};
|
||||||
|
|
||||||
|
function MapViewManager({ coords, mapState, setMapState, loading, setSubmenu }) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
// Effect 1: Handle map events (pan/zoom) from the user
|
||||||
|
useMapEvents({
|
||||||
|
mousedown: () => setSubmenu(false),
|
||||||
|
zoomstart: () => setSubmenu(false),
|
||||||
|
moveend: () => {
|
||||||
|
const center = map.getCenter();
|
||||||
|
const newZoom = map.getZoom();
|
||||||
|
const newCenter = [center.lat, center.lng];
|
||||||
|
|
||||||
|
setMapState(prevState => {
|
||||||
|
// A small tolerance for floating point comparisons
|
||||||
|
const tolerance = 1e-5;
|
||||||
|
if (!prevState.center) {
|
||||||
|
return { zoom: newZoom, center: newCenter };
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomChanged = prevState.zoom !== newZoom;
|
||||||
|
const centerChanged = Math.abs(prevState.center[0] - newCenter[0]) > tolerance ||
|
||||||
|
Math.abs(prevState.center[1] - newCenter[1]) > tolerance;
|
||||||
|
|
||||||
|
if (zoomChanged || centerChanged) {
|
||||||
|
return { zoom: newZoom, center: newCenter };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing changed, return the previous state to prevent a re-render
|
||||||
|
return prevState;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Effect 2: Handle programmatic view changes (refitting or setting from state)
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't do anything while loading new data
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
// Case A: A refit is needed (signaled by null center)
|
||||||
|
if (mapState.center === null && coords.length > 0) {
|
||||||
|
const bounds = leaflet.latLngBounds(coords);
|
||||||
|
if (bounds.isValid()) {
|
||||||
|
map.fitBounds(bounds);
|
||||||
|
// After fitting, the 'moveend' event will fire and update the state naturally.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Case B: A center is set in the state, ensure map is synced
|
||||||
|
else if (mapState.center) {
|
||||||
|
const currentCenter = map.getCenter();
|
||||||
|
const currentZoom = map.getZoom();
|
||||||
|
if (currentCenter.lat !== mapState.center[0] || currentCenter.lng !== mapState.center[1] || currentZoom !== mapState.zoom) {
|
||||||
|
map.setView(mapState.center, mapState.zoom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [coords, mapState, loading, map, setMapState]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolylineWithArrows({ coords, showDirection }) {
|
||||||
|
const map = useMap();
|
||||||
|
const polylineRef = useRef(null);
|
||||||
|
const decoratorRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (polylineRef.current) {
|
||||||
|
map.removeLayer(polylineRef.current);
|
||||||
|
}
|
||||||
|
if (decoratorRef.current) {
|
||||||
|
map.removeLayer(decoratorRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coords && coords.length > 1) {
|
||||||
|
const polyline = leaflet.polyline(coords, { color: 'blue' });
|
||||||
|
polylineRef.current = polyline;
|
||||||
|
map.addLayer(polyline);
|
||||||
|
|
||||||
|
if (showDirection) {
|
||||||
|
const decorator = leaflet.polylineDecorator(polyline, {
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
offset: 25,
|
||||||
|
repeat: 100,
|
||||||
|
symbol: leaflet.Symbol.arrowHead({
|
||||||
|
pixelSize: 10,
|
||||||
|
polygon: false,
|
||||||
|
pathOptions: {
|
||||||
|
stroke: true,
|
||||||
|
color: 'blue',
|
||||||
|
weight: 3
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
decoratorRef.current = decorator;
|
||||||
|
map.addLayer(decorator);
|
||||||
|
} else {
|
||||||
|
decoratorRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (polylineRef.current) {
|
||||||
|
map.removeLayer(polylineRef.current);
|
||||||
|
}
|
||||||
|
if (decoratorRef.current) {
|
||||||
|
map.removeLayer(decoratorRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [coords, map, showDirection]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Map({end, duration, slider, mapState, setMapState, setSubmenu, showDirection}) {
|
||||||
|
const [data, loading] = useSensor('owntracks', 'OwnTracks', end, duration);
|
||||||
|
|
||||||
|
const range = useMemo(() => parseSlider(end, duration, slider), [end, duration, slider]);
|
||||||
|
|
||||||
|
const coords = useMemo(() => {
|
||||||
|
// 1. Guard against invalid top-level data
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
const [startTime, endTime] = range;
|
||||||
|
|
||||||
|
// 2. Loop through the data
|
||||||
|
for (const point of data) {
|
||||||
|
// 3. Guard against malformed points
|
||||||
|
if (!point || typeof point !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lat, lon, time } = point;
|
||||||
|
|
||||||
|
// 4. Guard against invalid time
|
||||||
|
if (typeof time !== 'string' || time.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Guard against invalid coordinates (null, undefined, NaN, non-number)
|
||||||
|
if (typeof lat !== 'number' || typeof lon !== 'number' || !isFinite(lat) || !isFinite(lon)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Now that all data is known to be valid, filter by time
|
||||||
|
if (time >= startTime && time <= endTime) {
|
||||||
|
result.push([lat, lon]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [data, range]);
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const api_key = e.target[0].value;
|
||||||
|
localStorage.setItem('api_key', api_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='container'>
|
||||||
|
{loading ?
|
||||||
|
<p>Loading...</p>
|
||||||
|
:
|
||||||
|
coords.length ?
|
||||||
|
(
|
||||||
|
<MapContainer center={mapState.center || [0, 0]} zoom={mapState.zoom} scrollWheelZoom={true} style={{ width: '100%', height: 'calc(100vh - 2.5rem)' }}>
|
||||||
|
<MapViewManager coords={coords} mapState={mapState} setMapState={setMapState} loading={loading} setSubmenu={setSubmenu} />
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url='https://maptiles.p.rapidapi.com/en/map/v1/{z}/{x}/{y}.png?rapidapi-key=4375b0b1d8msh0c9e7fa3efb9adfp1769dfjsnd603a0387fea'
|
||||||
|
/>
|
||||||
|
<PolylineWithArrows coords={coords} showDirection={showDirection} />
|
||||||
|
</MapContainer>
|
||||||
|
)
|
||||||
|
:
|
||||||
|
<>
|
||||||
|
<p>No data</p>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<p>
|
||||||
|
<input placeholder='API key' />
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Menu({duration, setDuration, end, setEnd, slider, setSlider, submenu, setSubmenu, showDirection, setShowDirection}) {
|
||||||
|
const [showRange, setShowRange] = useState(false);
|
||||||
|
|
||||||
|
const chooseDuration = (x) => {
|
||||||
|
setSubmenu(false);
|
||||||
|
setSlider([0, x.num]);
|
||||||
|
setDuration(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseEnd = (x) => {
|
||||||
|
setSubmenu(false);
|
||||||
|
const newEnd = x.add(...duration.delta);
|
||||||
|
setSlider([0, duration.num]);
|
||||||
|
setEnd(newEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseNow = (x) => {
|
||||||
|
setSubmenu(false);
|
||||||
|
setSlider([0, duration.num]);
|
||||||
|
setEnd(moment());
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseMidnight = () => {
|
||||||
|
setSubmenu(false);
|
||||||
|
setSlider([0, duration.num]);
|
||||||
|
setEnd(moment().startOf('day'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const rangeStart = (x) => {
|
||||||
|
setSubmenu(false);
|
||||||
|
setEnd(moment(range[0]).add(...duration.delta));
|
||||||
|
setSlider([0, duration.num]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rangeEnd = (x) => {
|
||||||
|
setSubmenu(false);
|
||||||
|
setEnd(moment(range[1]));
|
||||||
|
setSlider([0, duration.num]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
setSubmenu(false);
|
||||||
|
setSlider([0, duration.num]);
|
||||||
|
setEnd(prevEnd => moment(prevEnd).add(...duration.delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = () => {
|
||||||
|
setSubmenu(false);
|
||||||
|
setSlider([0, duration.num]);
|
||||||
|
setEnd(prevEnd => moment(prevEnd).subtract(...duration.delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToDefaults = () => {
|
||||||
|
window.location.href = window.location.pathname;
|
||||||
|
};
|
||||||
|
|
||||||
|
const range = parseSlider(end, duration, slider);
|
||||||
|
|
||||||
|
const rangeTime = (x) => {
|
||||||
|
if (new Date().getTimezoneOffset()) { // non-librewolf browser
|
||||||
|
return moment(x).format('lll'); // default to browser's TZ
|
||||||
|
} else {
|
||||||
|
return moment(x).tz('America/Edmonton').format('lll');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rangeDelta = (range) => {
|
||||||
|
const start = moment(range[0]);
|
||||||
|
const end = moment(range[1]);
|
||||||
|
const diff = moment.duration(end.diff(start));
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
const years = diff.years();
|
||||||
|
if (years > 0) parts.push(`${years} year${years > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const months = diff.months();
|
||||||
|
if (months > 0) parts.push(`${months} month${months > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const days = diff.days();
|
||||||
|
if (days > 0) parts.push(`${days} day${days > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const hours = diff.hours();
|
||||||
|
if (hours > 0) parts.push(`${hours} hour${hours > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const minutes = diff.minutes();
|
||||||
|
if (minutes > 0) parts.push(`${minutes} minute${minutes > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const seconds = diff.seconds();
|
||||||
|
if (seconds > 0) parts.push(`${seconds} second${seconds > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return '0 seconds';
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='menu'>
|
||||||
|
{(showRange || !!submenu) && <div className='range'>
|
||||||
|
{rangeTime(range[0])} - {rangeTime(range[1])} <span style={{ whiteSpace: 'nowrap' }}>({rangeDelta(range)})</span>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
<div className='time-slider'>
|
||||||
|
<RangeSlider
|
||||||
|
min={0}
|
||||||
|
max={duration.num}
|
||||||
|
value={slider}
|
||||||
|
onInput={setSlider}
|
||||||
|
onThumbDragStart={() => setShowRange(true)}
|
||||||
|
onThumbDragEnd={() => setShowRange(false)}
|
||||||
|
onRangeDragStart={() => setShowRange(true)}
|
||||||
|
onRangeDragEnd={() => setShowRange(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!!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>
|
||||||
|
|
||||||
|
<div className='submenu-actions'>
|
||||||
|
<div className='submenu-group'>
|
||||||
|
<span>Jump to:</span>
|
||||||
|
<button onClick={chooseNow}>Now</button>
|
||||||
|
<button onClick={chooseMidnight}>Midnight</button>
|
||||||
|
</div>
|
||||||
|
<div className='submenu-group'>
|
||||||
|
<span>Shift to:</span>
|
||||||
|
<button onClick={rangeStart}>Range Start</button>
|
||||||
|
<button onClick={rangeEnd}>Range End</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
{submenu === 'misc' &&
|
||||||
|
<>
|
||||||
|
<div className='submenu-header'>
|
||||||
|
<h2>Misc</h2>
|
||||||
|
<button onClick={() => setSubmenu(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<label className="submenu-checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showDirection}
|
||||||
|
onChange={e => setShowDirection(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Show direction
|
||||||
|
</label>
|
||||||
|
<button onClick={resetToDefaults}>Reset to defaults</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className='menu-container'>
|
||||||
|
<button onClick={() => prev()}><</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSubmenu('end')}
|
||||||
|
className={submenu === 'end' ? 'active' : ''}
|
||||||
|
>
|
||||||
|
{moment(end).subtract(duration.delta[0], duration.delta[1]).format('dd MMM DD')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSubmenu('misc')}
|
||||||
|
className={submenu === 'misc' ? 'active' : ''}
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSubmenu('duration')}
|
||||||
|
className={submenu === 'duration' ? 'active' : ''}
|
||||||
|
>
|
||||||
|
{(duration.shortLen || duration.len)} / {duration.win}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => next()}>></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const initialDurationId = params.get('duration');
|
||||||
|
const initialEndTimestamp = params.get('end');
|
||||||
|
const initialSliderValue = params.get('slider');
|
||||||
|
const initialLat = params.get('lat');
|
||||||
|
const initialLng = params.get('lng');
|
||||||
|
const initialZoom = params.get('zoom');
|
||||||
|
const initialShowDirection = params.get('showDirection') === 'true';
|
||||||
|
|
||||||
|
const initialDuration = (initialDurationId && durations[parseInt(initialDurationId, 10)]) ? durations[parseInt(initialDurationId, 10)] : durations[0];
|
||||||
|
const initialEnd = initialEndTimestamp ? moment.unix(initialEndTimestamp) : moment();
|
||||||
|
const initialSlider = initialSliderValue ? initialSliderValue.split(',').map(Number) : [0, initialDuration.num];
|
||||||
|
|
||||||
|
const [duration, setDuration] = useState(initialDuration);
|
||||||
|
const [end, setEnd] = useState(initialEnd);
|
||||||
|
const [slider, setSlider] = useState(initialSlider);
|
||||||
|
const [mapState, setMapState] = useState({
|
||||||
|
center: (initialLat && initialLng) ? [parseFloat(initialLat), parseFloat(initialLng)] : null,
|
||||||
|
zoom: initialZoom ? parseInt(initialZoom, 10) : 13,
|
||||||
|
});
|
||||||
|
const [submenu, setSubmenu] = useState(false);
|
||||||
|
const [showDirection, setShowDirection] = useState(initialShowDirection);
|
||||||
|
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
} else {
|
||||||
|
// Reset map center to trigger a refit when new data arrives
|
||||||
|
setMapState(prev => ({ ...prev, center: null }));
|
||||||
|
}
|
||||||
|
}, [end, duration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('duration', duration.id);
|
||||||
|
params.set('end', end.unix());
|
||||||
|
params.set('slider', slider.join(','));
|
||||||
|
if (showDirection) {
|
||||||
|
params.set('showDirection', 'true');
|
||||||
|
}
|
||||||
|
if (mapState.center) {
|
||||||
|
params.set('lat', mapState.center[0].toFixed(5));
|
||||||
|
params.set('lng', mapState.center[1].toFixed(5));
|
||||||
|
params.set('zoom', mapState.zoom);
|
||||||
|
}
|
||||||
|
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [duration, end, slider, mapState, showDirection]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Menu
|
||||||
|
duration={duration}
|
||||||
|
setDuration={setDuration}
|
||||||
|
end={end}
|
||||||
|
setEnd={setEnd}
|
||||||
|
slider={slider}
|
||||||
|
setSlider={setSlider}
|
||||||
|
submenu={submenu}
|
||||||
|
setSubmenu={setSubmenu}
|
||||||
|
showDirection={showDirection}
|
||||||
|
setShowDirection={setShowDirection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Map
|
||||||
|
end={end}
|
||||||
|
duration={duration}
|
||||||
|
slider={slider}
|
||||||
|
mapState={mapState}
|
||||||
|
setMapState={setMapState}
|
||||||
|
setSubmenu={setSubmenu}
|
||||||
|
showDirection={showDirection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
10
mapper/src/index.js
Normal file
10
mapper/src/index.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
document.getElementById('root')
|
||||||
|
);
|
12081
mapper/yarn.lock
Normal file
12081
mapper/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
aiohttp==3.8.1
|
aiohttp==3.8.1
|
||||||
|
aiomqtt==2.0.0
|
||||||
aiosignal==1.2.0
|
aiosignal==1.2.0
|
||||||
async-timeout==4.0.2
|
async-timeout==4.0.2
|
||||||
asyncio-mqtt==0.11.0
|
asyncio-mqtt==0.11.0
|
||||||
@@ -12,11 +13,10 @@ influxdb==5.3.1
|
|||||||
msgpack==1.0.3
|
msgpack==1.0.3
|
||||||
multidict==5.2.0
|
multidict==5.2.0
|
||||||
paho-mqtt==1.6.1
|
paho-mqtt==1.6.1
|
||||||
pkg_resources==0.0.0
|
|
||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
pytz==2021.3
|
pytz==2021.3
|
||||||
requests==2.26.0
|
requests==2.26.0
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
typing_extensions==4.0.1
|
typing-extensions==4.9.0
|
||||||
urllib3==1.26.7
|
urllib3==1.26.7
|
||||||
yarl==1.7.2
|
yarl==1.7.2
|
||||||
|
Reference in New Issue
Block a user