diff options
-rw-r--r-- | hw/pcb.lay6 | bin | 0 -> 185796 bytes | |||
-rw-r--r-- | kvmd/.gitignore | 7 | ||||
-rw-r--r-- | kvmd/Makefile | 36 | ||||
-rw-r--r-- | kvmd/dev_requirements.txt | 6 | ||||
-rw-r--r-- | kvmd/kvmd.yaml | 64 | ||||
-rw-r--r-- | kvmd/kvmd/__init__.py | 197 | ||||
-rw-r--r-- | kvmd/kvmd/__main__.py | 2 | ||||
-rw-r--r-- | kvmd/kvmd/atx.py | 62 | ||||
-rw-r--r-- | kvmd/kvmd/streamer.py | 120 | ||||
-rw-r--r-- | kvmd/mypy.ini | 5 | ||||
-rw-r--r-- | kvmd/pylintrc | 62 | ||||
-rw-r--r-- | kvmd/requirements.txt | 6 | ||||
-rw-r--r-- | kvmd/tox.ini | 37 |
13 files changed, 604 insertions, 0 deletions
diff --git a/hw/pcb.lay6 b/hw/pcb.lay6 Binary files differnew file mode 100644 index 00000000..39700ab5 --- /dev/null +++ b/hw/pcb.lay6 diff --git a/kvmd/.gitignore b/kvmd/.gitignore new file mode 100644 index 00000000..1ed37632 --- /dev/null +++ b/kvmd/.gitignore @@ -0,0 +1,7 @@ +/build/ +/dist/ +/kvmd.egg-info/ +/.tox/ +/.mypy_cache/ +*.pyc +*.swp diff --git a/kvmd/Makefile b/kvmd/Makefile new file mode 100644 index 00000000..a6d8ac6a --- /dev/null +++ b/kvmd/Makefile @@ -0,0 +1,36 @@ +all: + cat Makefile + +bencoder: + python3 setup.py build_ext --inplace + +release: + make clean + make tox + make clean + make push + make bump + make push + make pypi + make clean + +tox: + tox + +bump: + bumpversion patch + +push: + git push + git push --tags + +pypi: + python3 setup.py register + python3 setup.py sdist upload + +clean: + rm -rf build site dist pkg src *.egg-info kvmd-*.tar.gz + find -name __pycache__ | xargs rm -rf + +clean-all: clean + rm -rf .tox .mypy_cache diff --git a/kvmd/dev_requirements.txt b/kvmd/dev_requirements.txt new file mode 100644 index 00000000..8452410a --- /dev/null +++ b/kvmd/dev_requirements.txt @@ -0,0 +1,6 @@ +git+git://github.com/willbuckner/rpi-gpio-development-mock@master#egg=rpi +aiohttp +contextlog +pyyaml +bumpversion +tox diff --git a/kvmd/kvmd.yaml b/kvmd/kvmd.yaml new file mode 100644 index 00000000..5a534a3a --- /dev/null +++ b/kvmd/kvmd.yaml @@ -0,0 +1,64 @@ +kvmd: + server: + host: localhost + port: 8081 + ws: + heartbeat: 3.0 + + keyboard: + pinout: + clock: 17 + data: 4 + delay: 0.0002 + + atx: + leds: + pinout: + power: 16 + hdd: 12 + poll: 0.1 + + switches: + pinout: + power: 26 + reset: 20 + click_delay: 0.1 + long_click_delay: 5.5 + + video: + pinout: + cap: 21 + vga: 25 + sync_delay: 1.0 + + mjpg_streamer: + prog: /usr/bin/mjpg_streamer + device: /dev/video0 + width: 720 + height: 576 + every: 2 + host: localhost + port: 8082 + +logging: + version: 1 + disable_existing_loggers: false + + formatters: + console: + (): contextlog.SmartFormatter + style: "{" + datefmt: "%H:%M:%S" + format: "[{asctime}] {app:10.10} {fg_bold_purple}{name:20.20} {log_color}{levelname:>7}{reset} {message} -- {cyan}{_extra}{reset}" + + handlers: + console: + level: DEBUG + class: logging.StreamHandler + stream: ext://sys.stdout + formatter: console + + root: + level: INFO + handlers: + - console diff --git a/kvmd/kvmd/__init__.py b/kvmd/kvmd/__init__.py new file mode 100644 index 00000000..10848c5b --- /dev/null +++ b/kvmd/kvmd/__init__.py @@ -0,0 +1,197 @@ +import asyncio +import argparse +import logging +import logging.config + +from typing import List +from typing import Dict +from typing import Set +from typing import Callable +from typing import Optional + +from contextlog import get_logger +from contextlog import patch_logging +from contextlog import patch_threading + +from RPi import GPIO + +import aiohttp + +import yaml + +from .atx import Atx +from .streamer import Streamer + + +# ===== +def _system_task(method: Callable) -> Callable: + async def wrap(self: "_Application") -> None: + try: + await method(self) + except asyncio.CancelledError: + pass + except Exception: + get_logger().exception("Unhandled exception") + raise SystemExit(1) + return wrap + + +class _Application: + def __init__(self, config: Dict) -> None: + self.__config = config + + self.__loop = asyncio.get_event_loop() + self.__sockets: Set[aiohttp.web.WebSocketResponse] = set() + self.__sockets_lock = asyncio.Lock() + + GPIO.setmode(GPIO.BCM) + + self.__atx = Atx( + power_led=self.__config["atx"]["leds"]["pinout"]["power"], + hdd_led=self.__config["atx"]["leds"]["pinout"]["hdd"], + power_switch=self.__config["atx"]["switches"]["pinout"]["power"], + reset_switch=self.__config["atx"]["switches"]["pinout"]["reset"], + click_delay=self.__config["atx"]["switches"]["click_delay"], + long_click_delay=self.__config["atx"]["switches"]["long_click_delay"], + ) + + self.__streamer = Streamer( + cap_power=self.__config["video"]["pinout"]["cap"], + vga_power=self.__config["video"]["pinout"]["vga"], + sync_delay=self.__config["video"]["sync_delay"], + mjpg_streamer=self.__config["video"]["mjpg_streamer"], + ) + + self.__system_futures: List[asyncio.Future] = [] + + def run(self) -> None: + app = aiohttp.web.Application(loop=self.__loop) + app.router.add_get("/", self.__root_handler) + app.router.add_get("/ws", self.__ws_handler) + app.on_shutdown.append(self.__on_shutdown) + app.on_cleanup.append(self.__on_cleanup) + + self.__system_futures.extend([ + asyncio.ensure_future(self.__poll_dead_sockets(), loop=self.__loop), + asyncio.ensure_future(self.__poll_atx_leds(), loop=self.__loop), + asyncio.ensure_future(self.__poll_streamer_events(), loop=self.__loop), + ]) + + aiohttp.web.run_app( + app=app, + host=self.__config["server"]["host"], + port=self.__config["server"]["port"], + print=(lambda text: [get_logger().info(line.strip()) for line in text.strip().splitlines()]), + ) + + async def __root_handler(self, _: aiohttp.web.Request) -> aiohttp.web.Response: + return aiohttp.web.Response(text="OK") + + async def __ws_handler(self, request: aiohttp.web.Request) -> aiohttp.web.WebSocketResponse: + ws = aiohttp.web.WebSocketResponse(**self.__config["ws"]) + await ws.prepare(request) + await self.__register_socket(ws) + async for msg in ws: + if msg.type == aiohttp.web.WSMsgType.TEXT: + retval = await self.__execute_command(msg.data) + if retval: + await ws.send_str(retval) + else: + break + return ws + + async def __on_shutdown(self, _: aiohttp.web.Application) -> None: + get_logger().info("Shutting down ...") + for ws in list(self.__sockets): + await self.__remove_socket(ws) + + async def __on_cleanup(self, _: aiohttp.web.Application) -> None: + logger = get_logger() + + logger.info("Cancelling tasks ...") + for future in self.__system_futures: + future.cancel() + await asyncio.gather(*self.__system_futures) + + logger.info("Cleaning up GPIO ...") + GPIO.cleanup() + + logger.info("Bye-bye") + + @_system_task + async def __poll_dead_sockets(self) -> None: + while True: + for ws in list(self.__sockets): + if ws.closed or not ws._req.transport: # pylint: disable=protected-access + await self.__remove_socket(ws) + await asyncio.sleep(0.1) + + @_system_task + async def __poll_atx_leds(self) -> None: + while True: + if self.__sockets: + await self.__broadcast("EVENT atx_leds %d %d" % (self.__atx.get_leds())) + await asyncio.sleep(self.__config["atx"]["leds"]["poll"]) + + @_system_task + async def __poll_streamer_events(self) -> None: + async for event in self.__streamer.events(): + await self.__broadcast("EVENT %s" % (event)) + + async def __broadcast(self, msg: str) -> None: + await asyncio.gather(*[ + ws.send_str(msg) + for ws in list(self.__sockets) + if not ws.closed and ws._req.transport # pylint: disable=protected-access + ], return_exceptions=True) + + async def __execute_command(self, command: str) -> Optional[str]: + (command, args) = (command.strip().split(" ", maxsplit=1) + [""])[:2] + if command == "CLICK": + method = { + "power": self.__atx.click_power, + "power_long": self.__atx.click_power_long, + "reset": self.__atx.click_reset, + }.get(args) + if method: + await method() + return None + get_logger().warning("Received incorrect command: %r", command) + return "ERROR incorrect command" + + async def __register_socket(self, ws: aiohttp.web.WebSocketResponse) -> None: + async with self.__sockets_lock: + self.__sockets.add(ws) + get_logger().info("Registered new client socket: remote=%s; id=%d; active=%d", + ws._req.remote, id(ws), len(self.__sockets)) # pylint: disable=protected-access + if len(self.__sockets) == 1: + await self.__streamer.start() + + async def __remove_socket(self, ws: aiohttp.web.WebSocketResponse) -> None: + async with self.__sockets_lock: + try: + self.__sockets.remove(ws) + get_logger().info("Removed client socket: remote=%s; id=%d; active=%d", + ws._req.remote, id(ws), len(self.__sockets)) # pylint: disable=protected-access + await ws.close() + except Exception: + pass + if not self.__sockets: + await self.__streamer.stop() + + +def main() -> None: + patch_logging() + patch_threading() + get_logger(app="kvmd") + + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--config", default="kvmd.yaml", metavar="<path>") + options = parser.parse_args() + + with open(options.config) as config_file: + config = yaml.load(config_file) + logging.captureWarnings(True) + logging.config.dictConfig(config["logging"]) + + _Application(config["kvmd"]).run() diff --git a/kvmd/kvmd/__main__.py b/kvmd/kvmd/__main__.py new file mode 100644 index 00000000..031df43e --- /dev/null +++ b/kvmd/kvmd/__main__.py @@ -0,0 +1,2 @@ +from . import main +main() diff --git a/kvmd/kvmd/atx.py b/kvmd/kvmd/atx.py new file mode 100644 index 00000000..237eacec --- /dev/null +++ b/kvmd/kvmd/atx.py @@ -0,0 +1,62 @@ +import asyncio + +from typing import Tuple + +from contextlog import get_logger + +from RPi import GPIO + + +# ===== +class Atx: + def __init__( + self, + power_led: int, + hdd_led: int, + power_switch: int, + reset_switch: int, + click_delay: float, + long_click_delay: float, + ) -> None: + + self.__power_led = self.__set_output_pin(power_led) + self.__hdd_led = self.__set_output_pin(hdd_led) + + self.__power_switch = self.__set_output_pin(power_switch) + self.__reset_switch = self.__set_output_pin(reset_switch) + self.__click_delay = click_delay + self.__long_click_delay = long_click_delay + + self.__lock = asyncio.Lock() + + def __set_output_pin(self, pin: int) -> int: + GPIO.setup(pin, GPIO.OUT) + GPIO.output(pin, False) + return pin + + def get_leds(self) -> Tuple[bool, bool]: + return ( + not GPIO.input(self.__power_led), + not GPIO.input(self.__hdd_led), + ) + + async def click_power(self) -> None: + if (await self.__click(self.__power_switch, self.__click_delay)): + get_logger().info("Clicked power") + + async def click_power_long(self) -> None: + if (await self.__click(self.__power_switch, self.__long_click_delay)): + get_logger().info("Clicked power (long press)") + + async def click_reset(self) -> None: + if (await self.__click(self.__reset_switch, self.__click_delay)): + get_logger().info("Clicked reset") + + async def __click(self, pin: int, delay: float) -> bool: + if not self.__lock.locked(): + async with self.__lock: + for flag in (True, False): + GPIO.output(pin, flag) + await asyncio.sleep(delay) + return True + return False diff --git a/kvmd/kvmd/streamer.py b/kvmd/kvmd/streamer.py new file mode 100644 index 00000000..af2900fb --- /dev/null +++ b/kvmd/kvmd/streamer.py @@ -0,0 +1,120 @@ +import asyncio +import asyncio.subprocess + +from typing import Dict +from typing import AsyncIterator +from typing import Optional + +from contextlog import get_logger + +from RPi import GPIO + + +# ===== +class Streamer: + def __init__( + self, + cap_power: int, + vga_power: int, + sync_delay: float, + mjpg_streamer: Dict, + ) -> None: + + self.__cap_power = self.__set_output_pin(cap_power) + self.__vga_power = self.__set_output_pin(vga_power) + self.__sync_delay = sync_delay + + self.__cmd = ( + "%(prog)s" + " -i 'input_uvc.so -d %(device)s -e %(every)s -y -n -r %(width)sx%(height)s'" + " -o 'output_http.so -p -l %(host)s %(port)s'" + ) % (mjpg_streamer) + + self.__lock = asyncio.Lock() + self.__events_queue: asyncio.Queue = asyncio.Queue() + self.__proc_future: Optional[asyncio.Future] = None + + def __set_output_pin(self, pin: int) -> int: + GPIO.setup(pin, GPIO.OUT) + GPIO.output(pin, False) + return pin + + async def events(self) -> AsyncIterator[str]: + while True: + yield (await self.__events_queue.get()) + + async def start(self) -> None: + async with self.__lock: + get_logger().info("Starting mjpg_streamer ...") + assert not self.__proc_future + await self.__set_hw_enabled(True) + self.__proc_future = asyncio.ensure_future(self.__process(), loop=asyncio.get_event_loop()) + + async def stop(self) -> None: + async with self.__lock: + get_logger().info("Stopping mjpg_streamer ...") + if self.__proc_future: + self.__proc_future.cancel() + await asyncio.gather(self.__proc_future, return_exceptions=True) + await self.__set_hw_enabled(False) + self.__proc_future = None + await self.__events_queue.put("mjpg_streamer stopped") + + async def __set_hw_enabled(self, enabled: bool) -> None: + # This sequence is important for enable + GPIO.output(self.__cap_power, enabled) + if enabled: + await asyncio.sleep(self.__sync_delay) + GPIO.output(self.__vga_power, enabled) + await asyncio.sleep(self.__sync_delay) + + async def __process(self) -> None: + logger = get_logger() + + proc: Optional[asyncio.subprocess.Process] = None # pylint: disable=no-member + while True: + try: + proc = await asyncio.create_subprocess_shell( + self.__cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + + logger.info("Started mjpg_streamer pid=%d: %s", proc.pid, self.__cmd) + await self.__events_queue.put("mjpg_streamer started") + + empty = 0 + while proc.returncode is None: + line = (await proc.stdout.readline()).decode(errors="ignore").strip() + if line: + logger.info("mjpg_streamer: %s", line) + empty = 0 + else: + empty += 1 + if empty == 100: # asyncio bug + break + + await self.__kill(proc) + raise RuntimeError("WTF") + + except asyncio.CancelledError: + break + except Exception as err: + if proc: + logger.error("Unexpected finished mjpg_streamer pid=%d with retcode=%d", proc.pid, proc.returncode) + else: + logger.error("Can't start mjpg_streamer: %s", err) + await asyncio.sleep(1) + + if proc: + await self.__kill(proc) + + async def __kill(self, proc: asyncio.subprocess.Process) -> None: # pylint: disable=no-member + try: + proc.terminate() + await asyncio.sleep(1) + if proc.returncode is None: + proc.kill() + await proc.wait() + except Exception: + pass diff --git a/kvmd/mypy.ini b/kvmd/mypy.ini new file mode 100644 index 00000000..df74d023 --- /dev/null +++ b/kvmd/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +python_version = 3.6 +ignore_missing_imports = True +disallow_untyped_defs = True +strict_optional = True diff --git a/kvmd/pylintrc b/kvmd/pylintrc new file mode 100644 index 00000000..3adf052d --- /dev/null +++ b/kvmd/pylintrc @@ -0,0 +1,62 @@ +[MASTER] +ignore=.git +extension-pkg-whitelist= + setproctitle, + +[DESIGN] +min-public-methods=0 +max-args=10 + +[TYPECHECK] +ignored-classes= + AioQueue, + +[MESSAGES CONTROL] +disable = + file-ignored, + locally-disabled, + fixme, + missing-docstring, + no-init, + no-self-use, + superfluous-parens, + abstract-class-not-used, + abstract-class-little-used, + duplicate-code, + bad-continuation, + bad-whitespace, + star-args, + broad-except, + redundant-keyword-arg, + wrong-import-order, + too-many-ancestors, + no-else-return, + len-as-condition, + +[REPORTS] +msg-template={symbol} -- {path}:{line}({obj}): {msg} + +[FORMAT] +max-line-length=160 + +[BASIC] +# List of builtins function names that should not be used, separated by a comma +bad-functions= + +# Regular expression matching correct method names +method-rgx=[a-z_][a-z0-9_]{2,50}$ + +# Regular expression matching correct function names +function-rgx=[a-z_][a-z0-9_]{2,50}$ + +# Regular expression which should only match correct module level names +const-rgx=([a-zA-Z_][a-zA-Z0-9_]*)$ + +# Regular expression which should only match correct argument names +argument-rgx=[a-z_][a-z0-9_]{1,30}$ + +# Regular expression which should only match correct variable names +variable-rgx=[a-z_][a-z0-9_]{1,30}$ + +# Regular expression which should only match correct instance attribute names +attr-rgx=[a-z_][a-z0-9_]{1,30}$ diff --git a/kvmd/requirements.txt b/kvmd/requirements.txt new file mode 100644 index 00000000..95592a40 --- /dev/null +++ b/kvmd/requirements.txt @@ -0,0 +1,6 @@ +RPi.GPIO +aiohttp +contextlog +pyyaml +bumpversion +tox diff --git a/kvmd/tox.ini b/kvmd/tox.ini new file mode 100644 index 00000000..d627345f --- /dev/null +++ b/kvmd/tox.ini @@ -0,0 +1,37 @@ +[tox] +envlist = flake8, pylint, mypy, vulture +skipsdist = True + +[testenv] +basepython = python3.6 + +[testenv:flake8] +commands = flake8 kvmd +deps = + flake8 + flake8-double-quotes + -rdev_requirements.txt + +[testenv:pylint] +commands = pylint --output-format=colorized --reports=no kvmd +deps = + pylint + -rdev_requirements.txt + +[testenv:mypy] +commands = mypy kvmd +deps = + mypy + -rdev_requirements.txt + +[testenv:vulture] +commands = vulture kvmd +deps = + vulture + -rdev_requirements.txt + +[flake8] +max-line-length = 160 +# W503 line break before binary operator +# E227 missing whitespace around bitwise or shift operator +ignore=W503,E227 |