Compare commits

...

80 Commits

Author SHA1 Message Date
25d6a8757b Add leaflet-polylinedecorator 2025-08-14 23:17:17 +00:00
b585a39dd0 fix: Force transparent checkbox background to override extensions 2025-08-14 23:14:05 +00:00
a3c7f85302 fix: Set transparent background for checked checkbox on Firefox Mobile 2025-08-14 23:10:49 +00:00
646ca1268e style: apply sans-serif font to menu buttons 2025-08-14 23:09:21 +00:00
562c7cb6eb style: Refine header font and checkbox styles 2025-08-14 23:09:16 +00:00
cb8129cbba style: Restyle 'Show direction' checkbox to match menu buttons 2025-08-14 23:04:18 +00:00
502ae2b982 feat: add checkbox to toggle direction arrows 2025-08-14 23:01:02 +00:00
1f744216ec feat: add direction arrows to polyline 2025-08-14 22:56:59 +00:00
ec7fbed514 fix: shorten day of week format to two letters 2025-08-14 22:56:54 +00:00
3d927c18ce feat: add reset button and correct date logic 2025-08-14 22:39:15 +00:00
f309c0af00 fix: Use two-letter day format in menu 2025-08-14 22:36:36 +00:00
7e0eddaf38 fix: Correct month short name from Mon to Mth 2025-08-14 22:33:45 +00:00
1875d7b4e7 fix: abbreviate Month duration to prevent button text wrap 2025-08-14 22:26:05 +00:00
959e1d85d0 feat: add Misc submenu 2025-08-14 22:08:32 +00:00
2be0dd1c3d fix: preserve selected date when choosing midnight 2025-08-14 22:08:27 +00:00
0708301396 style: Reduce submenu header font size 2025-08-14 21:53:53 +00:00
fbc15bb371 feat: add jump to midnight button and rearrange submenu actions 2025-08-14 21:46:26 +00:00
ca3202f9b7 feat: Close submenu on map interaction 2025-08-14 21:42:20 +00:00
435db835e9 refactor: unify map view logic to fix centering and data bugs 2025-08-14 21:18:09 +00:00
87e706c223 fix: return null from FitBounds component 2025-08-14 21:14:33 +00:00
478dca185e fix: prevent map freeze and fix repositioning race condition 2025-08-14 21:12:50 +00:00
b295c3fef0 fix: wait for data to load before fitting map bounds 2025-08-14 20:59:14 +00:00
13b35e1c00 fix: Validate coordinate points before processing to prevent freeze 2025-08-14 20:55:38 +00:00
2adc0a9fcb perf: memoize coordinate processing to prevent UI freeze 2025-08-14 20:25:18 +00:00
0a02db9a8d fix: filter invalid coordinates to prevent UI freeze 2025-08-14 20:21:57 +00:00
bdc2921bc0 fix: Resolve race condition when refitting map on date change 2025-08-14 20:16:07 +00:00
9dd772839b feat: Refit map bounds on end or duration change 2025-08-14 20:12:09 +00:00
4bc88e5ce9 feat: Fit map to all points on initial load 2025-08-14 20:08:44 +00:00
6c7dff2d8f fix: prevent infinite loop by checking map state on moveend 2025-08-14 20:01:09 +00:00
21cec132a7 feat: encode map position and zoom in URL 2025-08-14 19:58:05 +00:00
51031e7b20 fix: debounce URL updates to prevent History API errors 2025-08-14 19:52:37 +00:00
81880a6a0a feat: Store view state in URL for shareable links 2025-08-14 19:49:16 +00:00
44dcc1b8ad fix: Prevent range delta from wrapping mid-text 2025-08-14 19:44:55 +00:00
17b1f979a9 feat: implement rangeDelta to display time range duration 2025-08-14 19:40:06 +00:00
00d9ee362f feat: add rangeDelta function 2025-08-14 19:39:56 +00:00
578bed681a Change temperature chart settings 2025-07-03 22:41:22 +00:00
816624ec44 refactor: Abstract temperature components into a single generic component 2025-07-03 22:41:22 +00:00
1052cf9bb9 refactor: abstract air quality components 2025-07-03 22:41:22 +00:00
3c8393b14c refactor: Rename SoilMoisture to Soil and hardcode sensorId 2025-07-03 22:41:22 +00:00
d5f5e08a3c refactor: Abstract Lux components into single component 2025-07-03 22:41:22 +00:00
f93e6d2323 feat: Add soil moisture graphs for Kitchen Pothos and Dracaena 2025-07-03 22:41:22 +00:00
24bada26a4 refactor: Abstract DumbCaneSoil to generic SoilMoisture component 2025-07-03 22:41:22 +00:00
4202e1a19d Add Dumb Cane soil sensor 2025-07-03 22:41:22 +00:00
ecd1dab005 Add soil sensors, cooldown skipping, make dupe skipping optional 2025-06-20 16:59:25 +00:00
54e169bdd2 Handle transform() exception 2025-06-20 16:59:25 +00:00
49f9ee120b Add Kitchen and Bedroom air / lux sensors 2025-05-13 19:17:13 +00:00
fa8f2cddb5 Fix bugs, add Qot motion sensors 2024-08-01 17:53:23 +00:00
7b15b39d5f Simplify components, add sha256() for later 2024-07-16 02:02:55 +00:00
e5dbb0af39 Allow shifting by time range 2024-07-16 01:30:23 +00:00
e549afce96 Add slider for time range 2024-07-15 21:41:09 +00:00
45272a6242 Switch export to basement temperature 2024-07-15 19:41:17 +00:00
61fd657952 Take API key, add basement sensor 2024-07-15 19:41:17 +00:00
88dbba168c Take API key, adjust ranges 2024-07-15 19:41:17 +00:00
ba630b6fb9 Extract correct chrome version from exception 2024-07-15 19:41:17 +00:00
34f0444de7 Make task_died work with no internet 2024-02-08 20:32:43 +00:00
8abb15cdd3 Update / switch to aiomqtt, reconnect mqtt on error 2024-02-08 20:32:43 +00:00
1346171618 Handle new gas meter, owntracks, API key 2024-02-08 20:32:43 +00:00
e38164fd43 Sensors export fixes 2023-10-03 09:33:06 +00:00
b275305434 Create mapper to visualize owntracks data 2023-10-03 09:32:27 +00:00
1d5f63f86d Wrap export in try: finally: 2023-05-08 21:15:39 +00:00
c693d30394 Handle errors, remove menu bar, centre graphs 2023-04-18 21:19:24 +00:00
a7ca48dacf Rename .gitkeep to index.html 2023-04-18 21:19:24 +00:00
08b7196c26 Capture all sensor graphs 2023-04-18 21:19:24 +00:00
29ac0345c6 Ignore data/ folder 2023-04-18 21:19:24 +00:00
b393b88127 Freeze requirements 2023-04-18 21:19:24 +00:00
43535d0a95 Store strong references to async tasks
Reason: https://news.t0.vc/IEUC
2023-02-12 18:37:41 +00:00
47cdfff327 Change outside sensor ID 2023-02-12 18:36:55 +00:00
685e7917df Move seeds temp to misc temp 2023-01-12 18:31:53 +00:00
ed38f1fafe Add Misc temperature graph 2022-12-19 19:30:56 +00:00
6b1a23271b Begin sensor image exporter 2022-12-19 19:30:56 +00:00
8dce70d9d0 Freeze requirements 2022-10-03 21:57:06 +00:00
c618a24126 Add auth to sensors API 2022-10-03 21:51:42 +00:00
94f023f70c Change domains 2022-08-05 19:05:10 +00:00
2eb1cb7e4b Add Air quality and proper units 2022-08-05 19:05:10 +00:00
2b1be5344a Remove owntracks from /latest 2022-06-21 07:09:55 +00:00
6a236b198a Ignore .csv 2022-06-20 05:50:57 +00:00
9d6997bfc7 Add sensors, /latest API route 2022-06-20 05:50:57 +00:00
9157ff6da1 Fix timezone 2022-04-16 20:51:41 +00:00
890d02a616 Add seeds, fix last value bug 2022-03-02 22:24:18 +00:00
6e4b1b062b Add sensors 2022-02-13 08:28:55 +00:00
17 changed files with 17883 additions and 3515 deletions

