Compare commits

...

128 Commits

Author SHA1 Message Date
tanner 34f21791cc Catch bad OwnTrack data, add WH51 soil sensor 2026-06-07 00:04:19 +00:00
tanner cd547a15e6 Add quarter time period, Laundry Room air 2026-04-16 22:46:38 +00:00
tanner 900e31de9d perf: Filter search results in DB and use time-based gap detection
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner 6d1a1e7c78 fix: Recalculate visit ranges using point-based gap threshold
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner c959321c7b perf: Optimize search with InfluxDB geo-filtering; detect time gaps
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner 182c42de88 feat: Implement search API for owntracks geo-fence time ranges
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner d809c33f87 fix: Handle OPTIONS requests for search route to prevent CORS duplication
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner 502c18f434 fix: Remove duplicate CORS handling from aiohttp app
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner a37f446375 fix: Enable CORS for /search route
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner 6f80297ac7 feat: Implement search API handler stub with logging
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-04-16 22:46:38 +00:00
tanner aa3c10fab8 feat: Add P2 Pro scale sensor and secure history sharing 2026-04-16 22:46:38 +00:00
tanner 15fcc68f76 Add leaflet to mapper 2026-04-16 22:45:12 +00:00
tanner 3ad9ec9b3d Add Laundry Room air sensors 2026-04-16 22:43:55 +00:00
tanner 4a19599162 feat: Calculate and display average pace
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 20:19:08 +00:00
tanner 1003de33f2 fix: Center search result on time slider to allow panning
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 19:08:44 +00:00
tanner aa40a3b1c1 feat: Cancel search request when menu is closed
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 19:05:50 +00:00
tanner c8b9d2b8bd feat: Cycle included area segments by recency
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 19:02:13 +00:00
tanner 6df3446fca feat: Cycle excluded segments by recency on Exclude area clicks
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 18:56:38 +00:00
tanner 24a65b7f79 feat: Display total polyline distance in menu with unit conversion
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 18:50:44 +00:00
tanner 2ef752dc75 feat: Add include area button to narrow time range by selection
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 18:46:03 +00:00
tanner f1938509d7 feat: Add 'Show points' checkbox to display polyline points
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-08-17 18:43:32 +00:00
tanner 7b07a8049b fix: Prevent map render on empty data 2025-08-17 18:43:30 +00:00
tanner 316ea7bf86 feat: allow merging of adjacent search results 2025-08-15 20:25:02 +00:00
tanner 109c877e5d fix: close submenu on search result selection 2025-08-15 20:19:11 +00:00
tanner e53287be96 fix: prevent drawn items from disappearing during data load 2025-08-15 20:13:54 +00:00
tanner 9683110604 refactor: move search result inline styles to CSS file 2025-08-15 20:06:46 +00:00
tanner 0c107f86b6 feat: Set time range and highlight selected search result 2025-08-15 20:02:46 +00:00
tanner 7ec2a638a2 feat: add short label for Quarter duration 2025-08-15 20:02:32 +00:00
tanner 53acb33a56 feat: add 'Quarter' duration option 2025-08-15 19:52:01 +00:00
tanner 0a7f29e1d0 feat: Sort and scroll search results, preserving scroll position 2025-08-15 19:41:51 +00:00
tanner 5bc64bec13 feat: Use 24-hour format for search result times 2025-08-15 19:37:41 +00:00
tanner 9807187bc7 fix: Group search results by year and use abbreviated month 2025-08-15 19:35:19 +00:00
tanner 18c74bedf1 feat: group search results by month and year 2025-08-15 19:31:39 +00:00
tanner e1fcd77180 feat: display area search results in misc submenu 2025-08-15 19:16:48 +00:00
tanner 360252151a feat: add loading state to search area button 2025-08-15 19:10:29 +00:00
tanner dc9872ebb8 refactor: remove unused end and duration API parameters 2025-08-15 19:10:25 +00:00
tanner 8cdbb94878 feat: add 'Search area' button to query time in drawn zones 2025-08-15 17:28:58 +00:00
tanner de7d9e45b9 fix: correct rounding for exclude area slider calculation 2025-08-15 02:43:22 +00:00
tanner d0a5461073 feat: add button to exclude drawn areas from time range 2025-08-15 02:36:29 +00:00
tanner 1a3c11b5bb feat: add rectangle drawing controls to the map 2025-08-15 02:29:48 +00:00
tanner 25e57edf39 refactor: Update reset button text to "Reset page" 2025-08-15 02:29:40 +00:00
tanner b4b840bf9c style: color disabled menu buttons 2025-08-15 01:48:43 +00:00
tanner 84b3ca1efd Valid share range adjustments 2025-08-15 01:47:27 +00:00
tanner 7572520c96 fix: Simplify datepicker validation to ignore duration 2025-08-15 01:29:25 +00:00
tanner fa53b50fbf feat: restrict date navigation within shared link range 2025-08-15 01:18:18 +00:00
tanner a568bf2f57 feat: default start date picker to current start date 2025-08-15 00:27:29 +00:00
tanner d9f539f314 fix: preserve existing URL parameters 2025-08-15 00:22:50 +00:00
tanner 72c56c8245 Add button to recentre view 2025-08-15 00:21:30 +00:00
tanner 04f64a0fe4 fix: Prefix share signature data with 'owntracks-' 2025-08-15 00:02:28 +00:00
tanner 7bad27402a feat: add share range button to copy shareable URL 2025-08-14 23:36:40 +00:00
tanner 3e6782529d Fix midnight 2025-08-14 23:27:52 +00:00
tanner 25d6a8757b Add leaflet-polylinedecorator 2025-08-14 23:17:17 +00:00
tanner b585a39dd0 fix: Force transparent checkbox background to override extensions 2025-08-14 23:14:05 +00:00
tanner a3c7f85302 fix: Set transparent background for checked checkbox on Firefox Mobile 2025-08-14 23:10:49 +00:00
tanner 646ca1268e style: apply sans-serif font to menu buttons 2025-08-14 23:09:21 +00:00
tanner 562c7cb6eb style: Refine header font and checkbox styles 2025-08-14 23:09:16 +00:00
tanner cb8129cbba style: Restyle 'Show direction' checkbox to match menu buttons 2025-08-14 23:04:18 +00:00
tanner 502ae2b982 feat: add checkbox to toggle direction arrows 2025-08-14 23:01:02 +00:00
tanner 1f744216ec feat: add direction arrows to polyline 2025-08-14 22:56:59 +00:00
tanner ec7fbed514 fix: shorten day of week format to two letters 2025-08-14 22:56:54 +00:00
tanner 3d927c18ce feat: add reset button and correct date logic 2025-08-14 22:39:15 +00:00
tanner f309c0af00 fix: Use two-letter day format in menu 2025-08-14 22:36:36 +00:00
tanner 7e0eddaf38 fix: Correct month short name from Mon to Mth 2025-08-14 22:33:45 +00:00
tanner 1875d7b4e7 fix: abbreviate Month duration to prevent button text wrap 2025-08-14 22:26:05 +00:00
tanner 959e1d85d0 feat: add Misc submenu 2025-08-14 22:08:32 +00:00
tanner 2be0dd1c3d fix: preserve selected date when choosing midnight 2025-08-14 22:08:27 +00:00
tanner 0708301396 style: Reduce submenu header font size 2025-08-14 21:53:53 +00:00
tanner fbc15bb371 feat: add jump to midnight button and rearrange submenu actions 2025-08-14 21:46:26 +00:00
tanner ca3202f9b7 feat: Close submenu on map interaction 2025-08-14 21:42:20 +00:00
tanner 435db835e9 refactor: unify map view logic to fix centering and data bugs 2025-08-14 21:18:09 +00:00
tanner 87e706c223 fix: return null from FitBounds component 2025-08-14 21:14:33 +00:00
tanner 478dca185e fix: prevent map freeze and fix repositioning race condition 2025-08-14 21:12:50 +00:00
tanner b295c3fef0 fix: wait for data to load before fitting map bounds 2025-08-14 20:59:14 +00:00
tanner 13b35e1c00 fix: Validate coordinate points before processing to prevent freeze 2025-08-14 20:55:38 +00:00
tanner 2adc0a9fcb perf: memoize coordinate processing to prevent UI freeze 2025-08-14 20:25:18 +00:00
tanner 0a02db9a8d fix: filter invalid coordinates to prevent UI freeze 2025-08-14 20:21:57 +00:00
tanner bdc2921bc0 fix: Resolve race condition when refitting map on date change 2025-08-14 20:16:07 +00:00
tanner 9dd772839b feat: Refit map bounds on end or duration change 2025-08-14 20:12:09 +00:00
tanner 4bc88e5ce9 feat: Fit map to all points on initial load 2025-08-14 20:08:44 +00:00
tanner 6c7dff2d8f fix: prevent infinite loop by checking map state on moveend 2025-08-14 20:01:09 +00:00
tanner 21cec132a7 feat: encode map position and zoom in URL 2025-08-14 19:58:05 +00:00
tanner 51031e7b20 fix: debounce URL updates to prevent History API errors 2025-08-14 19:52:37 +00:00
tanner 81880a6a0a feat: Store view state in URL for shareable links 2025-08-14 19:49:16 +00:00
tanner 44dcc1b8ad fix: Prevent range delta from wrapping mid-text 2025-08-14 19:44:55 +00:00
tanner 17b1f979a9 feat: implement rangeDelta to display time range duration 2025-08-14 19:40:06 +00:00
tanner 00d9ee362f feat: add rangeDelta function 2025-08-14 19:39:56 +00:00
tanner 578bed681a Change temperature chart settings 2025-07-03 22:41:22 +00:00
tanner 816624ec44 refactor: Abstract temperature components into a single generic component 2025-07-03 22:41:22 +00:00
tanner 1052cf9bb9 refactor: abstract air quality components 2025-07-03 22:41:22 +00:00
tanner 3c8393b14c refactor: Rename SoilMoisture to Soil and hardcode sensorId 2025-07-03 22:41:22 +00:00
tanner d5f5e08a3c refactor: Abstract Lux components into single component 2025-07-03 22:41:22 +00:00
tanner f93e6d2323 feat: Add soil moisture graphs for Kitchen Pothos and Dracaena 2025-07-03 22:41:22 +00:00
tanner 24bada26a4 refactor: Abstract DumbCaneSoil to generic SoilMoisture component 2025-07-03 22:41:22 +00:00
tanner 4202e1a19d Add Dumb Cane soil sensor 2025-07-03 22:41:22 +00:00
tanner ecd1dab005 Add soil sensors, cooldown skipping, make dupe skipping optional 2025-06-20 16:59:25 +00:00
tanner 54e169bdd2 Handle transform() exception 2025-06-20 16:59:25 +00:00
tanner 49f9ee120b Add Kitchen and Bedroom air / lux sensors 2025-05-13 19:17:13 +00:00
tanner fa8f2cddb5 Fix bugs, add Qot motion sensors 2024-08-01 17:53:23 +00:00
tanner 7b15b39d5f Simplify components, add sha256() for later 2024-07-16 02:02:55 +00:00
tanner e5dbb0af39 Allow shifting by time range 2024-07-16 01:30:23 +00:00
tanner e549afce96 Add slider for time range 2024-07-15 21:41:09 +00:00
tanner 45272a6242 Switch export to basement temperature 2024-07-15 19:41:17 +00:00
tanner 61fd657952 Take API key, add basement sensor 2024-07-15 19:41:17 +00:00
tanner 88dbba168c Take API key, adjust ranges 2024-07-15 19:41:17 +00:00
tanner ba630b6fb9 Extract correct chrome version from exception 2024-07-15 19:41:17 +00:00
tanner 34f0444de7 Make task_died work with no internet 2024-02-08 20:32:43 +00:00
tanner 8abb15cdd3 Update / switch to aiomqtt, reconnect mqtt on error 2024-02-08 20:32:43 +00:00
tanner 1346171618 Handle new gas meter, owntracks, API key 2024-02-08 20:32:43 +00:00
tanner e38164fd43 Sensors export fixes 2023-10-03 09:33:06 +00:00
tanner b275305434 Create mapper to visualize owntracks data 2023-10-03 09:32:27 +00:00
tanner 1d5f63f86d Wrap export in try: finally: 2023-05-08 21:15:39 +00:00
tanner c693d30394 Handle errors, remove menu bar, centre graphs 2023-04-18 21:19:24 +00:00
tanner a7ca48dacf Rename .gitkeep to index.html 2023-04-18 21:19:24 +00:00
tanner 08b7196c26 Capture all sensor graphs 2023-04-18 21:19:24 +00:00
tanner 29ac0345c6 Ignore data/ folder 2023-04-18 21:19:24 +00:00
tanner b393b88127 Freeze requirements 2023-04-18 21:19:24 +00:00
tanner 43535d0a95 Store strong references to async tasks
Reason: https://news.t0.vc/IEUC
2023-02-12 18:37:41 +00:00
tanner 47cdfff327 Change outside sensor ID 2023-02-12 18:36:55 +00:00
tanner 685e7917df Move seeds temp to misc temp 2023-01-12 18:31:53 +00:00
tanner ed38f1fafe Add Misc temperature graph 2022-12-19 19:30:56 +00:00
tanner 6b1a23271b Begin sensor image exporter 2022-12-19 19:30:56 +00:00
tanner 8dce70d9d0 Freeze requirements 2022-10-03 21:57:06 +00:00
tanner c618a24126 Add auth to sensors API 2022-10-03 21:51:42 +00:00
tanner 94f023f70c Change domains 2022-08-05 19:05:10 +00:00
tanner 2eb1cb7e4b Add Air quality and proper units 2022-08-05 19:05:10 +00:00
tanner 2b1be5344a Remove owntracks from /latest 2022-06-21 07:09:55 +00:00
tanner 6a236b198a Ignore .csv 2022-06-20 05:50:57 +00:00
tanner 9d6997bfc7 Add sensors, /latest API route 2022-06-20 05:50:57 +00:00
17 changed files with 18721 additions and 3607 deletions
+2
View File
@@ -103,3 +103,5 @@ ENV/
*.swo
settings.py
*.csv
.aider*
+302 -158
View File
@@ -19,6 +19,21 @@ const durations = [
{id: 7, len: 'Year', win: '30d', full: '30 day', delta: [1, 'years'], format: 'M'},
];
const units = {
'PM10': ' ug/m³',
'PM2.5': ' ug/m³',
'VOC': ' / 500',
'CO2': ' ppm',
'Energy': ' kWh',
'Power': ' W',
'Temperature': ' °C',
'Humidity': '%',
'Setpoint': ' °C',
'State': '',
'Lux': ' lx',
'Soil': '',
};
function useSensor(measurement, name, end, duration) {
const [data, setData] = useState(false);
const [loading, setLoading] = useState(false);
@@ -27,7 +42,8 @@ function useSensor(measurement, name, end, duration) {
const get = async() => {
setLoading(true);
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(
'https://sensors-api.dns.t0.vc/history/'+measurement+'/'+name,
{ params: params },
@@ -93,7 +109,7 @@ function ChartContainer({name, data, lastFormatter, loading, children, topMargin
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;
if (data.length) {
const data_end = data.slice(-2);
@@ -105,7 +121,7 @@ function ChartContainer({name, data, lastFormatter, loading, children, topMargin
}
return (
<div className='chart'>
<div className='chart' id={name.replace(/ /g,'_')}>
<h2>{name}: {loading ? 'Loading...' : last || 'No data'}</h2>
<ResponsiveContainer width='100%' height={300}>
@@ -134,24 +150,43 @@ function SolarPower({end, duration}) {
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
yAxisId='right'
domain={[0, 10]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='left'
domain={[0, 6000]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={v => v + ' W'}
formatter={(v, name) => v.toFixed(1) + units[name]}
labelFormatter={timeStr => moment(timeStr).tz('America/Edmonton').format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue'>
<ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue'>
<Label value='Midnight' offset={7} position='top' />
</ReferenceLine>
<Bar
yAxisId='right'
type='monotone'
dataKey='lifetime_energy'
name='Energy'
fill='green'
isAnimationActive={false}
/>
<Line
yAxisId='left'
type='monotone'
dataKey='actual_total'
name='Total Power'
name='Power'
stroke='black'
strokeWidth={2}
dot={false}
@@ -161,12 +196,12 @@ function SolarPower({end, duration}) {
);
}
function OutsideTemperature({end, duration}) {
const [data, loading, tickFormatter] = useSensor('temperature', 'Outside', end, duration);
function Temperature({name, sensorName, end, duration, yDomain, showHumidity, showFreezingLine}) {
const [data, loading, tickFormatter] = useSensor('temperature', sensorName, end, duration);
return (
<ChartContainer
name='Outside Temperature'
name={name}
data={data}
lastFormatter={(x) => x.temperature_C?.toFixed(1) + ' °C'}
loading={loading}
@@ -176,184 +211,60 @@ function OutsideTemperature({end, duration}) {
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
domain={[-40, 40]}
/>
<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().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}) {
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().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 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().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 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}
/>
{showHumidity &&
<YAxis
yAxisId='right'
domain={[0, 100]}
orientation='right'
hide={true}
/>
}
<YAxis
yAxisId='left'
domain={[15, 25]}
yAxisId={showHumidity ? 'left' : undefined}
domain={yDomain}
/>
<CartesianGrid strokeDasharray='3 3'/>
<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')}
separator=': '
/>
<ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
{showFreezingLine &&
<ReferenceLine yAxisId={showHumidity ? 'left' : undefined} y={0} stroke='purple'>
<Label value='Freezing' offset={7} position='bottom' />
</ReferenceLine>
}
<ReferenceLine yAxisId={showHumidity ? 'left' : undefined} x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
yAxisId={showHumidity ? 'left' : undefined}
type='monotone'
dataKey='temperature_C'
yAxisId='left'
name='Temperature'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
{showHumidity &&
<Line
yAxisId='right'
type='monotone'
dataKey='humidity'
yAxisId='right'
name='Humidity'
stroke='blue'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
}
</ChartContainer>
);
}
function Thermostat({end, duration}) {
const [data, loading, tickFormatter] = useSensor('thermostat', 'Venstar', end, duration);
@@ -369,19 +280,40 @@ function Thermostat({end, duration}) {
minTickGap={10}
tickFormatter={tickFormatter}
/>
<YAxis
domain={[15, 25]}
yAxisId='right'
domain={[0, 6]}
orientation='right'
hide={true}
/>
<YAxis
yAxisId='left'
domain={[12, 30]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<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')}
separator=': '
/>
<ReferenceLine x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<ReferenceLine yAxisId='left' x={moment().tz('America/Edmonton').startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
yAxisId='right'
type='monotone'
dataKey='state'
name='State'
stroke='green'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
yAxisId='left'
type='monotone'
dataKey='heattemp'
name='Setpoint'
@@ -392,6 +324,7 @@ function Thermostat({end, duration}) {
/>
<Line
yAxisId='left'
type='monotone'
dataKey='spacetemp'
name='Temperature'
@@ -560,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}) {
const [data, loading, tickFormatter] = useSensor('sleep', 'Bedroom', end, duration);
@@ -600,20 +624,140 @@ 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}) {
const api_key = localStorage.getItem('api_key', false);
const handleSubmit = (e) => {
e.preventDefault();
const api_key = e.target[0].value;
localStorage.setItem('api_key', api_key);
}
return (
<div className='container'>
<SolarPower end={end} duration={duration} />
<OutsideTemperature end={end} duration={duration} />
<BedroomTemperature end={end} duration={duration} />
<NookTemperature end={end} duration={duration} />
<SeedsTemperature end={end} duration={duration} />
<LivingRoomDust end={end} duration={duration} />
<Air name='Living Room Air' sensorName='Living Room' end={end} duration={duration} />
<Air name='Kitchen Air' sensorName='Kitchen' end={end} duration={duration} />
<Air name='Bedroom Air' sensorName='Bedroom' end={end} duration={duration} />
<Air name='Laundry Room Air' sensorName='Laundry Room' end={end} duration={duration} />
<Temperature name='Outside Temperature' sensorName='Outside' end={end} duration={duration} yDomain={[-40, 40]} showHumidity={false} showFreezingLine={true} />
<Temperature name='Bedroom Temperature' sensorName='Bedroom' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Temperature name='Nook Temperature' sensorName='Nook' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Temperature name='Garden Temperature' sensorName='Seeds' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Temperature name='Misc Temperature' sensorName='Misc' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Temperature name='Basement Temperature' sensorName='Basement' end={end} duration={duration} yDomain={[15, 30]} showHumidity={true} showFreezingLine={false} />
<Thermostat end={end} duration={duration} />
<Gas end={end} duration={duration} />
<Water end={end} duration={duration} />
<LivingRoomDust end={end} duration={duration} />
<BedroomSleep end={end} duration={duration} />
<Lux name='Living Room Lux' sensorName='Living Room' end={end} duration={duration} />
<Lux name='Kitchen Lux' sensorName='Kitchen' end={end} duration={duration} />
<Lux name='Bedroom Lux' sensorName='Bedroom' end={end} duration={duration} />
<Lux name='Laundry Room Lux' sensorName='Laundry Room' end={end} duration={duration} />
<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>
);
}
+4153 -3354
View File
File diff suppressed because it is too large Load Diff
+26
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/
View File
+77
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
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
+415 -39
View File
@@ -9,16 +9,21 @@ logging.basicConfig(
level=logging.DEBUG if DEBUG else logging.INFO)
logging.getLogger('aiohttp').setLevel(logging.DEBUG if DEBUG else logging.WARNING)
logging.info('')
logging.info('BOOT UP')
import settings
import asyncio
import json
import time
import requests
from aiohttp import web, ClientSession, ClientError
from asyncio_mqtt import Client
import aiomqtt
from datetime import datetime, timedelta
import pytz
TIMEZONE = pytz.timezone('America/Edmonton')
import hashlib
app = web.Application()
http_session = None
@@ -28,6 +33,16 @@ sensors_client = InfluxDBClient('localhost', 8086, database='sensors1' if PROD e
solar_client = InfluxDBClient('localhost', 8086, database='solar2')
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():
sensors = []
@@ -65,8 +80,10 @@ class Sensor():
value = {}
prev_value = {}
bad_keys = []
last_update = time.time()
last_update = None
update_period = None
skip_if_hasnt_changed = False
skip_cooldown = 1.0
def __init__(self, id_, name):
self.id_ = id_
@@ -88,12 +105,33 @@ class Sensor():
return str(before) != str(after)
def check_cooldown(self):
if self.last_update and self.skip_cooldown and time.time() - self.last_update < self.skip_cooldown:
# ignore data point
return True
else:
return False
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.check_cooldown():
logging.debug('Skipping writing %s, cooldown limit', self)
return
data = self.value.copy()
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:
data.pop(key, None)
@@ -110,21 +148,26 @@ class Sensor():
'tags': {'id': self.id_, 'name': self.name},
'fields': data,
}
sensors_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):
if self.update_period:
if self.update_period and self.last_update:
if time.time() - self.last_update > self.update_period:
logging.warning('Missed expected update from %s.', self)
self.last_update = time.time()
def update(self, data):
self.last_update = time.time()
self.prev_value = self.value
self.value = data
self.log()
self.last_update = time.time()
async def poll(self):
return
@@ -143,6 +186,7 @@ class ThermostatSensor(Sensor):
'dehum_setpoint'
]
update_period = 300
skip_if_hasnt_changed = True
def __init__(self, id_, ip, name):
self.id_ = id_
@@ -150,7 +194,7 @@ class ThermostatSensor(Sensor):
self.name = name
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)
class ERTSCMSensor(Sensor):
@@ -161,6 +205,11 @@ class ERTSCMSensor(Sensor):
]
update_period = 60*60
def transform(self, data):
# new gas meter
if 'Consumption' in data:
data['consumption_data'] = data['Consumption']
class OwnTracksSensor(Sensor):
type_ = 'owntracks'
bad_keys = [
@@ -170,6 +219,7 @@ class OwnTracksSensor(Sensor):
'created_at',
]
update_period = 90
skip_cooldown = False
class DustSensor(Sensor):
type_ = 'dust'
@@ -183,6 +233,18 @@ class DustSensor(Sensor):
except TypeError:
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
@@ -195,6 +257,47 @@ class SleepSensor(Sensor):
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 WH51SoilSensor(Sensor):
type_ = 'soil'
bad_keys = [
'model',
'mic',
'battery_ok',
]
update_period = 60*30
class P2ProScaleSensor(Sensor):
type_ = 'scale'
skip_cooldown = 60.0 * 60 * 18 # 18 hours
def check_cooldown(self):
if 'weight' not in self.value:
return False
skip = super().check_cooldown()
if skip:
controller_message('Cooldown skipping scale weight: ' + str(self.value['weight']))
return skip
class SolarSensor(Sensor):
type_ = 'solar'
class Acurite606TX(Sensor):
type_ = 'temperature'
bad_keys = [
@@ -203,11 +306,52 @@ class Acurite606TX(Sensor):
'battery_ok',
]
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):
if data['battery_ok'] != 1:
if data.get('battery_ok', None) != 1:
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():
@@ -216,7 +360,7 @@ async def poll_sensors():
await sensor.poll()
sensor.check_update()
await asyncio.sleep(1)
await asyncio.sleep(5)
async def process_data(data):
sensor = sensors.get(data['id'])
@@ -224,11 +368,15 @@ async def process_data(data):
sensor.update(data)
async def process_mqtt(message):
try:
text = message.payload.decode()
topic = message.topic
except UnicodeDecodeError:
return
topic = message.topic.value
logging.debug('MQTT topic: %s, message: %s', topic, text)
if topic == 'test':
if topic.startswith('test'):
logging.info('MQTT test, message: %s', text)
return
@@ -243,15 +391,26 @@ async def process_mqtt(message):
await process_data(data)
async def fetch_mqtt():
async with Client('localhost') as client:
async with client.filtered_messages('#') as messages:
await asyncio.sleep(3)
# 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('#')
async for message in messages:
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):
try:
data = await request.json()
logging.info('Web data: %s', str(data))
logging.debug('Web data: %s', str(data))
if data.get('_type', '') == 'location':
data['id'] = data['topic'].split('/')[-1]
@@ -262,12 +421,41 @@ async def owntracks(request):
else:
logging.info('Not a location, skipping.')
return web.Response()
return web.json_response({'status': 'ok'}, status=200)
except BaseException as e:
logging.exception('Problem with OwnTracks data: {} - {}'.format(e.__class__.__name__, str(e)))
try:
logging.info('Data: %s', str(data))
except:
pass
return web.json_response({'error': str(e)}, status=500)
def share_sha256(measurement, share_start, share_end, api_key):
s = f'{measurement}-{share_start}-{share_end}-{api_key}'
return hashlib.sha256(s.encode()).hexdigest()
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')
name = request.match_info.get('name')
share_start = request.rel_url.query.get('shareStart', '')
share_end = request.rel_url.query.get('shareEnd', '')
share_sig = request.rel_url.query.get('shareSig', '')
share_authed = share_sig == share_sha256(measurement, share_start, share_end, settings.SENSORS_API_KEY)
authed = authed or share_authed
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)
if end_unix:
end_unix = int(end_unix)
@@ -290,11 +478,18 @@ async def history(request):
elif duration == 'month':
start = end - timedelta(days=30)
window = '1d'
elif duration == 'quarter':
start = end - timedelta(days=90)
window = '1d'
elif duration == 'year':
start = end - timedelta(days=365)
window = '1d'
else:
raise
window = request.rel_url.query.get('window', window)
if window not in ['1m', '3m', '10m', '30m', '1h', '2h', '1d', '7d', '30d']:
raise
if name == 'Water':
scale = 10
@@ -306,60 +501,241 @@ async def history(request):
start = int(start.timestamp())
end = int(end.timestamp())
if share_authed:
if start <= int(share_start):
start = int(share_start)
if end >= int(share_end):
end = int(share_end)
if measurement == 'temperature':
client = sensors_client
q = 'select mean("temperature_C") as temperature_C from temperature where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
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':
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, first("state") as state from thermostat where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(previous)'.format(name, start, end, window)
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':
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 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:
raise
q += ' tz(\'America/Edmonton\')'
#if window and moving_average:
# 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())
return web.json_response(result)
async def search(request):
api_key = request.rel_url.query.get('api_key', '')
authed = api_key == settings.SENSORS_API_KEY
measurement = request.match_info.get('measurement')
name = request.match_info.get('name')
if not authed:
return web.json_response([])
if name not in [x.name for x in sensors.sensors]:
raise
if measurement != 'owntracks':
return web.json_response({'error': 'not implemented for this measurement'}, status=400)
try:
post_data = await request.json()
except json.JSONDecodeError:
return web.json_response({'error': 'invalid json'}, status=400)
params = request.rel_url.query
logging.info('Search request: meas=%s, name=%s, params=%s, data=%s',
measurement, name, params, post_data)
areas = post_data.get('areas')
if not areas or not isinstance(areas, list):
return web.json_response({'error': 'invalid areas format'}, status=400)
try:
for area in areas:
_ = area['southWest']['lat']
_ = area['southWest']['lng']
_ = area['northEast']['lat']
_ = area['northEast']['lng']
except (KeyError, TypeError):
return web.json_response({'error': 'invalid area format in areas list'}, status=400)
client = sensors_client
where_clauses = []
for area in areas:
sw = area['southWest']
ne = area['northEast']
where_clauses.append(f'("lat" >= {sw["lat"]} and "lat" <= {ne["lat"]} and "lon" >= {sw["lng"]} and "lon" <= {ne["lng"]})')
full_where_clause = ' or '.join(where_clauses)
q = f'select "lat", "lon" from owntracks where "acc" < 100 and "name" = \'{name}\' and ({full_where_clause}) order by time asc'
points = list(client.query(q).get_points())
ranges = []
current_range = None
last_point_dt = None
# Use a 12-hour gap to distinguish between separate visits
GAP_THRESHOLD_HOURS = 12
for point in points:
point_time_str = point['time']
if '.' in point_time_str:
point_dt = datetime.strptime(point_time_str, '%Y-%m-%dT%H:%M:%S.%fZ')
else:
point_dt = datetime.strptime(point_time_str, '%Y-%m-%dT%H:%M:%SZ')
if current_range is None:
current_range = {'start': point_dt, 'end': point_dt}
else:
time_diff_hours = (point_dt - last_point_dt).total_seconds() / 3600
if time_diff_hours > GAP_THRESHOLD_HOURS:
ranges.append({
'start': int(current_range['start'].timestamp()),
'end': int(current_range['end'].timestamp())
})
current_range = {'start': point_dt, 'end': point_dt}
else:
current_range['end'] = point_dt
last_point_dt = point_dt
if current_range is not None:
ranges.append({
'start': int(current_range['start'].timestamp()),
'end': int(current_range['end'].timestamp())
})
return web.json_response(ranges)
async def options_handler(request):
return web.Response()
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):
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__':
app.router.add_get('/', index)
app.router.add_post('/owntracks', owntracks)
app.router.add_get('/history/{measurement}/{name}', history)
app.router.add_post('/search/{measurement}/{name}', search)
app.router.add_route('OPTIONS', '/search/{measurement}/{name}', options_handler)
app.router.add_get('/latest', latest)
# serial, name
# API look up is done by name
# when retiring / reassigning a serial, change it to something impossible ie. 9999
sensors.add(ThermostatSensor('thermostat2', '192.168.69.152', 'Venstar'))
sensors.add(ERTSCMSensor('31005493', 'Water'))
sensors.add(ERTSCMSensor('41249312', 'Gas'))
sensors.add(ERTSCMSensor('78628180', 'Gas'))
sensors.add(OwnTracksSensor('owntracks1', 'OwnTracks'))
sensors.add(DustSensor('dust1', 'Living Room'))
sensors.add(Acurite606TX('231', 'Outside'))
sensors.add(Acurite606TX('226', 'Bedroom'))
sensors.add(Acurite606TX('132', 'Nook'))
sensors.add(AirSensor('air9999', 'Living Room'))
sensors.add(AirSensor('air1', 'Laundry Room'))
sensors.add(AirSensor('air2', 'Bedroom'))
sensors.add(AirSensor('air3', 'Kitchen'))
sensors.add(Acurite606TX('252', '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(SoilSensor('soil2', 'Kitchen Pothos'))
sensors.add(SoilSensor('soil3', 'Dracaena'))
sensors.add(WH51SoilSensor('0fafff', 'Side Garden'))
sensors.add(P2ProScaleSensor('scale1', 'Master Bathroom'))
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.create_task(poll_sensors())
loop.create_task(fetch_mqtt())
web.run_app(app, port=PORT, loop=loop)
a = loop.create_task(poll_sensors()).add_done_callback(task_died)
b = loop.create_task(fetch_mqtt()).add_done_callback(task_died)
c = loop.create_task(run_webserver()).add_done_callback(task_died)
loop.run_forever()
+23
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*
+42
View File
@@ -0,0 +1,42 @@
{
"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-draw": "^1.0.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-leaflet-draw": "^0.20.6",
"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
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>
+290
View File
@@ -0,0 +1,290 @@
* {
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;
}
.menu button:disabled {
color: #ce7e7e;
}
.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 .rdtPicker .rdtDay.rdtDisabled {
color: #ce7e7e !important;
}
.datepicker .rdtPicker .rdtDay.rdtNew,
.datepicker .rdtPicker .rdtDay.rdtOld {
color: #c8c8c8;
}
.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;
}
.search-results-container {
max-height: 50vh;
overflow-y: auto;
}
.search-results-group-header {
color: white;
margin: 0.5em 0 0.25em;
font-size: 1em;
font-weight: normal;
text-align: center;
}
.search-results-empty {
color: white;
text-align: center;
padding: 1em 0;
}
.submenu button.active {
background-color: #4a4a4a;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: flex;
justify-content: center;
align-items: center;
color: white;
}
.search-result-row {
display: flex;
align-items: center;
position: relative;
}
.merge-button-wrapper {
flex-shrink: 0;
width: 2em;
position: relative;
align-self: stretch;
}
.merge-button {
position: absolute;
bottom: 0;
left: 50%;
transform: translate(-50%, 50%);
z-index: 10;
background-color: #888;
border: 1px solid #aaa;
border-radius: 50%;
color: white;
cursor: pointer;
font-size: 0.8rem;
line-height: 1;
height: 1.5em;
width: 1.5em;
padding: 0;
min-width: 0;
}
.merge-button:hover {
background-color: #999;
}
.search-result-button-wrapper {
flex-grow: 1;
}
.search-result-button-wrapper button {
width: 100%;
text-align: center;
}
+1138
View File
File diff suppressed because it is too large Load Diff
+10
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')
);
+12099
View File
File diff suppressed because it is too large Load Diff
+22
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
+1 -1
View File
@@ -1 +1 @@
THERMOSTAT_IP = ''
SENSORS_API_KEY = ''