Compare commits

...

10 Commits

Author SHA1 Message Date
cd547a15e6 Add quarter time period, Laundry Room air 2026-04-16 22:46:38 +00:00
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
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
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
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
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
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
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
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
aa3c10fab8 feat: Add P2 Pro scale sensor and secure history sharing 2026-04-16 22:46:38 +00:00

158
main.py
View File

@@ -23,6 +23,7 @@ import aiomqtt
from datetime import datetime, timedelta
import pytz
TIMEZONE = pytz.timezone('America/Edmonton')
import hashlib
app = web.Application()
http_session = None
@@ -104,6 +105,13 @@ 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:
return
@@ -112,7 +120,7 @@ class Sensor():
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:
if self.check_cooldown():
logging.debug('Skipping writing %s, cooldown limit', self)
return
@@ -261,6 +269,23 @@ class SoilSensor(Sensor):
except TypeError:
pass
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'
@@ -326,7 +351,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'])
@@ -388,6 +413,11 @@ async def owntracks(request):
return web.Response()
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
@@ -395,6 +425,14 @@ async def history(request):
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([])
@@ -423,6 +461,9 @@ 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'
@@ -430,7 +471,7 @@ async def history(request):
raise
window = request.rel_url.query.get('window', window)
if window not in ['1m', '3m', '10m', '1h', '2h', '1d', '7d', '30d']:
if window not in ['1m', '3m', '10m', '30m', '1h', '2h', '1d', '7d', '30d']:
raise
if name == 'Water':
@@ -443,6 +484,13 @@ 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, mean("humidity") as humidity from temperature where "name" = \'{}\' and time >= {}s and time < {}s group by time({}) fill(linear)'.format(name, start, end, window)
@@ -482,6 +530,99 @@ async def history(request):
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', '')
@@ -535,13 +676,19 @@ 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('78628180', 'Gas'))
sensors.add(OwnTracksSensor('owntracks1', 'OwnTracks'))
sensors.add(AirSensor('air1', 'Living Room'))
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('185', 'Outside', 0.0))
@@ -553,6 +700,9 @@ if __name__ == '__main__':
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(P2ProScaleSensor('scale1', 'Master Bathroom'))
sensors.add(QotMotionSensor('qot_dc3c', 'Bedroom'))
sensors.add(QotMotionSensor('qot_88c3', 'Lower Stairs Hi'))