summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDevaev Maxim <[email protected]>2018-06-27 18:25:59 +0300
committerDevaev Maxim <[email protected]>2018-06-27 18:25:59 +0300
commit4c145f363f33222b948df981d8c936844f2b17dc (patch)
tree7932ea4f2af537caed6b8cbc9750db05fe14e72b
initial commit
-rw-r--r--hw/pcb.lay6bin0 -> 185796 bytes
-rw-r--r--kvmd/.gitignore7
-rw-r--r--kvmd/Makefile36
-rw-r--r--kvmd/dev_requirements.txt6
-rw-r--r--kvmd/kvmd.yaml64
-rw-r--r--kvmd/kvmd/__init__.py197
-rw-r--r--kvmd/kvmd/__main__.py2
-rw-r--r--kvmd/kvmd/atx.py62
-rw-r--r--kvmd/kvmd/streamer.py120
-rw-r--r--kvmd/mypy.ini5
-rw-r--r--kvmd/pylintrc62
-rw-r--r--kvmd/requirements.txt6
-rw-r--r--kvmd/tox.ini37
13 files changed, 604 insertions, 0 deletions
diff --git a/hw/pcb.lay6 b/hw/pcb.lay6
new file mode 100644
index 00000000..39700ab5
--- /dev/null
+++ b/hw/pcb.lay6
Binary files differ
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