init
This commit is contained in:
		
							
								
								
									
										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