Compare commits
10 Commits
15fcc68f76
...
cd547a15e6
| Author | SHA1 | Date | |
|---|---|---|---|
| cd547a15e6 | |||
| 900e31de9d | |||
| 6d1a1e7c78 | |||
| c959321c7b | |||
| 182c42de88 | |||
| d809c33f87 | |||
| 502c18f434 | |||
| a37f446375 | |||
| 6f80297ac7 | |||
| aa3c10fab8 |
158
main.py
158
main.py
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user