This commit is contained in:
2022-06-24 12:14:10 -07:00
commit 27fc1f5e16
31 changed files with 3769 additions and 0 deletions

View File

@ -0,0 +1 @@
__version__ = "2.3.0"

170
barcode_server/barcode.py Normal file
View File

@ -0,0 +1,170 @@
import asyncio
import logging
import uuid
from datetime import datetime
from pathlib import Path
from typing import List, Dict
import evdev
from evdev import *
from barcode_server.config import AppConfig
from barcode_server.keyevent_reader import KeyEventReader
from barcode_server.stats import SCAN_COUNT, DEVICES_COUNT, DEVICE_DETECTION_TIME
LOGGER = logging.getLogger(__name__)
class BarcodeEvent:
def __init__(self, input_device: InputDevice, barcode: str, date: datetime = None):
self.id = str(uuid.uuid4())
self.date = date if date is not None else datetime.now()
self.device = input_device
self.input_device = self.device
self.barcode = barcode
class BarcodeReader:
"""
Reads barcodes from all USB barcode scanners in the system
"""
def __init__(self, config: AppConfig):
self.config = config
self.devices = {}
self.listeners = set()
self._keyevent_reader = KeyEventReader()
self._main_task = None
self._device_tasks = {}
async def start(self):
"""
Start detecting and reading barcode scanner devices
"""
self._main_task = asyncio.create_task(self._detect_and_read())
async def stop(self):
"""
Stop detecting and reading barcode scanner devices
"""
if self._main_task is None:
return
for device_path, t in self._device_tasks.items():
t.cancel()
self._device_tasks.clear()
self._main_task.cancel()
self._main_task = None
async def _detect_and_read(self):
"""
Detect barcode scanner devices and start readers for them
"""
while True:
try:
self.devices = self._find_devices(self.config.DEVICE_PATTERNS.value, self.config.DEVICE_PATHS.value)
DEVICES_COUNT.set(len(self.devices))
for path, d in self.devices.items():
if path in self._device_tasks:
continue
LOGGER.info(
f"Reading: {d.path}: Name: {d.name}, "
f"Vendor: {d.info.vendor:04x}, Product: {d.info.product:04x}")
task = asyncio.create_task(self._start_reader(d))
self._device_tasks[path] = task
await asyncio.sleep(1)
except Exception as e:
logging.exception(e)
await asyncio.sleep(10)
async def _start_reader(self, input_device):
"""
Start a reader for a specific device
:param input_device: the input device
"""
try:
# become the sole recipient of all incoming input events
input_device.grab()
while True:
barcode = await self._read_line(input_device)
if barcode is not None and len(barcode) > 0:
event = BarcodeEvent(input_device, barcode)
asyncio.create_task(self._notify_listeners(event))
except Exception as e:
LOGGER.exception(e)
self._device_tasks.pop(input_device.path)
finally:
try:
# release device
input_device.ungrab()
except Exception as e:
pass
@staticmethod
@DEVICE_DETECTION_TIME.time()
def _find_devices(patterns: List, paths: List[str]) -> Dict[str, InputDevice]:
"""
# Finds the input device with the name ".*Barcode Reader.*".
# Could and should be parameterized, of course. Device name as cmd line parameter, perhaps?
:param patterns: list of patterns to match the device name against
:return: Map of ("Device Path" -> InputDevice) items
"""
result = {}
# find devices
devices = evdev.list_devices()
# create InputDevice instances
devices = [evdev.InputDevice(fn) for fn in devices]
# filter by device name
devices = list(filter(lambda d: any(map(lambda y: y.match(d.name), patterns)), devices))
# add manually defined paths
for path in paths:
try:
if Path(path).exists():
devices.append(evdev.InputDevice(path))
else:
logging.warning(f"Path doesn't exist: {path}")
except Exception as e:
logging.exception(e)
for d in devices:
result[d.path] = d
return result
async def _read_line(self, input_device: InputDevice) -> str or None:
"""
Read a single line (ENTER stops input) from the given device
:param input_device: the device to listen on
:return: a barcode
"""
# Using a thread executor here is a workaround for
# input_device.async_read_loop() skipping input events sometimes,
# so we use a synchronous method instead.
# While not perfect, it has a much higher success rate.
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, self._keyevent_reader.read_line, input_device)
return result
def add_listener(self, listener: callable):
"""
Add a barcode event listener
:param listener: async callable taking two arguments
"""
self.listeners.add(listener)
async def _notify_listeners(self, event: BarcodeEvent):
"""
Notifies all listeners about the scanned barcode
:param event: barcode event
"""
SCAN_COUNT.inc()
LOGGER.info(f"{event.input_device.name} ({event.input_device.path}): {event.barcode}")
for listener in self.listeners:
asyncio.create_task(listener(event))

