Compare commits

...

15 Commits

Author SHA1 Message Date
a0e0651703 Logging, timeout 2022-04-01 17:53:37 -06:00
8fa91a9c55 Remove watchdog 2022-04-01 17:35:52 -06:00
055b12ee05 Implement our own unifi websocket connection 2022-04-01 17:34:37 -06:00
14051edfc5 Misc 2021-12-19 05:29:10 +00:00
8a05f1aacb Fix update bugs 2021-11-29 06:53:32 +00:00
0053b78e41 Add outer loop to ws listener 2021-11-29 04:49:17 +00:00
6ecb570abf Upgrade pyunifiprotect 2021-11-29 04:44:35 +00:00
760ce18290 Increase pulse length 2021-10-16 02:05:16 +01:00
02e7e017b0 Add license 2021-10-16 01:57:25 +01:00
e9b0b08fee Improve logging, watchdog 2021-10-16 01:51:08 +01:00
f2a9b94cf6 Add README 2021-10-15 18:46:32 -06:00
59fcd694b6 Abstract pulse_relay, improve watchdog 2021-10-16 01:33:57 +01:00
f8e5e9cba2 Update requrements, settings 2021-10-16 01:28:00 +01:00
5a29373838 Don't feed the watch dog in DEBUG mode 2021-10-15 15:48:19 -06:00
a7c3e13fcb Improve logging, add watchdog 2021-10-15 15:43:27 -06:00
6 changed files with 247 additions and 63 deletions

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2021 Tanner Collin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

110
README.md Normal file
View File

@@ -0,0 +1,110 @@
# Doorbell Ding Dong Ring Ring Doorbell
A doorbell chime controller for my house. Runs on a Raspberry Pi and talks to wifi Unifi G4 Doorbells.
## Setup
### Misc
Set up user account, add user to `sudo`, `adm`, `gpio` groups.
Set up wifi:
```text
$ sudo raspi-config
- System Options -> Wireless LAN -> Canada
- Enter wifi credentials
- Reboot
```
If wifi doesn't work, you're on your own.
For the watchdog to work, we need write access to `/dev/watchdog/`.
Configure `/etc/udev/rules.d/60-watchdog.rules`:
```text
KERNEL=="watchdog", MODE="0666"
```
Change the hostname:
```text
$ sudoedit /etc/hostname
$ sudoedit /etc/hosts
```
### Script
Install dependencies:
```text
$ sudo apt update
$ sudo apt install python3 python3-pip python-virtualenv python3-virtualenv supervisor python3-rpi.gpio
```
**Make sure you have at least Python 3.9 installed.**
Clone this repo:
```text
$ cd
$ git clone https://tanner@git.tannercollin.com/tanner/doorbelldingdongringringdoorbell.git
$ cd doorbelldingdongringringdoorbell/
$ virtualenv --system-site-packages -p python3 env
$ source env/bin/activate
(env) $ pip install -r requirements.txt
```
Edit settings for your setup:
```text
$ cp settings.py.example settings.py
$ vim settings.py
```
Now you can run the script to test:
```text
$ source env/bin/activate
(env) $ DEBUG=true python main.py # no watchdog
(env) $ python main.py # uses watchdog
```
The watchdog will activate if a doorbell is pressed. It will reboot the Pi if the script stops.
## Process management
The script is kept alive with [supervisor](https://pypi.org/project/supervisor/).
Configure `/etc/supervisor/conf.d/doorbell.conf`:
```text
[program:doorbell]
user=tanner
directory=/home/tanner/doorbelldingdongringringdoorbell
command=/home/tanner/doorbelldingdongringringdoorbell/env/bin/python -u main.py
stopasgroup=true
stopsignal=INT
autostart=true
autorestart=true
stderr_logfile=/var/log/doorbell.log
stderr_logfile_maxbytes=10MB
stdout_logfile=/var/log/doorbell.log
stdout_logfile_maxbytes=10MB
```
Then run:
```text
$ sudo supervisorctl reread; sudo supervisorctl reload
```
Script logs to /var/log/doorbell.log. Remove `-u` from the above command when you're done testing to save SD card writes.
## License
This program is free and open-source software licensed under the MIT License. Please see the `LICENSE` file for details.
That means you have the right to study, change, and distribute the software and source code to anyone and for any purpose. You deserve these rights.

105
main.py
View File

@@ -1,78 +1,92 @@
import os
import logging
import os, logging
DEBUG = os.environ.get('DEBUG')
logging.basicConfig(
format='[%(asctime)s] %(levelname)s %(module)s/%(funcName)s - %(message)s',
level=logging.DEBUG if os.environ.get('DEBUG') else logging.INFO)
logging.getLogger('aiohttp').setLevel(logging.DEBUG if os.environ.get('DEBUG') else logging.WARNING)
level=logging.DEBUG if DEBUG else logging.INFO)
logging.getLogger('aiohttp').setLevel(logging.DEBUG if DEBUG else logging.WARNING)
import json
import os
import sys
import asyncio
import aiohttp
try:
import RPi.GPIO as GPIO
IS_PI = True
except ModuleNotFoundError:
logging.info('RPi.GPIO not found, running without GPIO.')
IS_PI = False
import time
from aiohttp import ClientSession, CookieJar
from signal import *
import unifi
import settings
from pyunifiprotect.unifi_protect_server import UpvServer
RELAY_ON = False
RELAY_OFF = True
def ring_bell(mac):
cooldown_time = time.time()
def set_relay(pin, state):
if IS_PI: GPIO.output(pin, state)
logging.info('Set relay on pin %s to %s', pin, 'ON' if state == RELAY_ON else 'OFF')
def pulse_relay(pin):
set_relay(pin, RELAY_ON)
time.sleep(0.25) # atomic
set_relay(pin, RELAY_OFF)
def ring_bell(camera):
global cooldown_time
if time.time() - cooldown_time < 2:
logging.info('Cooldown skipping.')
return
cooldown_time = time.time()
try:
doorbell = settings.DOORBELLS[mac]
GPIO.output(doorbell['gpio'], RELAY_ON)
time.sleep(0.5)
GPIO.output(doorbell['gpio'], RELAY_OFF)
doorbell = settings.DOORBELLS[camera]
pulse_relay(doorbell['gpio'])
except KeyError:
logging.error('Doorbell %s not found!', mac)
logging.error('Doorbell %s not found!', camera)
def subscriber(updated):
logging.debug('Subscription: updated=%s', updated)
for _, data in updated.items():
if data['event_type'] == 'ring' and data['event_ring_on']:
logging.info('%s: %s is ringing!', data['mac'], data['name'])
ring_bell(data['mac'])
async def process_message(msg):
if msg.get('type', '') != 'ring':
return
async def ws_listener():
session = ClientSession(cookie_jar=CookieJar(unsafe=True))
logging.info('Ring message: %s', msg)
unifiprotect = UpvServer(
session,
settings.UFP_ADDRESS,
settings.UFP_PORT,
settings.UFP_USERNAME,
settings.UFP_PASSWORD,
)
ring_bell(msg['camera'])
await unifiprotect.check_unifi_os()
await unifiprotect.update()
async def main():
while True:
try:
async for msg in unifi.connect():
await process_message(msg)
except BaseException as e:
logging.exception('Error connecting to Unifi Protect: %s. Trying again...', str(e))
await asyncio.sleep(5)
unsub = unifiprotect.subscribe_websocket(subscriber)
for i in range(15000):
await asyncio.sleep(1)
await session.close()
unsub()
def disable_relays_on_exit(*args):
logging.info('Exiting, disabling relays...')
for _, doorbell in settings.DOORBELLS.items():
GPIO.output(doorbell['gpio'], RELAY_OFF)
set_relay(doorbell['gpio'], RELAY_OFF)
logging.info('Goodbye.')
os._exit(0)
def init():
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
if IS_PI: GPIO.setmode(GPIO.BCM)
if IS_PI: GPIO.setwarnings(False)
for _, doorbell in settings.DOORBELLS.items():
GPIO.setup(doorbell['gpio'], GPIO.OUT)
GPIO.output(doorbell['gpio'], RELAY_OFF)
if IS_PI: GPIO.setup(doorbell['gpio'], GPIO.OUT)
set_relay(doorbell['gpio'], RELAY_OFF)
#pulse_relay(doorbell['gpio'])
time.sleep(1)
logging.info('GPIO initialized')
for sig in (SIGABRT, SIGILL, SIGINT, SIGSEGV, SIGTERM):
@@ -80,8 +94,11 @@ def init():
logging.info('Signals initialized')
if __name__ == '__main__':
logging.info('')
logging.info('======================================')
logging.info('Boot up...')
init()
loop = asyncio.get_event_loop()
loop.run_until_complete(ws_listener())
loop.run_until_complete(main())
loop.close()

