Init
This commit is contained in:
1
barcode_server/__init__.py
Normal file
1
barcode_server/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
__version__ = "2.3.0"
|
170
barcode_server/barcode.py
Normal file
170
barcode_server/barcode.py
Normal 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
94
barcode_server/cli.py
Normal 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
250
barcode_server/config.py
Normal 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
32
barcode_server/const.py
Normal 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"
|
198
barcode_server/keyevent_reader.py
Normal file
198
barcode_server/keyevent_reader.py
Normal 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
|
103
barcode_server/notifier/__init__.py
Normal file
103
barcode_server/notifier/__init__.py
Normal 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()
|
||||
|
30
barcode_server/notifier/http.py
Normal file
30
barcode_server/notifier/http.py
Normal 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}")
|
38
barcode_server/notifier/mqtt.py
Normal file
38
barcode_server/notifier/mqtt.py
Normal 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}")
|
23
barcode_server/notifier/ws.py
Normal file
23
barcode_server/notifier/ws.py
Normal 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
28
barcode_server/stats.py
Normal 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
38
barcode_server/util.py
Normal 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
180
barcode_server/webserver.py
Normal 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())))
|
Reference in New Issue
Block a user