94
barcode_server/cli.py Normal file
View File

@ -0,0 +1,94 @@
import asyncio
import logging
import os
import signal
import sys
import click
from container_app_conf.formatter.toml import TomlFormatter
from prometheus_client import start_http_server
parent_dir = os.path.abspath(os.path.join(os.path.abspath(__file__), "..", ".."))
sys.path.append(parent_dir)
logging.basicConfig(level=logging.WARNING, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
LOGGER = logging.getLogger(__name__)
loop = asyncio.get_event_loop()
def signal_handler(signal=None, frame=None):
LOGGER.info("Exiting...")
os._exit(0)
CMD_OPTION_NAMES = {
}
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
@click.group(context_settings=CONTEXT_SETTINGS)
@click.version_option()
def cli():
pass
def get_option_names(parameter: str) -> list:
"""
Returns a list of all valid console parameter names for a given parameter
:param parameter: the parameter to check
:return: a list of all valid names to use this parameter
"""
return CMD_OPTION_NAMES[parameter]
@cli.command(name="run")
def c_run():
"""
Run the barcode-server
"""
from barcode_server.barcode import BarcodeReader
from barcode_server.config import AppConfig
from barcode_server.webserver import Webserver
signal.signal(signal.SIGINT, signal_handler)
config = AppConfig()
log_level = logging._nameToLevel.get(str(config.LOG_LEVEL.value).upper(), config.LOG_LEVEL.default)
LOGGER = logging.getLogger("barcode_server")
LOGGER.setLevel(log_level)
LOGGER.info("=== barcode-server ===")
LOGGER.info(f"Instance ID: {config.INSTANCE_ID.value}")
barcode_reader = BarcodeReader(config)
webserver = Webserver(config, barcode_reader)
# start prometheus server
if config.STATS_PORT.value is not None:
LOGGER.info("Starting statistics webserver...")
start_http_server(config.STATS_PORT.value)
tasks = asyncio.gather(
webserver.start(),
)
loop.run_until_complete(tasks)
loop.run_forever()
@cli.command(name="config")
def c_config():
"""
Print the current configuration of barcode-server
"""
from barcode_server.config import AppConfig
config = AppConfig()
click.echo(config.print(TomlFormatter()))
if __name__ == '__main__':
cli()

250
barcode_server/config.py Normal file
View File

@ -0,0 +1,250 @@
# Copyright (c) 2019 Markus Ressel
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import logging
import re
import uuid
from container_app_conf import ConfigBase
from container_app_conf.entry.bool import BoolConfigEntry
from container_app_conf.entry.file import FileConfigEntry
from container_app_conf.entry.int import IntConfigEntry
from container_app_conf.entry.list import ListConfigEntry
from container_app_conf.entry.regex import RegexConfigEntry
from container_app_conf.entry.string import StringConfigEntry
from container_app_conf.entry.timedelta import TimeDeltaConfigEntry
from container_app_conf.source.env_source import EnvSource
from container_app_conf.source.toml_source import TomlSource
from container_app_conf.source.yaml_source import YamlSource
from py_range_parse import Range
from barcode_server.const import *
class AppConfig(ConfigBase):
def __new__(cls, *args, **kwargs):
yaml_source = YamlSource(CONFIG_NODE_ROOT)
toml_source = TomlSource(CONFIG_NODE_ROOT)
data_sources = [
EnvSource(),
yaml_source,
toml_source,
]
return super(AppConfig, cls).__new__(cls, data_sources=data_sources)
LOG_LEVEL = StringConfigEntry(
description="Log level",
key_path=[
CONFIG_NODE_ROOT,
"log_level"
],
regex=re.compile(f" {'|'.join(logging._nameToLevel.keys())}", flags=re.IGNORECASE),
default="INFO",
)
INSTANCE_ID = StringConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
"id"
],
regex=re.compile("[0-9a-zA-Z\.\_\-\+\/\#]+"),
default=str(uuid.uuid4()),
required=True
)
SERVER_HOST = StringConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_SERVER,
"host"
],
default=DEFAULT_SERVER_HOST,
secret=True)
SERVER_PORT = IntConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_SERVER,
CONFIG_NODE_PORT
],
range=Range(1, 65534),
default=DEFAULT_SERVER_PORT)
SERVER_API_TOKEN = StringConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_SERVER,
"api_token"
],
default=None,
secret=True
)
DROP_EVENT_QUEUE_AFTER = TimeDeltaConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
"drop_event_queue_after"
],
default="2h",
)
RETRY_INTERVAL = TimeDeltaConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
"retry_interval"
],
default="2s",
)
HTTP_METHOD = StringConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_HTTP,
"method"
],
required=True,
default="POST",
regex="GET|POST|PUT|PATCH"
)
HTTP_URL = StringConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_HTTP,
"url"
],
required=False
)
HTTP_HEADERS = ListConfigEntry(
item_type=StringConfigEntry,
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_HTTP,
"headers"
],
default=[]
)
MQTT_HOST = StringConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_MQTT,
"host"
],
required=False
)
MQTT_PORT = IntConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_MQTT,
"port"
],
required=True,
default=1883,
range=Range(1, 65534),
)
MQTT_CLIENT_ID = StringConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_MQTT,
"client_id"
],
default="barcode-server"
)
MQTT_USER = StringConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_MQTT,
"user"
]
)
MQTT_PASSWORD = StringConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_MQTT,
"password"
],
secret=True
)
MQTT_TOPIC = StringConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_MQTT,
"topic"
],
default="barcode-server/barcode",
required=True
)
MQTT_QOS = IntConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_MQTT,
"qos"
],
default=2,
required=True
)
MQTT_RETAIN = BoolConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_MQTT,
"retain"
],
default=False,
required=True
)
DEVICE_PATTERNS = ListConfigEntry(
item_type=RegexConfigEntry,
item_args={
"flags": re.IGNORECASE
},
key_path=[
CONFIG_NODE_ROOT,
"devices"
],
default=[]
)
DEVICE_PATHS = ListConfigEntry(
item_type=FileConfigEntry,
key_path=[
CONFIG_NODE_ROOT,
"device_paths"
],
default=[]
)
STATS_PORT = IntConfigEntry(
key_path=[
CONFIG_NODE_ROOT,
CONFIG_NODE_STATS,
CONFIG_NODE_PORT
],
default=8000,
required=False
)
def validate(self):
super(AppConfig, self).validate()
if len(self.DEVICE_PATHS.value) == len(self.DEVICE_PATTERNS.value) == 0:
raise AssertionError("You must provide at least one device pattern or device_path!")