View File

@@ -1,15 +1,9 @@
aiohttp==3.7.4.post0
async-timeout==3.0.1
asyncio==3.4.3
attrs==21.2.0
chardet==4.0.0
click==8.0.3
aiohttp==3.8.1
aiosignal==1.2.0
async-timeout==4.0.2
attrs==21.4.0
charset-normalizer==2.0.12
frozenlist==1.3.0
idna==3.3
multidict==5.2.0
Pillow==8.4.0
PyJWT==2.2.0
python-dotenv==0.19.1
pyunifiprotect==0.33.0
typer==0.4.0
typing-extensions==3.10.0.2
yarl==1.7.0
multidict==6.0.2
yarl==1.7.2

View File

@@ -5,11 +5,11 @@ UFP_PORT = 443
DOORBELLS = {
'123456780ABC': {
name: 'Front Door',
gpio: 26,
'name': 'Front Door',
'gpio': 19,
},
'123456780ABC': {
name: 'Side Door',
gpio: 19,
'name': 'Side Door',
'gpio': 26,
},
}

41
unifi.py Normal file
View File

@@ -0,0 +1,41 @@
import asyncio
import aiohttp
import zlib
import struct
import json
import settings
HEADER_LENGTH = 8
async def connect():
data = dict(
username=settings.UFP_USERNAME,
password=settings.UFP_PASSWORD,
rememberMe=True,
)
async with aiohttp.ClientSession() as session:
async with session.post(settings.UFP_ADDRESS + '/api/auth/login', json=data, ssl=False) as resp:
cookie = resp.cookies['TOKEN']
headers = {'cookie': cookie.key + '=' + cookie.value}
async with session.ws_connect(settings.UFP_ADDRESS + '/proxy/protect/ws/updates', headers=headers, ssl=False) as ws:
async for msg in ws:
packet_type, payload_format, deflated, unknown, payload_size = struct.unpack('!bbbbi', msg.data[0:HEADER_LENGTH])
action_start = HEADER_LENGTH
action_packet = zlib.decompress(msg.data[action_start:])
data_start = payload_size + 2*HEADER_LENGTH
data_packet = zlib.decompress(msg.data[data_start:])
yield json.loads(data_packet.decode())
async def test():
async for msg in connect():
print(msg)
if __name__ == '__main__':
loop = asyncio.get_event_loop()
loop.run_until_complete(test())
loop.close()