2
.gitignore vendored
View File

@@ -103,3 +103,5 @@ ENV/
*.swo *.swo
settings.py settings.py
*.csv
.aider*

View File

@@ -19,6 +19,21 @@ const durations = [
{id: 7, len: 'Year', win: '30d', full: '30 day', delta: [1, 'years'], format: 'M'}, {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) { function useSensor(measurement, name, end, duration) {
const [data, setData] = useState(false); const [data, setData] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -27,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 },
@@ -89,10 +105,23 @@ function ChartContainer({name, data, lastFormatter, loading, children, topMargin
); );
} }
const last = data.length ? lastFormatter(data.slice(-1)[0]) : null; 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 ( return (
<div className='chart'> <div className='chart' id={name.replace(/ /g,'_')}>
<h2>{name}: {loading ? 'Loading...' : last || 'No data'}</h2> <h2>{name}: {loading ? 'Loading...' : last || 'No data'}</h2>
<ResponsiveContainer width='100%' height={300}> <ResponsiveContainer width='100%' height={300}>
@@ -121,24 +150,43 @@ function SolarPower({end, duration}) {
minTickGap={10} minTickGap={10}
tickFormatter={tickFormatter} tickFormatter={tickFormatter}
/> />
<YAxis <YAxis
yAxisId='right'
domain={[0, 10]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='left'
domain={[0, 6000]} domain={[0, 6000]}
/> />
<CartesianGrid strokeDasharray='3 3'/> <CartesianGrid strokeDasharray='3 3'/>
<Tooltip <Tooltip
formatter={v => v + ' W'} 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 x={moment().startOf('day').toISOString().replace('.000', '')} stroke='blue'> <ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue'>
<Label value='Midnight' offset={7} position='top' /> <Label value='Midnight' offset={7} position='top' />
</ReferenceLine> </ReferenceLine>
<Bar
yAxisId='right'
type='monotone'
dataKey='lifetime_energy'
name='Energy'
fill='green'
isAnimationActive={false}
/>
<Line <Line
yAxisId='left'
type='monotone' type='monotone'
dataKey='actual_total' dataKey='actual_total'
name='Total Power' name='Power'
stroke='black' stroke='black'
strokeWidth={2} strokeWidth={2}
dot={false} dot={false}
@@ -148,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}
@@ -163,23 +211,35 @@ function OutsideTemperature({end, duration}) {
minTickGap={10} minTickGap={10}
tickFormatter={tickFormatter} tickFormatter={tickFormatter}
/> />
{showHumidity &&
<YAxis
yAxisId='right'
domain={[0, 100]}
orientation='right'
hide={true}
/>
}
<YAxis <YAxis
domain={[-40, 40]} 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='blue'> {showFreezingLine &&
<Label value='Freezing' offset={7} position='bottom' /> <ReferenceLine yAxisId={showHumidity ? 'left' : undefined} y={0} stroke='purple'>
</ReferenceLine> <Label value='Freezing' offset={7} position='bottom' />
</ReferenceLine>
}
<ReferenceLine x={moment().startOf('day').toISOString().replace('.000', '')} stroke='blue' /> <ReferenceLine yAxisId={showHumidity ? 'left' : undefined} x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line <Line
yAxisId={showHumidity ? 'left' : undefined}
type='monotone' type='monotone'
dataKey='temperature_C' dataKey='temperature_C'
name='Temperature' name='Temperature'
@@ -188,97 +248,22 @@ function OutsideTemperature({end, duration}) {
dot={false} dot={false}
isAnimationActive={false} isAnimationActive={false}
/> />
{showHumidity &&
<Line
yAxisId='right'
type='monotone'
dataKey='humidity'
name='Humidity'
stroke='blue'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
}
</ChartContainer> </ChartContainer>
); );
} }
function NookTemperature({end, duration}) {
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
domain={[15, 25]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={v => v.toFixed(1) + ' °C'}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine y={0} stroke='blue'>
<Label value='Freezing' offset={7} position='bottom' />
</ReferenceLine>
<ReferenceLine x={moment().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 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
domain={[15, 25]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={v => v.toFixed(1) + ' °C'}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine y={0} stroke='blue'>
<Label value='Freezing' offset={7} position='bottom' />
</ReferenceLine>
<ReferenceLine x={moment().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 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);
@@ -295,19 +280,40 @@ function Thermostat({end, duration}) {
minTickGap={10} minTickGap={10}
tickFormatter={tickFormatter} tickFormatter={tickFormatter}
/> />
<YAxis <YAxis
domain={[15, 25]} yAxisId='right'
domain={[0, 6]}
orientation='right'
hide={true}
/> />
<YAxis
yAxisId='left'
domain={[12, 30]}
/>
<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 x={moment().startOf('day').toISOString().replace('.000', '')} stroke='blue' /> <ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line <Line
yAxisId='right'
type='monotone'
dataKey='state'
name='State'
stroke='green'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
yAxisId='left'
type='monotone' type='monotone'
dataKey='heattemp' dataKey='heattemp'
name='Setpoint' name='Setpoint'
@@ -318,6 +324,7 @@ function Thermostat({end, duration}) {
/> />
<Line <Line
yAxisId='left'
type='monotone' type='monotone'
dataKey='spacetemp' dataKey='spacetemp'
name='Temperature' name='Temperature'
@@ -363,7 +370,7 @@ function Gas({end, duration}) {
separator=': ' separator=': '
/> />
<ReferenceLine yAxisId='right' x={moment().startOf('day').toISOString().replace('.000', '')} stroke='blue' /> <ReferenceLine yAxisId='right' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Bar <Bar
yAxisId='left' yAxisId='left'
@@ -421,7 +428,7 @@ function Water({end, duration}) {
separator=': ' separator=': '
/> />
<ReferenceLine yAxisId='right' x={moment().startOf('day').toISOString().replace('.000', '')} stroke='blue' /> <ReferenceLine yAxisId='right' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Bar <Bar
yAxisId='left' yAxisId='left'
@@ -471,7 +478,7 @@ function LivingRoomDust({end, duration}) {
separator=': ' separator=': '
/> />
<ReferenceLine x={moment().startOf('day').toISOString().replace('.000', '')} stroke='blue' /> <ReferenceLine x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line <Line
type='monotone' type='monotone'
@@ -486,6 +493,97 @@ function LivingRoomDust({end, duration}) {
); );
} }
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}) { function BedroomSleep({end, duration}) {
const [data, loading, tickFormatter] = useSensor('sleep', 'Bedroom', end, duration); const [data, loading, tickFormatter] = useSensor('sleep', 'Bedroom', end, duration);
@@ -511,7 +609,7 @@ function BedroomSleep({end, duration}) {
separator=': ' separator=': '
/> />
<ReferenceLine x={moment().startOf('day').toISOString().replace('.000', '')} stroke='blue' /> <ReferenceLine x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line <Line
type='monotone' type='monotone'
@@ -526,19 +624,138 @@ function BedroomSleep({end, duration}) {
); );
} }
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 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} />
<OutsideTemperature end={end} duration={duration} /> <LivingRoomDust end={end} duration={duration} />
<BedroomTemperature end={end} duration={duration} /> <Air name='Living Room Air' sensorName='Living Room' end={end} duration={duration} />
<NookTemperature end={end} duration={duration} /> <Air name='Kitchen Air' sensorName='Kitchen' end={end} duration={duration} />
<Air name='Bedroom Air' sensorName='Bedroom' 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} /> <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} />
<LivingRoomDust end={end} duration={duration} />
<BedroomSleep 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} />
<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>
); );
} }