32
barcode_server/const.py Normal file
View File

@ -0,0 +1,32 @@
# Copyright (c) 2019 Markus Ressel
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
Client_Id = "Client-ID"
Drop_Event_Queue = "Drop-Event-Queue"
X_Auth_Token = "X-Auth-Token"
CONFIG_NODE_ROOT = "barcode_server"
CONFIG_NODE_SERVER = "server"
CONFIG_NODE_HTTP = "http"
CONFIG_NODE_MQTT = "mqtt"
CONFIG_NODE_STATS = "stats"
CONFIG_NODE_PORT = "port"
DEFAULT_SERVER_HOST = "127.0.0.1"
DEFAULT_SERVER_PORT = 9654
ENDPOINT_DEVICES = "devices"

View File

@ -0,0 +1,198 @@
import logging
from evdev import KeyEvent, InputDevice, categorize
LOGGER = logging.getLogger(__name__)
US_EN_UPPER_DICT = {
"`": "~",
"1": "!",
"2": "@",
"3": "#",
"4": "$",
"5": "%",
"6": "^",
"7": "&",
"8": "*",
"9": "(",
"0": ")",
"-": "_",
"=": "+",
",": "<",
".": ">",
"/": "?",
";": ":",
"'": "\"",
"\\": "|",
"[": "{",
"]": "}"
}
class KeyEventReader:
"""
Class used to convert a sequence of KeyEvents to text
"""
def __init__(self):
self._shift = False
self._caps = False
self._alt = False
self._unicode_number_input_buffer = ""
self._line = ""
def read_line(self, input_device: InputDevice) -> str:
"""
Reads a line
:param input_device: the device to read from
:return: line
"""
self._line = ""
# While there is a function called async_read_loop, it tends
# to skip input events, so we use the non-async read-loop here.
# async for event in input_device.async_read_loop():
for event in input_device.read_loop():
try:
event = categorize(event)
if hasattr(event, "event"):
if not hasattr(event, "keystate") and hasattr(event.event, "keystate"):
event.keystate = event.event.keystate
if not hasattr(event, "keystate") or not hasattr(event, "keycode"):
continue
keycode = event.keycode
keystate = event.keystate
if isinstance(event, KeyEvent):
if self._on_key_event(keycode, keystate):
return self._line
elif hasattr(event, "event") and event.event.type == 1:
if self._on_key_event(keycode, keystate):
return self._line
except Exception as ex:
LOGGER.exception(ex)
def _on_key_event(self, code: str, state: int) -> bool:
if code in ["KEY_ENTER", "KEY_KPENTER"]:
if state == KeyEvent.key_up:
# line is finished
self._reset_modifiers()
return True
elif code in ["KEY_RIGHTSHIFT", "KEY_LEFTSHIFT"]:
if state in [KeyEvent.key_down, KeyEvent.key_hold]:
self._shift = True
else:
self._shift = False
elif code in ["KEY_LEFTALT", "KEY_RIGHTALT"]:
if state in [KeyEvent.key_down, KeyEvent.key_hold]:
self._alt = True
else:
self._alt = False
character = self._unicode_numbers_to_character(self._unicode_number_input_buffer)
self._unicode_number_input_buffer = ""
if character is not None:
self._line += character
elif code == "KEY_BACKSPACE":
self._line = self._line[:-1]
elif state == KeyEvent.key_down:
character = self._code_to_character(code)
if self._alt:
self._unicode_number_input_buffer += character
else:
if character is not None and not self._alt:
# append the current character
self._line += character
return False
def _code_to_character(self, code: str) -> chr or None:
character = None
if len(code) == 5:
character = code[-1]
elif code.startswith("KEY_KP") and len(code) == 7:
character = code[-1]
elif code in ["KEY_DOWN"]:
character = '\n'
elif code in ["KEY_SPACE"]:
character = ' '
elif code in ["KEY_ASTERISK", "KEY_KPASTERISK"]:
character = '*'
elif code in ["KEY_MINUS", "KEY_KPMINUS"]:
character = '-'
elif code in ["KEY_PLUS", "KEY_KPPLUS"]:
character = '+'
elif code in ["KEY_QUESTION"]:
character = '?'
elif code in ["KEY_COMMA", "KEY_KPCOMMA"]:
character = ','
elif code in ["KEY_DOT", "KEY_KPDOT"]:
character = '.'
elif code in ["KEY_EQUAL", "KEY_KPEQUAL"]:
character = '='
elif code in ["KEY_LEFTPAREN", "KEY_KPLEFTPAREN"]:
character = '('
elif code in ["KEY_PLUSMINUS", "KEY_KPPLUSMINUS"]:
character = '+-'
elif code in ["KEY_RIGHTPAREN", "KEY_KPRIGHTPAREN"]:
character = ')'
elif code in ["KEY_RIGHTBRACE"]:
character = ']'
elif code in ["KEY_LEFTBRACE"]:
character = '['
elif code in ["KEY_SLASH", "KEY_KPSLASH"]:
character = '/'
elif code in ["KEY_BACKSLASH"]:
character = '\\'
elif code in ["KEY_COLON"]:
character = ';'
elif code in ["KEY_SEMICOLON"]:
character = ';'
elif code in ["KEY_APOSTROPHE"]:
character = '\''
elif code in ["KEY_GRAVE"]:
character = '`'
if character is None:
character = code[4:]
if len(character) > 1:
LOGGER.warning(f"Unhandled Keycode: {code}")
if self._shift or self._caps:
character = character.upper()
if character in US_EN_UPPER_DICT.keys():
character = US_EN_UPPER_DICT[character]
else:
character = character.lower()
return character
@staticmethod
def _unicode_numbers_to_character(code: str) -> chr or None:
if code is None or len(code) <= 0:
return None
try:
# convert to hex
i = int(code)
h = hex(i)
s = f"{h}"
return bytearray.fromhex(s[2:]).decode('utf-8')
except Exception as ex:
LOGGER.exception(ex)
return None
def _reset_modifiers(self):
self._alt = False
self._unicode_number_input_buffer = ""
self._shift = False
self._caps = False

