init
This commit is contained in:
commit
d2de4e13d7
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal file
@ -0,0 +1,66 @@
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# PyCharm
|
||||
.idea/
|
||||
|
||||
# Other
|
||||
venv/
|
||||
run.sh
|
18
README.md
Normal file
18
README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# K-Door-Pi
|
||||
|
||||
Client for K-Space door system. Run on Raspberry Pi and controlls the doors
|
||||
|
||||
# Running it
|
||||
|
||||
```sh
|
||||
export KDOORPI_DOOR="name" # Door name
|
||||
export KDOORPI_API_ALLOWED="http://server/allowed" # Used to fetch allowed cards
|
||||
export KDOORPI_API_LONGPOLL="http://server/longpoll" # Open door now and force allowed cards sync endpoint
|
||||
export KDOORPI_API_SWIPE="http://server/swipe" # On card swipe post log line here
|
||||
export KDOORPI_API_KEY="keykeykey" # api key for accessing allowed and longpoll api endpoints
|
||||
export KDOORPI_UID_SALT="saltsaltsalt" # shared slat used to hash card uid bytes
|
||||
|
||||
python3 -m kdoorpi
|
||||
```
|
||||
|
||||
You can also use the `run.sh.example` by copying it to `run.sh` and changing variables inside it.
|
26
contrib/uidhash.py
Normal file
26
contrib/uidhash.py
Normal file
@ -0,0 +1,26 @@
|
||||
import secrets
|
||||
from functools import partial
|
||||
|
||||
try:
|
||||
from hashlib import scrypt
|
||||
uid_hash = partial(scrypt, n=16384, r=8, p=1)
|
||||
except ImportError:
|
||||
# Python 3.5 has to use external py-scrypt package from pypi
|
||||
from scrypt import hash as scrypt
|
||||
uid_hash = partial(scrypt, N=16384, r=8, p=1)
|
||||
|
||||
# print(secrets.token_urlsafe())
|
||||
UID_SALT = "hkRXwLlQKmCJoy5qaahp"
|
||||
|
||||
|
||||
def hash_uid(uid: str, salt: str = UID_SALT) -> str:
|
||||
return uid_hash(bytes.fromhex(uid), salt=salt.encode()).hex()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
UID = "01 23 AD F0"
|
||||
ch = hash_uid(UID)
|
||||
h = 'a6d9ba36ecb5f8e6312f40ee260ad59e9cca3c6ce073bf072df3324c0072886196e6823a7c758ab567fc53e91fbbda297a4efe0072e41316c56446ef126a5180'
|
||||
print("UID:", UID)
|
||||
print("hash:", ch)
|
||||
print("correct", ch == h)
|
114
kdoorpi/__init__.py
Normal file
114
kdoorpi/__init__.py
Normal file
@ -0,0 +1,114 @@
|
||||
import logging
|
||||
import threading
|
||||
import requests
|
||||
import time
|
||||
from functools import partial
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
try:
|
||||
from hashlib import scrypt
|
||||
UID_HASH = partial(scrypt, n=16384, r=8, p=1)
|
||||
except ImportError:
|
||||
# Python 3.5 needs pip install scrypt
|
||||
from scrypt import hash as scrypt
|
||||
UID_HASH = partial(scrypt, N=16384, r=8, p=1)
|
||||
|
||||
from .wiegand import Decoder
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
def hash_uid(uid: str, salt: str) -> str:
|
||||
return UID_HASH(bytes.fromhex(uid), salt=salt.encode()).hex()
|
||||
|
||||
|
||||
class Main:
|
||||
|
||||
def __init__(self,
|
||||
door,
|
||||
api_allowed,
|
||||
api_longpoll,
|
||||
api_swipe,
|
||||
api_key,
|
||||
uid_hash):
|
||||
self.door = door
|
||||
self.api_allowed = api_allowed
|
||||
self.api_longpoll = api_longpoll
|
||||
self.api_swipe = api_swipe
|
||||
self.uid_hash = uid_hash
|
||||
|
||||
self.uids = {}
|
||||
|
||||
self.force_sync_now = threading.Event()
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"KEY": api_key})
|
||||
|
||||
self.sync_cards()
|
||||
logging.info("Running")
|
||||
self.wiegand = Decoder(self.wiegand_callback)
|
||||
self.notify_thread = threading.Thread(target=self.listen_notification, daemon=True)
|
||||
self.notify_thread.start()
|
||||
self.auto_sync_loop()
|
||||
|
||||
def sync_cards(self):
|
||||
logging.info("Downloading users list")
|
||||
r = self.session.get(self.api_allowed)
|
||||
try:
|
||||
allowed_uids = r.json()["allowed_uids"]
|
||||
except JSONDecodeError as e:
|
||||
logging.exception("Failed to decode allowed uids json")
|
||||
return
|
||||
uids = set()
|
||||
for token in allowed_uids:
|
||||
uids.add(token["token"]["uid_hash"].strip())
|
||||
self.uids = uids
|
||||
|
||||
def wiegand_callback(self, bits, value):
|
||||
#print("bits", bits, "value", value)
|
||||
uid_h = hash_uid(value, self.uid_hash)
|
||||
logging.debug("hash %s", uid_h)
|
||||
if uid_h in self.uids:
|
||||
logging.info("Opening door for UID hash trail %s", uid_h[-10:])
|
||||
self.wiegand.open_door()
|
||||
success = True
|
||||
else:
|
||||
logging.info("Access card not in allow list, hash trail %s", uid_h[-10:])
|
||||
success = False
|
||||
data = {
|
||||
"uid": value,
|
||||
"door": self.door,
|
||||
"success": success,
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
print(data)
|
||||
requests.post(self.api_swipe, data=data)
|
||||
|
||||
def listen_notification(self):
|
||||
while 1:
|
||||
try:
|
||||
r = self.session.get(self.api_longpoll,
|
||||
headers={"Connection": "close"},
|
||||
timeout=60, stream=True)
|
||||
for line in r.iter_lines(1, decode_unicode=True):
|
||||
if not line.strip():
|
||||
continue
|
||||
logging.debug("Got notification: %s", line)
|
||||
if self.door in line:
|
||||
logging.info("Opening door from notify")
|
||||
self.wiegand.open_door()
|
||||
self.force_sync_now.set()
|
||||
except (requests.Timeout,
|
||||
requests.ConnectionError) as e:
|
||||
logging.debug("notification timeout")
|
||||
time.sleep(0.1)
|
||||
|
||||
def auto_sync_loop(self):
|
||||
|
||||
while 1:
|
||||
try:
|
||||
self.force_sync_now.wait(60*10) # == 10min
|
||||
self.force_sync_now.clear()
|
||||
self.sync_cards()
|
||||
except KeyboardInterrupt as e:
|
||||
self.wiegand.cancel()
|
||||
break
|
30
kdoorpi/__main__.py
Normal file
30
kdoorpi/__main__.py
Normal file
@ -0,0 +1,30 @@
|
||||
import os
|
||||
|
||||
from . import Main
|
||||
|
||||
"""
|
||||
if "eesuks" in buf:
|
||||
door = "front"
|
||||
elif "valis" in buf:
|
||||
door = "ground"
|
||||
elif "taga" in buf:
|
||||
door = "back"
|
||||
else:
|
||||
door = "unknown"
|
||||
"""
|
||||
|
||||
door = os.environ["KDOORPI_DOOR"]
|
||||
api_allowed = os.environ["KDOORPI_API_ALLOWED"]
|
||||
api_longpoll = os.environ["KDOORPI_API_LONGPOLL"]
|
||||
api_swipe = os.environ["KDOORPI_API_SWIPE"]
|
||||
api_key = os.environ["KDOORPI_API_KEY"]
|
||||
uid_salt = os.environ["KDOORPI_UID_SALT"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Main(door,
|
||||
api_allowed,
|
||||
api_longpoll,
|
||||
api_swipe,
|
||||
api_key,
|
||||
uid_salt)
|
150
kdoorpi/wiegand.py
Normal file
150
kdoorpi/wiegand.py
Normal file
@ -0,0 +1,150 @@
|
||||
#!/bin/python
|
||||
import logging
|
||||
from time import sleep
|
||||
|
||||
try:
|
||||
import pigpio
|
||||
except Exception:
|
||||
pigpio = False
|
||||
|
||||
|
||||
class Decoder:
|
||||
"""
|
||||
A class to read Wiegand codes of an arbitrary length.
|
||||
"""
|
||||
|
||||
def __init__(self, callback, bit_timeout=5):
|
||||
|
||||
"""
|
||||
Instantiate with the pi, gpio for 0 (green wire), the gpio for 1
|
||||
(white wire), the callback function, and the bit timeout in
|
||||
milliseconds which indicates the end of a code.
|
||||
|
||||
The callback is passed the code length in bits and the hex value.
|
||||
"""
|
||||
if pigpio:
|
||||
self.pi = pigpio.pi()
|
||||
else:
|
||||
self.pi = False
|
||||
|
||||
self.gpio_0 = 17 #settings.WIEGAND[0]
|
||||
self.gpio_1 = 18 #settings.WIEGAND[1]
|
||||
self.door_pin = 21 # from settings.py
|
||||
self.button_pin = 13 # from settings.py
|
||||
|
||||
self.callback = callback
|
||||
|
||||
self.bit_timeout = bit_timeout
|
||||
self.items = []
|
||||
self.in_code = False
|
||||
|
||||
if self.pi:
|
||||
self.pi.set_mode(self.gpio_0, pigpio.INPUT)
|
||||
self.pi.set_mode(self.gpio_1, pigpio.INPUT)
|
||||
self.pi.set_mode(self.door_pin, pigpio.OUTPUT)
|
||||
self.pi.set_mode(self.button_pin, pigpio.INPUT)
|
||||
|
||||
self.pi.set_pull_up_down(self.gpio_0, pigpio.PUD_UP)
|
||||
self.pi.set_pull_up_down(self.gpio_1, pigpio.PUD_UP)
|
||||
self.pi.set_pull_up_down(self.button_pin, pigpio.PUD_UP)
|
||||
|
||||
self.cb_0 = self.pi.callback(self.gpio_0, pigpio.FALLING_EDGE, self._cb)
|
||||
self.cb_1 = self.pi.callback(self.gpio_1, pigpio.FALLING_EDGE, self._cb)
|
||||
self.button_cb_h = self.pi.callback(self.button_pin, pigpio.FALLING_EDGE, self._cb)
|
||||
|
||||
def cut_empty(self, item):
|
||||
if item[0:8] == "00000000":
|
||||
return self.cut_empty(item[8:])
|
||||
else:
|
||||
return item
|
||||
|
||||
def get_hex(self):
|
||||
try:
|
||||
items = self.items
|
||||
if len(self.items) == 26:
|
||||
items = self.items[1:-1]
|
||||
elif len(self.items) == 64:
|
||||
items = self.cut_empty(self.items)
|
||||
|
||||
bits = []
|
||||
for i in range(len(items), 0, -8):
|
||||
bits.append(int(items[i - 8:i], 2))
|
||||
return (" ".join(map(lambda a: "%-0.2X" % ((a + 256) % 256), bits))).rstrip()
|
||||
|
||||
except ValueError:
|
||||
logging.error("Wiegand convert error: bin to hex convertion ended with ValeError. raw: " + str(self.items))
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Wiegand convert error: (raw: " + str(self.items) + ") " + str(e))
|
||||
return False
|
||||
|
||||
def _cb(self, gpio_pin, level, tick):
|
||||
|
||||
"""
|
||||
Accumulate bits until both gpios 0 and 1 timeout.
|
||||
"""
|
||||
|
||||
try:
|
||||
if level < pigpio.TIMEOUT:
|
||||
|
||||
if self.in_code:
|
||||
self.bits += 1
|
||||
else:
|
||||
logging.debug("Wiegand data transfer start")
|
||||
self.bits = 1
|
||||
self.items = ""
|
||||
self.in_code = True
|
||||
self.code_timeout = 0
|
||||
self.pi.set_watchdog(self.gpio_0, self.bit_timeout)
|
||||
self.pi.set_watchdog(self.gpio_1, self.bit_timeout)
|
||||
|
||||
if gpio_pin == self.gpio_0:
|
||||
self.code_timeout &= 2 # clear gpio 0 timeout
|
||||
self.items += "1"
|
||||
else:
|
||||
self.code_timeout &= 1 # clear gpio 1 timeout
|
||||
self.items += "0"
|
||||
|
||||
else:
|
||||
|
||||
if self.in_code:
|
||||
|
||||
if gpio_pin == self.gpio_0:
|
||||
self.code_timeout |= 1 # timeout gpio 0
|
||||
else:
|
||||
self.code_timeout |= 2 # timeout gpio 1
|
||||
|
||||
if self.code_timeout == 3: # both gpios timed out
|
||||
self.pi.set_watchdog(self.gpio_0, 0)
|
||||
self.pi.set_watchdog(self.gpio_1, 0)
|
||||
self.in_code = False
|
||||
|
||||
if self.bits >= 26:
|
||||
hex = self.get_hex()
|
||||
if hex:
|
||||
self.callback(self.bits, hex)
|
||||
else:
|
||||
logging.error("Wiegand receive error: Expected at least 26 got %i bits. raw: %s" %(self.bits, self.items))
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Wiegand callback error: " + str(e))
|
||||
|
||||
def button_cb(self, gpio_pin, level, tick):
|
||||
print("button: gpio_pin:{}, level:{}, tick:{}".format(gpio_pin, level, tick))
|
||||
|
||||
def open_door(self):
|
||||
self.pi.write(self.door_pin, 1)
|
||||
sleep(3)
|
||||
self.pi.write(self.door_pin, 0)
|
||||
|
||||
def cancel(self):
|
||||
|
||||
"""
|
||||
Cancel the Wiegand decoder.
|
||||
"""
|
||||
|
||||
self.cb_0.cancel()
|
||||
self.cb_1.cancel()
|
||||
self.button_cb_h.cancel()
|
||||
self.pi.stop()
|
3
pyproject.toml
Normal file
3
pyproject.toml
Normal file
@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["setuptools >= 40.6.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
10
run.sh.example
Executable file
10
run.sh.example
Executable file
@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
|
||||
export KDOORPI_DOOR="name" # Door name
|
||||
export KDOORPI_API_ALLOWED="http://server/allowed" # Used to fetch allowed cards
|
||||
export KDOORPI_API_LONGPOLL="http://server/longpoll" # Open door now and force allowed cards sync endpoint
|
||||
export KDOORPI_API_SWIPE="http://server/swipe" # On card swipe post log line here
|
||||
export KDOORPI_API_KEY="keykeykey" # api key for accessing allowed and longpoll api endpoints
|
||||
export KDOORPI_UID_SALT="saltsaltsalt" # shared slat used to hash card uid bytes
|
||||
|
||||
python3 -m kdoorpi
|
23
setup.py
Normal file
23
setup.py
Normal file
@ -0,0 +1,23 @@
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
setup(
|
||||
name='kdoorpi',
|
||||
version='0.0.0',
|
||||
author="Arti Zirk",
|
||||
author_email="arti@zirk.me",
|
||||
description="K-Space Door client that talks to the hardware",
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
python_requires='>=3.5',
|
||||
install_requires=["requests"],
|
||||
extras_require={},
|
||||
classifiers=[
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3 :: Only',
|
||||
'Topic :: System :: Networking',
|
||||
'Intended Audience :: System Administrators',
|
||||
]
|
||||
|
||||
)
|
Reference in New Issue
Block a user