diff --git a/.gitignore b/.gitignore index 68bc17f..41edece 100644 --- a/.gitignore +++ b/.gitignore @@ -1,160 +1,2 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# 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/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +/sql +/.env \ No newline at end of file diff --git a/README.md b/README.md index ba67db8..ad8cb98 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,10 @@ DB_USERNAME=postgres DB_PASSWORD=postgres DB_DATABASE=fab_access +MQTT_PORT=1883 MQTT_USERNAME=user MQTT_PASSWORD=password MQTT_BROKER=mqtt MQTT_CLIENT=FabMan ``` + diff --git a/docker-compose.yml b/docker-compose.yml index 887004b..1abe1ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,10 @@ services: MQTT_PASSWORD: ${MQTT_PASSWORD:?err} MQTT_BROKER: ${MQTT_BROKER:?err} MQTT_CLIENT: ${MQTT_CLIENT:?err} + # Various + MACHINES: ${MACHINES:?err} restart: unless-stopped + db: image: postgres:15-alpine volumes: @@ -30,8 +33,11 @@ services: POSTGRES_DB: ${DB_DATABASE} POSTGRES_USER: ${DB_USERNAME} POSTGRES_PASSWORD: ${DB_PASSWORD} + mqtt: image: eclipse-mosquitto:2 + ports: + - 1883:1883 environment: MQTT_USERNAME: ${MQTT_USERNAME} MQTT_PASSWORD: ${MQTT_PASSWORD} diff --git a/fab_access/config.py b/fab_access/config.py index 084d7b4..3701757 100644 --- a/fab_access/config.py +++ b/fab_access/config.py @@ -1,33 +1,27 @@ import os -def _read_from_env(key: str, default: str = None) -> str: - if (value := os.environ.get(key, default)) is not None: - return value - raise Exception(f'[Config] Error: Cannot find required value {key}') +class Config: -def get_keycloak_config(): - return { - 'server_url': _read_from_env('KEYCLOAK_URL'), - 'username': _read_from_env('KEYCLOAK_USERNAME'), - 'password': _read_from_env('KEYCLOAK_PASSWORD'), - 'realm_name': _read_from_env('REALM'), - } + def _read_from_env(key: str, default: str = None) -> str: + if (value := os.environ.get(key, default)) is not None: + return value + raise Exception(f'[Config] Error: Cannot find required value {key}') -def get_database_config(): - return { - 'host': _read_from_env('DB_HOSTNAME'), - 'user': _read_from_env('DB_USERNAME'), - 'port': int(_read_from_env('DB_PORT', '3306')), - 'password': _read_from_env('DB_PASSWORD'), - 'dbname': _read_from_env('DB_DATABASE'), - } + mqtt_broker = _read_from_env('MQTT_BROKER') + mqtt_port = int(_read_from_env('MQTT_PORT','1883')) + mqtt_client_id = _read_from_env('MQTT_CLIENT') + mqtt_user_name = _read_from_env('MQTT_USERNAME') + mqtt_password = _read_from_env('MQTT_PASSWORD') -def get_mqtt_config(): - print("port", int(_read_from_env('MQTT_PORT', '1883'))) - return { - 'client_name': _read_from_env('MQTT_CLIENT'), - 'username': _read_from_env('MQTT_USERNAME', ''), - 'password': _read_from_env('MQTT_PASSWORD', ''), - 'broker': _read_from_env('MQTT_BROKER'), - 'port': int(_read_from_env('MQTT_PORT', '1883')), - } + keycloak_url = _read_from_env('KEYCLOAK_URL') + keycloak_user_name = _read_from_env('KEYCLOAK_USER_NAME') + keycloak_password = _read_from_env('KEYCLOAK_USER_PW') + keycloak_realm = _read_from_env('KEYCLOAK_REALM') + + db_host_name = _read_from_env('DB_HOSTNAME') + db_user_name = _read_from_env('DB_USERNAME') + db_password = _read_from_env('DB_PASSWORD') + db_database = _read_from_env('DB_DATABASE') + db_port = int(_read_from_env('DB_PORT','5432')) + + machines = _read_from_env('MACHINES') \ No newline at end of file diff --git a/fab_access/keycloak_handler.py b/fab_access/keycloak_handler.py new file mode 100644 index 0000000..faddd3f --- /dev/null +++ b/fab_access/keycloak_handler.py @@ -0,0 +1,45 @@ +from config import Config +from keycloak import KeycloakAdmin + + +class KeycloakHandler: + Config.keycloak_password + + @staticmethod + def login(): + KeycloakHandler.admin = KeycloakAdmin( + server_url=Config.keycloak_url, + username=Config.keycloak_user_name, + password=Config.keycloak_password, + realm_name=Config.keycloak_realm, + verify=True + ) + + @staticmethod + def get_user_by_card_id(card_id): + users = KeycloakHandler.admin.get_users() + # Filter not working for Attributes because of multidimensional JSON + + user = [user for user in users if "attributes" in user and "FabCard" in user["attributes"] and user["attributes"]["FabCard"] == [card_id]] + print(f'Found {len(user)} user(s) with card_id: {card_id}') + + match len(user): + case 0: + return None + case 1: + print(f'FabCard matches with user {user[0]["username"]}') + return user[0] + case other: + print(f'Error! too many users with card_id: {card_id}') + return None + + @staticmethod + def user_is_privileged(username): + groups = KeycloakHandler.admin.get_user_groups(user_id=KeycloakHandler.admin.get_user_id(username)) + groups = [group['name'] for group in groups] + + if 'Mentoren' in groups: + print('Overrided becouse of "Mentor" group') + return True + else: + return False diff --git a/fab_access/main.py b/fab_access/main.py index 054a4e8..9a014e0 100644 --- a/fab_access/main.py +++ b/fab_access/main.py @@ -1,154 +1,100 @@ -from keycloak import KeycloakAdmin import json -import psycopg import os -from paho.mqtt.client import Client as MQTTClient +from config import Config +from mqtt_client import MqttHandler +from keycloak_handler import KeycloakHandler +from sql_handler import SQLHandler -import config -from mqtt_helper import MQTTHelper +def has_permission(user_permissions, machine_id): + parsed_permissions = [permission.split('.') for permission in user_permissions] + parsed_machine_id = machine_id.split('.') -conn: psycopg.Connection -keycloak_admin: KeycloakAdmin + for permission in parsed_permissions: + missmatch = False + for i, id_sequence in enumerate(parsed_machine_id): + if permission[i] == '*': + return True + if permission[i] != id_sequence: + missmatch = True + break + if not missmatch: + return True -def publish(client, topic,msg): - result = client.publish(topic, msg) - status: 0 | 1 = result[0] - if status == 0: - #print(f"Send `{msg}` to topic `{topic}`") - pass - else: - print(f"Failed to send message to topic {topic}") + return False -def changePlug(client: MQTTClient, plug_id: str, is_running: bool): - publish(client,f"/FabLogging/{plug_id}/POWER", is_running) - cmd = "On" if is_running else "Off" - publish(client, f"/aktoren/{plug_id}/cmnd/POWER", cmd) - -def changeDisplay(client: MQTTClient, reader_id: str, status: int, fab_card_id: str): - publish(client, f"/cmnd/reader/{reader_id}", '{"Cmd": "message", "MssgID": %s , "ClrTxt":"" , "AddnTxt":"%s"}' % (status, fab_card_id)) - -def hasPermission(UserPermsJSON, PermissionPath): - # ToDo: refactor this - permission = False - error = 0 - MachinePermArray = PermissionPath.split(".") - for UserPerm in UserPermsJSON: - #print(f"check {UserPerm}") - UserPermArray = UserPerm.split(".") - x = 0 - for UserPermPart in UserPermArray: - if(error == 0): - #print(f"Compare {UserPermPart} and {MachinePermArray[x]}") - if not (MachinePermArray[x] == UserPermPart): - if(UserPermPart == "*"): - #print("* regelt") - permission = True - else: - error = 1 - #print(f"MISmatch between {MachinePermArray[x]} and {UserPermPart}") - else: - pass - #print(f"Match between {MachinePermArray[x]} and {UserPermPart}") - x = x + 1 - if(error == 1): - pass - #print("Error") - else: - #print("Hurra") - permission = True - error = 0 - return permission - -def handle_msg(client: MQTTClient, userdata, msg): - global conn - - payload = msg.payload.decode() - print(f"Received `{payload}` from `{msg.topic}` topic") - - fab_card_id = json.loads(msg.payload.decode())["UID"] - reader_id = msg.topic.split("/")[-1] - - # check user exists - users = keycloak_admin.get_users({ 'attributes': { 'FabCard': fab_card_id } }) - if (len(users) != 1): - print(f'Found {len(users)} users with {fab_card_id=}') - changeDisplay(client, reader_id, 16, fab_card_id) - return - - # retrieve user attributes from DB - user = users[0] - print(f"FabCard matches with user {user['username']}") - with conn.cursor() as cursor: - cursor.execute(f'SELECT PlugName,PermissionPath,Status,LastUser FROM ReaderPlug WHERE ReaderID=?;', (reader_id,)) - res = cursor.fetchone() - if res is None: - print(f'Error fetching card info from db for {reader_id=}') - return - plug_id, permission_path, is_running, last_user = res - - # check permissions - user_permissions = user['attributes']['FabPermissions'][0] - if not hasPermission(user_permissions, permission_path): - changeDisplay(client, reader_id, 7, "") - return - - # check for mentor - if is_running and last_user != user['username']: +def gen_display_name(user): + # display names must be not longer than 8 chrs + if 'firstName' in user.keys() and 'lastName' in user.keys(): + full_name = f'{user["firstName"]} {user["lastName"]}' + if len(full_name) > 8: + display_name = f'{user["firstName"][0]}.{user["lastName"][:6]}' + else: try: - if (keycloak_admin.get_user_groups(user_id=user['id'])[0]['name'] == 'Mentoren'): - print('Overrided becouse of "Mentor" Group') - else: - print(f'Bereits benutzt von {last_user}') - error = True - except IndexError: - print("No groups available") - error = True - if(error): - changeDisplay(client, reader_id, 9, last_user) + display_name = user['username'][:8] + except KeyError: + print('user has no username') + return 'Error' + return(display_name) - # toggle machine state - print(f"Turn Plug {'off' if is_running else 'on'}") - changePlug(client, plug_id, is_running) - publish(client, f"/FabLogging/{plug_id}/USER", user['username']) +def handle_request(msg, client): + print('') + print(f'Received `{msg.payload.decode()}` from `{msg.topic}` topic') + fabcard_id = json.loads(msg.payload.decode())['UID'] + reader_id = msg.topic.split('/')[-1] + + KeycloakHandler.login() + user = KeycloakHandler.get_user_by_card_id(fabcard_id) + if not user: + MqttHandler.print_to_display(reader_id, 16, fabcard_id) + return + + db_data = SQLHandler.get_machine_data(reader_id) + + machine_id = db_data["machine_id"] + last_user = db_data["last_user"] + machine_status = db_data["machine_status"] + plug_id = db_data["plug_id"] - # ToDo: refactor display name construction try: - firstCombo = user['firstName']+" " - DisplayUser = firstCombo+user['lastName'] - if(len(firstCombo) >= 7): - DisplayUser = user['firstName'][:7] + user_permissions = json.loads(user['attributes']['FabPermissions'][0]) except KeyError: - DisplayUser = user['username'] - if(len(DisplayUser) > 7): - DisplayUser = DisplayUser[0:7] + "." + DisplayUser[7+1: ] - DisplayUser = DisplayUser[:8] + print(f'user with id {fabcard_id} is missing FabPermissions attr') + except IndexError: + print(f'user with id {fabcard_id} is missing FabPermissions attr') - changeDisplay(client, reader_id, 20, f"Login\n{DisplayUser}" if is_running else "Bitte anmelden") + if not has_permission(user_permissions, machine_id): + print(f"user with id {fabcard_id} is missing {machine_id}") + MqttHandler.print_to_display(reader_id, 7, '') + return + + username = user['username'] + display_name = gen_display_name(user) + + if not machine_status: + print(f'Turn Plug {plug_id} on') + MqttHandler.switch_plug(plug_id, 1) + MqttHandler.print_to_display(reader_id, 20, f'Login\n{display_name}') + else: + if not (username == last_user or KeycloakHandler.user_is_privileged(username)): + MqttHandler.print_to_display(reader_id, 9, last_user) + return + print(f'Turn Plug {plug_id} off') + MqttHandler.switch_plug(plug_id, 0) + MqttHandler.print_to_display(reader_id, 20, f'Bitte anmelden') + + SQLHandler.update_machine(reader_id, username, machine_status) + MqttHandler.publish(f'/FabLogging/{plug_id}/USER', username) - # write new status to db - with conn.cursor() as cursor: - cursor.execute( - f'UPDATE ReaderPlug SET Status=?, LastUser="?" WHERE ReaderID="?";', - (is_running, user['username'], reader_id), - ) - conn.commit() def main(): - global conn - global keycloak_admin - - mqtt_client = MQTTHelper(**config.get_mqtt_config()) - mqtt_client.subscribe("/rfid_reader/#", handle_msg) - mqtt_client.loop_forever() - - try: - conn = psycopg.connect(**config.get_database_config()) - except mariadb.Error as e: - print(f"Error connecting to MariaDB Platform") - raise e - - keycloak_admin = KeycloakAdmin(**config.get_keycloak_config(), verify=True) + MqttHandler.setup(handle_request) + KeycloakHandler.login() + SQLHandler.setup() + MqttHandler.connect_mqtt() + MqttHandler.subscribe("/rfid_reader/#") + SQLHandler.init_db() + MqttHandler.loop() if __name__ == '__main__': main() diff --git a/fab_access/mqtt_client.py b/fab_access/mqtt_client.py new file mode 100644 index 0000000..e64d9e5 --- /dev/null +++ b/fab_access/mqtt_client.py @@ -0,0 +1,62 @@ +from paho.mqtt import client as mqtt_client +from config import Config +import json + +class MqttHandler: + @staticmethod + def setup(msg_handler): + MqttHandler.msg_handler = msg_handler + MqttHandler.client = None + + @staticmethod + def connect_mqtt(): + def on_connect(client, userdata, flags, rc): + if rc == 0: + print('Connected to MQTT Broker!') + else: + print('Failed to connect, return code %d\n', rc) + + MqttHandler.client = mqtt_client.Client(Config.mqtt_client_id) + MqttHandler.client.username_pw_set('admin', 'user') + MqttHandler.client.on_connect = on_connect + MqttHandler.client.username_pw_set(Config.mqtt_user_name, Config.mqtt_password) + MqttHandler.client.connect(Config.mqtt_broker, Config.mqtt_port) + + @staticmethod + def publish(topic, msg): + result = MqttHandler.client.publish(topic, msg) + # result: [0, 1] + status = result[0] + if status == 0: + #print(f'Send `{msg}` to topic `{topic}`') + pass + else: + print(f'Failed to send message to topic {topic}') + + + @staticmethod + def subscribe(topic): + def on_message(client, userdata, msg): + MqttHandler.msg_handler(msg, client) + + MqttHandler.client.subscribe(topic) + MqttHandler.client.on_message = on_message + + @staticmethod + def loop(): + MqttHandler.client.loop_forever() + + @staticmethod + def switch_plug(PlugID, state): + MqttHandler.publish(f'/FabLogging/{PlugID}/POWER', 1 if state else 0) + MqttHandler.publish(f'/aktoren/{PlugID}/cmnd/POWER', 'On' if state else 'OFF') + + @staticmethod + def print_to_display(MachineID, status, text): + message = { + 'Cmd': 'message', + 'MssgID': status, + 'ClrTxt': '', + 'AddnTxt': text, + } + MqttHandler.publish(f'/cmnd/reader/{MachineID}', json.dumps(message)) diff --git a/fab_access/sql_handler.py b/fab_access/sql_handler.py new file mode 100644 index 0000000..d321a98 --- /dev/null +++ b/fab_access/sql_handler.py @@ -0,0 +1,82 @@ +import psycopg2 +from psycopg2 import sql, extensions +import json + +from config import Config + +class SQLHandler: + @staticmethod + def setup(): + SQLHandler.cursor = None + SQLHandler.conn = None + SQLHandler.conn = psycopg2.connect(host=Config.db_host_name, user=Config.db_user_name, port=Config.db_port, password=Config.db_password, dbname=Config.db_database) + # get the isolation leve for autocommit + autocommit = extensions.ISOLATION_LEVEL_AUTOCOMMIT + print ("ISOLATION_LEVEL_AUTOCOMMIT:", extensions.ISOLATION_LEVEL_AUTOCOMMIT) + + # set the isolation level for the connection's cursors + # will raise ActiveSqlTransaction exception otherwise + SQLHandler.conn.set_isolation_level( autocommit ) + SQLHandler.cursor = SQLHandler.conn.cursor() + + @staticmethod + def get_machine_data(reader_id): + # TODO Database + # - get machine_id from database + # - get last_user from database + # - get machine_status from database, is 0 for off, 1 for on + # - get plug_id from database + SQLHandler.cursor.execute("SELECT * FROM readerplug WHERE readerplug.reader_id = %s;", (reader_id,)) + data = [row for row in SQLHandler.cursor.fetchall()] + if(len(data) > 0): + return { + 'machine_id': data[0][2], + 'last_user': data[0][4], + 'machine_status': data[0][3], + 'plug_id': data[0][0] + } + else: + print("No maching Card Reader found in db") + return "Error" + + @staticmethod + def update_machine(reader_id,last_user,machine_status): + SQLHandler.cursor.execute("UPDATE readerplug SET machine_status = %s WHERE readerplug.reader_id = %s", (False if machine_status else True,reader_id)) + SQLHandler.cursor.execute("UPDATE readerplug SET last_user = %s WHERE readerplug.reader_id = %s", (last_user,reader_id)) + SQLHandler.conn.commit() + + @staticmethod + def init_db(): + SQLHandler.cursor.execute("SELECT datname FROM pg_database;") + dbs = [row[0] for row in SQLHandler.cursor.fetchall()] + if not (Config.db_database in dbs): + print(f"Missing database ({Config.db_database}) -> creating new db") + SQLHandler.cursor.execute(sql.SQL("CREATE DATABASE {};").format(sql.Identifier( Config.db_database ))) + else: + print(f"Found DB {Config.db_database} -> Using existing one") + + SQLHandler.cursor.execute("SELECT * FROM pg_catalog.pg_tables\ + WHERE schemaname != 'pg_catalog' AND \ + schemaname != 'information_schema';") + tables = [row[1] for row in SQLHandler.cursor.fetchall()] + if not ("readerplug" in tables): + print("Missing table -> creating new table in db") + SQLHandler.cursor.execute("\ + CREATE TABLE readerplug (\ + reader_id int NOT NULL, \ + plug_id varchar(255) NOT NULL, \ + machine_id varchar(255) NOT NULL, \ + machine_status boolean NOT NULL, \ + last_user varchar(255) NOT NULL \ + )") + else: + print("Found Table -> Using existing one") + SQLHandler.conn.commit() + + SQLHandler.cursor.execute("SELECT * FROM readerplug;") + if(len(SQLHandler.cursor.fetchall()) < 1): + print("Found no machines in table, adding machines from config") + machines = json.loads(Config.machines) + for machine in machines: + SQLHandler.cursor.execute("INSERT INTO readerplug (reader_id, plug_id, machine_id, machine_status, last_user) VALUES (%s, %s, %s, False, 'no_user');", (machine[0],machine[1],machine[2])) + SQLHandler.conn.commit() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c57afff..6a6ef13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ keycloak_wrapper paho-mqtt -psycopg[binary] -python-keycloak +psycopg2-binary +python-keycloak \ No newline at end of file diff --git a/test.py b/test.py new file mode 100644 index 0000000..ccfeef2 --- /dev/null +++ b/test.py @@ -0,0 +1,7 @@ +from keycloak import KeycloakAdmin + +keycloak_admin = KeycloakAdmin(server_url="https://auth.sfz-aalen.space/auth/", + username='luca.lutz', + password='LiviT2005', + realm_name="master", + verify=True) \ No newline at end of file