View File

@ -0,0 +1,103 @@
import asyncio
import logging
from asyncio import Task, QueueEmpty
from datetime import datetime
from typing import Optional
from barcode_server.barcode import BarcodeEvent
from barcode_server.config import AppConfig
LOGGER = logging.getLogger(__name__)
class BarcodeNotifier:
"""
Base class for a notifier.
"""
def __init__(self):
self.config = AppConfig()
self.drop_event_queue_after = self.config.DROP_EVENT_QUEUE_AFTER.value
self.retry_interval = self.config.RETRY_INTERVAL.value
self.event_queue = asyncio.Queue()
self.processor_task: Optional[Task] = None
def is_running(self) -> bool:
return self.processor_task is not None
async def start(self):
"""
Starts the event processor of this notifier
"""
self.processor_task = asyncio.create_task(self.event_processor())
async def stop(self):
"""
Stops the event processor of this notifier
"""
if self.processor_task is None:
return
self.processor_task.cancel()
self.processor_task = None
async def drop_queue(self):
"""
Drops all items in the event queue
"""
running = self.is_running()
# stop if currently running
if running:
await self.stop()
# mark all items as finished
for _ in range(self.event_queue.qsize()):
try:
self.event_queue.get_nowait()
self.event_queue.task_done()
except QueueEmpty as ex:
break
# restart if it was running
if running:
await self.start()
async def event_processor(self):
"""
Processes the event queue
"""
while True:
try:
event = await self.event_queue.get()
success = False
while not success:
if datetime.now() - event.date >= self.drop_event_queue_after:
# event is older than threshold, so we just skip it
self.event_queue.task_done()
break
try:
await self._send_event(event)
success = True
self.event_queue.task_done()
except Exception as ex:
LOGGER.exception(ex)
await asyncio.sleep(self.retry_interval.total_seconds())
except Exception as ex:
LOGGER.exception(ex)
async def add_event(self, event: BarcodeEvent):
"""
Adds an event to the event queue
"""
await self.event_queue.put(event)
async def _send_event(self, event: BarcodeEvent):
"""
Sends the given event to the notification target
:param event: barcode event
"""
raise NotImplementedError()

