summaryrefslogtreecommitdiff
path: root/include/py/homekit/soundsensor
diff options
context:
space:
mode:
Diffstat (limited to 'include/py/homekit/soundsensor')
-rw-r--r--include/py/homekit/soundsensor/__init__.py22
-rw-r--r--include/py/homekit/soundsensor/__init__.pyi8
-rw-r--r--include/py/homekit/soundsensor/node.py75
-rw-r--r--include/py/homekit/soundsensor/server.py128
-rw-r--r--include/py/homekit/soundsensor/server_client.py38
5 files changed, 271 insertions, 0 deletions
diff --git a/include/py/homekit/soundsensor/__init__.py b/include/py/homekit/soundsensor/__init__.py
new file mode 100644
index 0000000..30052f8
--- /dev/null
+++ b/include/py/homekit/soundsensor/__init__.py
@@ -0,0 +1,22 @@
+import importlib
+
+__all__ = [
+ 'SoundSensorNode',
+ 'SoundSensorHitHandler',
+ 'SoundSensorServer',
+ 'SoundSensorServerGuardClient'
+]
+
+
+def __getattr__(name):
+ if name in __all__:
+ if name == 'SoundSensorNode':
+ file = 'node'
+ elif name == 'SoundSensorServerGuardClient':
+ file = 'server_client'
+ else:
+ file = 'server'
+ module = importlib.import_module(f'.{file}', __name__)
+ return getattr(module, name)
+
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/include/py/homekit/soundsensor/__init__.pyi b/include/py/homekit/soundsensor/__init__.pyi
new file mode 100644
index 0000000..cb34972
--- /dev/null
+++ b/include/py/homekit/soundsensor/__init__.pyi
@@ -0,0 +1,8 @@
+from .server import (
+ SoundSensorHitHandler as SoundSensorHitHandler,
+ SoundSensorServer as SoundSensorServer,
+)
+from .server_client import (
+ SoundSensorServerGuardClient as SoundSensorServerGuardClient
+)
+from .node import SoundSensorNode as SoundSensorNode
diff --git a/include/py/homekit/soundsensor/node.py b/include/py/homekit/soundsensor/node.py
new file mode 100644
index 0000000..292452f
--- /dev/null
+++ b/include/py/homekit/soundsensor/node.py
@@ -0,0 +1,75 @@
+import logging
+import threading
+
+from typing import Optional
+from time import sleep
+from ..util import stringify, send_datagram, Addr
+
+from pyA20.gpio import gpio
+from pyA20.gpio import port as gpioport
+
+logger = logging.getLogger(__name__)
+
+
+class SoundSensorNode:
+ def __init__(self,
+ name: str,
+ pinname: str,
+ server_addr: Optional[Addr],
+ threshold: int = 1,
+ delay=0.005):
+
+ if not hasattr(gpioport, pinname):
+ raise ValueError(f'invalid pin {pinname}')
+
+ self.pin = getattr(gpioport, pinname)
+ self.name = name
+ self.delay = delay
+ self.threshold = threshold
+
+ self.server_addr = server_addr
+
+ self.hits = 0
+ self.hitlock = threading.Lock()
+
+ self.interrupted = False
+
+ def run(self):
+ try:
+ t = threading.Thread(target=self.sensor_reader)
+ t.daemon = True
+ t.start()
+
+ while True:
+ with self.hitlock:
+ hits = self.hits
+ self.hits = 0
+
+ if hits >= self.threshold:
+ try:
+ if self.server_addr is not None:
+ send_datagram(stringify([self.name, hits]), self.server_addr)
+ else:
+ logger.debug(f'server reporting disabled, skipping reporting {hits} hits')
+ except OSError as exc:
+ logger.exception(exc)
+
+ sleep(1)
+
+ except (KeyboardInterrupt, SystemExit) as e:
+ self.interrupted = True
+ logger.info(str(e))
+
+ def sensor_reader(self):
+ gpio.init()
+ gpio.setcfg(self.pin, gpio.INPUT)
+ gpio.pullup(self.pin, gpio.PULLUP)
+
+ while not self.interrupted:
+ state = gpio.input(self.pin)
+ sleep(self.delay)
+
+ if not state:
+ with self.hitlock:
+ logger.debug('got a hit')
+ self.hits += 1
diff --git a/include/py/homekit/soundsensor/server.py b/include/py/homekit/soundsensor/server.py
new file mode 100644
index 0000000..a627390
--- /dev/null
+++ b/include/py/homekit/soundsensor/server.py
@@ -0,0 +1,128 @@
+import asyncio
+import json
+import logging
+import threading
+
+from ..database.sqlite import SQLiteBase
+from ..config import config
+from .. import http
+
+from typing import Type
+from ..util import Addr
+
+logger = logging.getLogger(__name__)
+
+
+class SoundSensorHitHandler(asyncio.DatagramProtocol):
+ def datagram_received(self, data, addr):
+ try:
+ data = json.loads(data)
+ except json.JSONDecodeError as e:
+ logger.error('failed to parse json datagram')
+ logger.exception(e)
+ return
+
+ try:
+ name, hits = data
+ except (ValueError, IndexError) as e:
+ logger.error('failed to unpack data')
+ logger.exception(e)
+ return
+
+ self.handler(name, hits)
+
+ def handler(self, name: str, hits: int):
+ pass
+
+
+class Database(SQLiteBase):
+ SCHEMA = 1
+
+ def __init__(self):
+ super().__init__(dbname='sound_sensor_server')
+
+ def schema_init(self, version: int) -> None:
+ cursor = self.cursor()
+
+ if version < 1:
+ cursor.execute("CREATE TABLE IF NOT EXISTS status (guard_enabled INTEGER NOT NULL)")
+ cursor.execute("INSERT INTO status (guard_enabled) VALUES (-1)")
+
+ self.commit()
+
+ def get_guard_enabled(self) -> int:
+ cur = self.cursor()
+ cur.execute("SELECT guard_enabled FROM status LIMIT 1")
+ return int(cur.fetchone()[0])
+
+ def set_guard_enabled(self, enabled: bool) -> None:
+ cur = self.cursor()
+ cur.execute("UPDATE status SET guard_enabled=?", (int(enabled),))
+ self.commit()
+
+
+class SoundSensorServer:
+ def __init__(self,
+ addr: Addr,
+ handler_impl: Type[SoundSensorHitHandler]):
+ self.addr = addr
+ self.impl = handler_impl
+ self.db = Database()
+
+ self._recording_lock = threading.Lock()
+ self._recording_enabled = True
+
+ if self.guard_control_enabled():
+ current_status = self.db.get_guard_enabled()
+ if current_status == -1:
+ self.set_recording(config['server']['guard_recording_default']
+ if 'guard_recording_default' in config['server']
+ else False,
+ update=False)
+ else:
+ self.set_recording(bool(current_status), update=False)
+
+ @staticmethod
+ def guard_control_enabled() -> bool:
+ return 'guard_control' in config['server'] and config['server']['guard_control'] is True
+
+ def set_recording(self, enabled: bool, update=True):
+ with self._recording_lock:
+ self._recording_enabled = enabled
+ if update:
+ self.db.set_guard_enabled(enabled)
+
+ def is_recording_enabled(self) -> bool:
+ with self._recording_lock:
+ return self._recording_enabled
+
+ def run(self):
+ if self.guard_control_enabled():
+ t = threading.Thread(target=self.run_guard_server)
+ t.daemon = True
+ t.start()
+
+ loop = asyncio.get_event_loop()
+ t = loop.create_datagram_endpoint(self.impl, local_addr=self.addr)
+ loop.run_until_complete(t)
+ loop.run_forever()
+
+ def run_guard_server(self):
+ routes = http.routes()
+
+ @routes.post('/guard/enable')
+ async def guard_enable(request):
+ self.set_recording(True)
+ return http.ok()
+
+ @routes.post('/guard/disable')
+ async def guard_disable(request):
+ self.set_recording(False)
+ return http.ok()
+
+ @routes.get('/guard/status')
+ async def guard_status(request):
+ return http.ok({'enabled': self.is_recording_enabled()})
+
+ asyncio.set_event_loop(asyncio.new_event_loop()) # need to create new event loop in new thread
+ http.serve(self.addr, routes, handle_signals=False) # handle_signals=True doesn't work in separate thread
diff --git a/include/py/homekit/soundsensor/server_client.py b/include/py/homekit/soundsensor/server_client.py
new file mode 100644
index 0000000..7eef996
--- /dev/null
+++ b/include/py/homekit/soundsensor/server_client.py
@@ -0,0 +1,38 @@
+import requests
+import logging
+
+from ..util import Addr
+from ..api.errors import ApiResponseError
+
+
+class SoundSensorServerGuardClient:
+ def __init__(self, addr: Addr):
+ self.endpoint = f'http://{addr[0]}:{addr[1]}'
+ self.logger = logging.getLogger(self.__class__.__name__)
+
+ def guard_enable(self):
+ return self._call('guard/enable', is_post=True)
+
+ def guard_disable(self):
+ return self._call('guard/disable', is_post=True)
+
+ def guard_status(self):
+ return self._call('guard/status')
+
+ def _call(self,
+ method: str,
+ is_post=False):
+
+ url = f'{self.endpoint}/{method}'
+ self.logger.debug(f'calling {url}')
+
+ r = requests.get(url) if not is_post else requests.post(url)
+
+ if r.status_code != 200:
+ response = r.json()
+ raise ApiResponseError(status_code=r.status_code,
+ error_type=response['error'],
+ error_message=response['message'] or None,
+ error_stacktrace=response['stacktrace'] if 'stacktrace' in response else None)
+
+ return r.json()['response']