File diff suppressed because it is too large Load Diff

26
export/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# 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*
image.png
data/

0
export/data/index.html Normal file
View File

77
export/main.py Normal file
View File

@@ -0,0 +1,77 @@
import time
import traceback
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
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
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('start-maximized')
chrome_options.add_argument('--no-sandbox')
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')
time.sleep(3)
driver.execute_script("return document.getElementsByClassName('menu')[0].remove();")
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)
finally:
driver.close()
driver.quit()
print('done.')

24
export/requirements.txt Normal file
View 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

342
main.py
View File

@@ -9,13 +9,17 @@ logging.basicConfig(
level=logging.DEBUG if DEBUG else logging.INFO) level=logging.DEBUG if DEBUG else logging.INFO)
logging.getLogger('aiohttp').setLevel(logging.DEBUG if DEBUG else logging.WARNING) logging.getLogger('aiohttp').setLevel(logging.DEBUG if DEBUG else logging.WARNING)
logging.info('')
logging.info('BOOT UP')
import settings import settings
import asyncio import asyncio
import json import json
import time import time
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')
@@ -24,9 +28,20 @@ app = web.Application()
http_session = None http_session = None
from influxdb import InfluxDBClient from influxdb import InfluxDBClient
client = InfluxDBClient('localhost', 8086, database='sensors1' if PROD else 'sensors1dev') sensors_client = InfluxDBClient('localhost', 8086, database='sensors1' if PROD else 'sensors1dev')
solar_client = InfluxDBClient('localhost', 8086, database='solar2')
PORT = 6903 if PROD else 6904 PORT = 6903 if PROD else 6904
def controller_message(message):
logging.info('Sending controller message: %s', message)
payload = dict(home=message)
r = requests.post('https://tbot.tannercollin.com/message', data=payload, timeout=10)
if r.status_code == 200:
return True
else:
logging.exception('Unable to communicate with controller! Message: ' + message)
return False
class Sensors(): class Sensors():
sensors = [] sensors = []
@@ -64,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_
@@ -88,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()
self.transform(data)
try:
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)
@@ -109,21 +140,26 @@ class Sensor():
'tags': {'id': self.id_, 'name': self.name}, 'tags': {'id': self.id_, 'name': self.name},
'fields': data, 'fields': data,
} }
client.write_points([point])
logging.info('Wrote %s data to InfluxDB.', self) try:
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)
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.error('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
@@ -142,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_
@@ -149,7 +186,7 @@ class ThermostatSensor(Sensor):
self.name = name self.name = name
async def poll(self): async def poll(self):
data = await getter('http://{}/query/info'.format(self.ip)) data = await getter('http://{}/query/info'.format(self.ip)) or {}
self.update(data) self.update(data)
class ERTSCMSensor(Sensor): class ERTSCMSensor(Sensor):
@@ -160,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 = [
@@ -169,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'
@@ -182,6 +225,45 @@ class DustSensor(Sensor):
except TypeError: except TypeError:
pass pass
class AirSensor(Sensor):
type_ = 'air'
update_period = 15
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 SleepSensor(Sensor):
type_ = 'sleep'
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 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):
type_ = 'solar'
class Acurite606TX(Sensor): class Acurite606TX(Sensor):
type_ = 'temperature' type_ = 'temperature'
bad_keys = [ bad_keys = [
@@ -190,11 +272,52 @@ class Acurite606TX(Sensor):
'battery_ok', 'battery_ok',
] ]
update_period = 40 update_period = 40
offset = 0.0
def __init__(self, id_, name, offset=0.0):
self.id_ = id_
self.name = name
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']) data['temperature_C'] = float(data['temperature_C']) + self.offset
class AcuRite6002RM(Sensor):
type_ = 'temperature'
bad_keys = [
'model',
'mic',
'battery_ok',
'channel',
]
update_period = 40
offset = 0.0
def __init__(self, id_, name, temp_offset=0.0, hum_offset=0.0):
self.id_ = id_
self.name = name
self.temp_offset = temp_offset
self.hum_offset = hum_offset
def transform(self, data):
if data.get('battery_ok', None) != 1:
logging.error('%s battery not ok!', self)
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():
@@ -211,11 +334,15 @@ async def process_data(data):
sensor.update(data) sensor.update(data)
async def process_mqtt(message): async def process_mqtt(message):
text = message.payload.decode() try:
topic = message.topic text = message.payload.decode()
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 == 'test': if topic.startswith('test'):
logging.info('MQTT test, message: %s', text) logging.info('MQTT test, message: %s', text)
return return
@@ -224,21 +351,31 @@ async def process_mqtt(message):
except json.JSONDecodeError: except json.JSONDecodeError:
return return
if 'id' not in data: if type(data) != dict or 'id' not in data:
return return
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:
await client.subscribe('#') # from https://sbtinstruments.github.io/aiomqtt/reconnection.html
async for message in messages: # modified to make new client since their code didn't work
await process_mqtt(message) # https://github.com/sbtinstruments/aiomqtt/issues/269
while True:
try:
async with aiomqtt.Client('localhost') as client:
await client.subscribe('#')
async for message in client.messages:
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()
logging.info('Web data: %s', str(data)) logging.debug('Web data: %s', str(data))
if data.get('_type', '') == 'location': if data.get('_type', '') == 'location':
data['id'] = data['topic'].split('/')[-1] data['id'] = data['topic'].split('/')[-1]
@@ -252,74 +389,185 @@ 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]:
raise
end_unix = request.rel_url.query.get('end', None) end_unix = request.rel_url.query.get('end', None)
if end_unix: if end_unix:
end_unix = int(end_unix)
end = datetime.fromtimestamp(end_unix) end = datetime.fromtimestamp(end_unix)
else: else:
end = datetime.now(tz=pytz.UTC) end = datetime.now(tz=pytz.UTC)
duration = request.rel_url.query.get('duration', 'today') duration = request.rel_url.query.get('duration', 'day')
if duration == 'today': if duration == 'today':
now_tz = datetime.now(tz=TIMEZONE) now_tz = datetime.now(tz=TIMEZONE)
start = now_tz.replace(hour=0, minute=0, second=0, microsecond=0) start = now_tz.replace(hour=0, minute=0, second=0, microsecond=0)
window = '5m' window = '10m'
elif duration == 'day': elif duration == 'day':
start = end - timedelta(days=1) start = end - timedelta(days=1)
window = '5m' window = '10m'
elif duration == 'week': elif duration == 'week':
start = end - timedelta(days=7) start = end - timedelta(days=7)
window = '30m' window = '1h'
elif duration == 'month': elif duration == 'month':
start = end - timedelta(days=30) start = end - timedelta(days=30)
window = '2h' window = '1d'
elif duration == 'year': elif duration == 'year':
start = end - timedelta(days=365) start = end - timedelta(days=365)
window = '24h' window = '1d'
else:
raise
window = request.rel_url.query.get('window', window)
if window not in ['1m', '3m', '10m', '1h', '2h', '1d', '7d', '30d']:
raise
if name == 'Water':
scale = 10
elif name == 'Gas':
scale = 0.001055*1000
else:
scale = 1
start = int(start.timestamp()) start = int(start.timestamp())
end = int(end.timestamp()) end = int(end.timestamp())
if measurement == 'temperature': if measurement == 'temperature':
q = 'select mean("temperature_C") as temperature_C from temperature where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(none)'.format(name, start, end, window) client = sensors_client
q = 'select mean("temperature_C") as temperature_C, mean("humidity") as humidity from temperature where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
elif measurement == 'ertscm': elif measurement == 'ertscm':
q = 'select max("consumption_data") as consumption_data from ertscm where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(none)'.format(name, start, end, window) client = sensors_client
q = 'select derivative(max("consumption_data"))*{} as delta, max("consumption_data")*{} as max from ertscm where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(previous)'.format(scale, scale, name, start, end, window)
elif measurement == 'thermostat':
client = sensors_client
q = 'select first("spacetemp") as spacetemp, first("heattemp") as heattemp, mode("state") as state from thermostat where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(previous)'.format(name, start, end, window)
elif measurement == 'dust': elif measurement == 'dust':
q = 'select max("max_p10") as max_p10, max("max_p25") as max_p25 from dust where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(none)'.format(name, start, end, window) client = sensors_client
q = 'select max("avg_p10") as max_p10, max("avg_p25") as max_p25 from dust where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
elif measurement == 'air':
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)
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':
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)
elif measurement == 'sleep':
client = sensors_client
q = 'select max("max_mag") as max_mag from sleep where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
elif measurement == 'solar':
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)
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:
raise
#if window and moving_average: q += ' tz(\'America/Edmonton\')'
# q = 'select moving_average(mean("value"),{}) as value from {} where "name" = \'{}\' and time >= {}s and time < {}s group by time({}m) fill(none)'.format(moving_average, measurement, name, start, end, window)
#elif window:
# q = 'select mean("value") as value from {} where "name" = \'{}\' and time >= {}s and time < {}s group by time({}m) fill(none)'.format(measurement, name, start, end, window)
#elif moving_average:
# q = 'select moving_average("value", {}) as value from {} where "name" = \'{}\' and time >= {}s and time < {}s'.format(moving_average, name, start, end)
#else:
# q = 'select value from {} where "name" = \'{}\' and time >= {}s and time < {}s'.format(measurement, name, start, end)
result = list(client.query(q).get_points()) result = list(client.query(q).get_points())
return web.json_response(result) return web.json_response(result)
async def latest(request):
result = dict()
api_key = request.rel_url.query.get('api_key', '')
authed = api_key == settings.SENSORS_API_KEY
for sensor in sensors:
if sensor.type_ in ['solar']:
continue
if not authed and sensor.type_ in ['owntracks', 'sleep']:
continue
q = 'select * from {} where "name" = \'{}\' order by desc limit 1'.format(sensor.type_, sensor.name)
points = sensors_client.query(q).get_points()
point = list(points)
if sensor.type_ not in result:
result[sensor.type_] = dict()
result[sensor.type_][sensor.name] = point
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():
#web.run_app(app, port=PORT, loop=loop)
logging.info('Starting webserver on port: %s', PORT)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '0.0.0.0', PORT)
await site.start()
while True: await asyncio.sleep(10)
def task_died(future):
if os.environ.get('SHELL'):
logging.error('Sensors server task died!')
else:
logging.error('Sensors server task died! Waiting 60s and exiting...')
try:
controller_message('Sensors server task died! Waiting 60s and exiting...')
except: # we want this to succeed no matter what
pass
time.sleep(60)
exit()
if __name__ == '__main__': if __name__ == '__main__':
app.router.add_get('/', index) app.router.add_get('/', index)
app.router.add_post('/owntracks', owntracks) app.router.add_post('/owntracks', owntracks)
app.router.add_get('/history/{measurement}/{name}', history) app.router.add_get('/history/{measurement}/{name}', history)
app.router.add_get('/latest', latest)
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(DustSensor('dust1', 'Nook')) sensors.add(AirSensor('air1', 'Living Room'))
sensors.add(Acurite606TX('231', '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('999998', 'Misc', 0.7, -1.0)) # A
sensors.add(AcuRite6002RM('12516', 'Basement', 0.7, -1.0)) # A
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(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()
loop.create_task(poll_sensors()) a = loop.create_task(poll_sensors()).add_done_callback(task_died)
loop.create_task(fetch_mqtt()) b = loop.create_task(fetch_mqtt()).add_done_callback(task_died)
web.run_app(app, port=PORT, loop=loop) c = loop.create_task(run_webserver()).add_done_callback(task_died)
loop.run_forever()

23
mapper/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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='&copy; <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()}>&lt;</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()}>&gt;</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
View 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

File diff suppressed because it is too large Load Diff

22
requirements.txt Normal file
View File

@@ -0,0 +1,22 @@
aiohttp==3.8.1
aiomqtt==2.0.0
aiosignal==1.2.0
async-timeout==4.0.2
asyncio-mqtt==0.11.0
asynctest==0.13.0
attrs==21.2.0
certifi==2021.10.8
charset-normalizer==2.0.9
frozenlist==1.2.0
idna==3.3
influxdb==5.3.1
msgpack==1.0.3
multidict==5.2.0
paho-mqtt==1.6.1
python-dateutil==2.8.2
pytz==2021.3
requests==2.26.0
six==1.16.0
typing-extensions==4.9.0
urllib3==1.26.7
yarl==1.7.2

View File

@@ -1 +1 @@
THERMOSTAT_IP = '' SENSORS_API_KEY = ''