View File

@ -0,0 +1,30 @@
import logging
from typing import List
import aiohttp
from prometheus_async.aio import time
from barcode_server.barcode import BarcodeEvent
from barcode_server.notifier import BarcodeNotifier
from barcode_server.stats import HTTP_NOTIFIER_TIME
from barcode_server.util import barcode_event_to_json
LOGGER = logging.getLogger(__name__)
class HttpNotifier(BarcodeNotifier):
def __init__(self, method: str, url: str, headers: List[str]):
super().__init__()
self.method = method
self.url = url
headers = list(map(lambda x: tuple(x.split(':', 1)), headers))
self.headers = list(map(lambda x: (x[0].strip(), x[1].strip()), headers))
@time(HTTP_NOTIFIER_TIME)
async def _send_event(self, event: BarcodeEvent):
json = barcode_event_to_json(self.config.INSTANCE_ID.value, event)
async with aiohttp.ClientSession() as session:
async with session.request(self.method, self.url, headers=self.headers, data=json) as resp:
resp.raise_for_status()
LOGGER.debug(f"Notified {self.url}: {event.barcode}")

View File

@ -0,0 +1,38 @@
import logging
from asyncio_mqtt import Client
from prometheus_async.aio import time
from barcode_server.barcode import BarcodeEvent
from barcode_server.notifier import BarcodeNotifier
from barcode_server.stats import MQTT_NOTIFIER_TIME
from barcode_server.util import barcode_event_to_json
LOGGER = logging.getLogger(__name__)
class MQTTNotifier(BarcodeNotifier):
def __init__(self, host: str, port: int = 1883,
topic: str = "/barcode-server/barcode",
client_id: str = "barcode-server",
user: str = None, password: str = None,
qos: int = 2, retain: bool = False):
super().__init__()
self.client_id = client_id
self.host = host
self.port = port
self.user = user
self.password = password
self.topic = topic
self.qos = qos
self.retain = retain
@time(MQTT_NOTIFIER_TIME)
async def _send_event(self, event: BarcodeEvent):
json = barcode_event_to_json(self.config.INSTANCE_ID.value, event)
async with Client(hostname=self.host, port=self.port,
username=self.user, password=self.password,
client_id=self.client_id) as client:
await client.publish(self.topic, json, self.qos, self.retain)
LOGGER.debug(f"Notified {self.host}:{self.port}: {event.barcode}")

View File

@ -0,0 +1,23 @@
from prometheus_async.aio import time
from barcode_server.barcode import BarcodeEvent
from barcode_server.notifier import BarcodeNotifier
from barcode_server.stats import WEBSOCKET_NOTIFIER_TIME
from barcode_server.util import barcode_event_to_json
class WebsocketNotifier(BarcodeNotifier):
def __init__(self, websocket):
super().__init__()
self.websocket = websocket
@time(WEBSOCKET_NOTIFIER_TIME)
async def _send_event(self, event: BarcodeEvent):
json = barcode_event_to_json(self.config.INSTANCE_ID.value, event)
await self.websocket.send_bytes(json)
# TODO: cant log websocket address here because we don't have access
# to an unique identifier anymore, maybe we need to store one manually
# when the websocket is connected initially...
# LOGGER.debug(f"Notified {client.remote_address}")

28
barcode_server/stats.py Normal file
View File

@ -0,0 +1,28 @@
from prometheus_client import Gauge, Summary
from barcode_server.const import *
WEBSOCKET_CLIENT_COUNT = Gauge(
'websocket_client_count',
'Number of currently connected websocket clients'
)
DEVICES_COUNT = Gauge(
'devices_count',
'Number of currently detected devices'
)
SCAN_COUNT = Gauge(
'scan_count',
'Number of times a scan has been detected'
)
DEVICE_DETECTION_TIME = Summary('device_detection_processing_seconds', 'Time spent detecting devices')
REST_TIME = Summary('rest_endpoint_processing_seconds', 'Time spent in a rest command handler', ['endpoint'])
REST_TIME_DEVICES = REST_TIME.labels(endpoint=ENDPOINT_DEVICES)
NOTIFIER_TIME = Summary('notifier_processing_seconds', 'Time spent in a notifier', ['type'])
WEBSOCKET_NOTIFIER_TIME = NOTIFIER_TIME.labels(type='websocket')
HTTP_NOTIFIER_TIME = NOTIFIER_TIME.labels(type='http')
MQTT_NOTIFIER_TIME = NOTIFIER_TIME.labels(type='mqtt')

38
barcode_server/util.py Normal file
View File

@ -0,0 +1,38 @@
from evdev import InputDevice
from barcode_server.barcode import BarcodeEvent
def input_device_to_dict(input_device: InputDevice) -> dict:
"""
Converts an input device to a a dictionary with human readable values
:param input_device: the device to convert
:return: dictionary
"""
return {
"name": input_device.name,
"path": input_device.path,
"vendorId": f"{input_device.info.vendor: 04x}",
"productId": f"{input_device.info.product: 04x}",
}
def barcode_event_to_json(server_id: str, event: BarcodeEvent) -> bytes:
"""
Converts a barcode event to json
:param server_id: server instance id
:param event: the event to convert
:return: json representation
"""
import orjson
event = {
"id": event.id,
"serverId": server_id,
"date": event.date.isoformat(),
"device": input_device_to_dict(event.input_device),
"barcode": event.barcode
}
json = orjson.dumps(event)
return json

180
barcode_server/webserver.py Normal file
View File

@ -0,0 +1,180 @@
import asyncio
import logging
from typing import Dict
import aiohttp
from aiohttp import web
from aiohttp.web_middlewares import middleware
from prometheus_async.aio import time
from barcode_server.barcode import BarcodeReader, BarcodeEvent
from barcode_server.config import AppConfig
from barcode_server.const import *
from barcode_server.notifier import BarcodeNotifier
from barcode_server.notifier.http import HttpNotifier
from barcode_server.notifier.mqtt import MQTTNotifier
from barcode_server.notifier.ws import WebsocketNotifier
from barcode_server.stats import REST_TIME_DEVICES, WEBSOCKET_CLIENT_COUNT
from barcode_server.util import input_device_to_dict
LOGGER = logging.getLogger(__name__)
routes = web.RouteTableDef()
class Webserver:
def __init__(self, config: AppConfig, barcode_reader: BarcodeReader):
self.config = config
self.host = config.SERVER_HOST.value
self.port = config.SERVER_PORT.value
self.clients = {}
self.barcode_reader = barcode_reader
self.barcode_reader.add_listener(self.on_barcode)
self.notifiers: Dict[str, BarcodeNotifier] = {}
if config.HTTP_URL.value is not None:
http_notifier = HttpNotifier(
config.HTTP_METHOD.value,
config.HTTP_URL.value,
config.HTTP_HEADERS.value)
self.notifiers["http"] = http_notifier
if config.MQTT_HOST.value is not None:
mqtt_notifier = MQTTNotifier(
host=config.MQTT_HOST.value,
port=config.MQTT_PORT.value,
client_id=config.MQTT_CLIENT_ID.value,
user=config.MQTT_USER.value,
password=config.MQTT_PASSWORD.value,
topic=config.MQTT_TOPIC.value,
qos=config.MQTT_QOS.value,
retain=config.MQTT_RETAIN.value,
)
self.notifiers["mqtt"] = mqtt_notifier
async def start(self):
# start detecting and reading barcode scanners
await self.barcode_reader.start()
# start notifier queue processors
for key, notifier in self.notifiers.items():
LOGGER.debug(f"Starting notifier: {key}")
await notifier.start()
LOGGER.info(f"Starting webserver on {self.config.SERVER_HOST.value}:{self.config.SERVER_PORT.value} ...")
app = self.create_app()
runner = aiohttp.web.AppRunner(app)
await runner.setup()
site = aiohttp.web.TCPSite(
runner,
host=self.config.SERVER_HOST.value,
port=self.config.SERVER_PORT.value
)
await site.start()
# wait forever
return await asyncio.Event().wait()
def create_app(self) -> web.Application:
app = web.Application(middlewares=[self.authentication_middleware])
app.add_routes(routes)
return app
@middleware
async def authentication_middleware(self, request, handler):
if self.config.SERVER_API_TOKEN.value is not None and \
(X_Auth_Token not in request.headers.keys()
or request.headers[X_Auth_Token] != self.config.SERVER_API_TOKEN.value):
LOGGER.warning(f"Rejecting unauthorized connection: {request.host}")
return web.HTTPUnauthorized()
if Client_Id not in request.headers.keys():
LOGGER.warning(f"Rejecting client without {Client_Id} header: {request.host}")
return web.HTTPBadRequest()
client_id = request.headers[Client_Id].lower().strip()
if self.clients.get(client_id, None) is not None:
LOGGER.warning(
f"Rejecting new connection of already connected client {request.headers[Client_Id]}: {request.host}")
return web.HTTPBadRequest()
return await handler(self, request)
@routes.get(f"/{ENDPOINT_DEVICES}")
@time(REST_TIME_DEVICES)
async def devices_handle(self, request):
import orjson
device_list = list(map(input_device_to_dict, self.barcode_reader.devices.values()))
json = orjson.dumps(device_list)
return web.Response(body=json, content_type="application/json")
@routes.get("/")
async def websocket_handler(self, request):
client_id = request.headers[Client_Id].lower().strip()
websocket = web.WebSocketResponse()
await websocket.prepare(request)
self.clients[client_id] = websocket
active_client_count = self.count_active_clients()
known_client_ids_count = len(self.clients.keys())
# TODO: report both the mount of currently connected clients, as well as known client ids
WEBSOCKET_CLIENT_COUNT.set(active_client_count)
if client_id not in self.notifiers.keys():
LOGGER.debug(
f"New client connected: {client_id} (from {request.host})")
LOGGER.debug(f"Creating new notifier for client id: {client_id}")
notifier = WebsocketNotifier(websocket)
self.notifiers[client_id] = notifier
else:
LOGGER.debug(
f"Previously seen client reconnected: {client_id} (from {request.host})")
notifier = self.notifiers[client_id]
if isinstance(notifier, WebsocketNotifier):
notifier.websocket = websocket
if Drop_Event_Queue in request.headers.keys():
await notifier.drop_queue()
LOGGER.debug(f"Starting notifier: {client_id}")
await notifier.start()
try:
async for msg in websocket:
if msg.type == aiohttp.WSMsgType.TEXT:
if msg.data.strip() == 'close':
await websocket.close()
else:
await websocket.send_str(msg.data + '/answer')
elif msg.type == aiohttp.WSMsgType.ERROR:
LOGGER.debug('ws connection closed with exception %s' %
websocket.exception())
except Exception as e:
LOGGER.exception(e)
finally:
# TODO: should we remove this notifier after some time?
LOGGER.debug(f"Stopping notifier: {client_id}")
await notifier.stop()
self.clients[client_id] = None
self.clients.pop(client_id)
active_client_count = self.count_active_clients()
WEBSOCKET_CLIENT_COUNT.set(active_client_count)
LOGGER.debug(f"Client disconnected: {client_id} (from {request.host})")
return websocket
async def on_barcode(self, event: BarcodeEvent):
for key, notifier in self.notifiers.items():
await notifier.add_event(event)
def count_active_clients(self):
"""
Counts the number of clients with an active websocket connection
:return: number of active clients
"""
return len(list(filter(lambda x: x[1] is not None, self.clients.items())))