From a48fd74a153d9926821b8406e6d19131f03b1dd8 Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Thu, 29 Apr 2021 22:19:47 -0500 Subject: [PATCH] Add backlight daemon The backlight on the CM3-PANEL cannot be controled with normal DPMS. Instead, the brightness is set using PWM on GPIO pin 22. The `thermostat.backlight` program runs as a background process to manage the PWM duty cycle. It exposes a D-Bus service to allow other programs to control the backlight. It also watches for *xscreensaver* events to detect when the screen is blanked and disable the backlight. --- .vscode/settings.json | 3 +- backlight.service | 8 ++ poetry.lock | 13 ++- pyproject.toml | 3 +- src/thermostat/backlight.py | 169 ++++++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 backlight.service create mode 100644 src/thermostat/backlight.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 2d5db40..c167a13 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "python.pythonPath": ".venv/bin/python3.9" + "python.pythonPath": ".venv/bin/python", + "python.formatting.provider": "black" } \ No newline at end of file diff --git a/backlight.service b/backlight.service new file mode 100644 index 0000000..b5fc92b --- /dev/null +++ b/backlight.service @@ -0,0 +1,8 @@ +[Unit] +Description=CM3-Panel backlight brightness control service + +[Service] +ExecStart=/usr/bin/python3 -m thermostat.backlightd + +[Install] +WantedBy=default.target \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index b7315a3..3a29644 100644 --- a/poetry.lock +++ b/poetry.lock @@ -58,6 +58,14 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "dbussy" +version = "1.3" +description = "language bindings for libdbus, for Python 3.5 or later" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "flake8" version = "3.9.1" @@ -274,7 +282,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "b45f2d08de21121b2d91ec83f040e748438c74c4d10a1480d56504fc242568da" +content-hash = "8ec699d6ce96b5082d8ff5f190cca908fe5043c6b74fdfbfd825a281ac230264" [metadata.files] appdirs = [ @@ -297,6 +305,9 @@ colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] +dbussy = [ + {file = "DBussy-1.3-py35-none-any.whl", hash = "sha256:511cf4c76b9c82fa08075ebee01eb9331fe1277404fab52924a083d9ae3f62b3"}, +] flake8 = [ {file = "flake8-3.9.1-py2.py3-none-any.whl", hash = "sha256:3b9f848952dddccf635be78098ca75010f073bfe14d2c6bda867154bea728d2a"}, {file = "flake8-3.9.1.tar.gz", hash = "sha256:1aa8990be1e689d96c745c5682b687ea49f2e05a443aff1f8251092b0014e378"}, diff --git a/pyproject.toml b/pyproject.toml index 698321d..3e0f275 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "thermostat" -version = "0.1.0" +version = "0.2.0" description = "" authors = ["Dustin C. Hatch "] @@ -9,6 +9,7 @@ python = "^3.7" smbus2 = "^0.4.1" "RPi.bme280" = "^0.2.3" paho-mqtt = "^1.5.1" +DBussy = "^1.3" [tool.poetry.dev-dependencies] black = "^21.4b2" diff --git a/src/thermostat/backlight.py b/src/thermostat/backlight.py new file mode 100644 index 0000000..bb489c6 --- /dev/null +++ b/src/thermostat/backlight.py @@ -0,0 +1,169 @@ +import asyncio +import logging +import os +import signal +import threading +from types import TracebackType +from typing import Optional, Type + +import ravel +from RPi import GPIO + + +log = logging.getLogger("backlightd") + + +@ravel.interface( + ravel.INTERFACE.SERVER, name="me.dustinhatch.home.thermostat.Backlight" +) +class Backlight: + def __init__(self) -> None: + self.brightness = 100 + self.pwm: Optional[GPIO.PWM] = None + + def __enter__(self) -> None: + GPIO.setmode(GPIO.BCM) + GPIO.setup(22, GPIO.OUT) + self.pwm = GPIO.PWM(22, 100) + self.pwm.start(0) + + def __exit__( + self, + exc_type: Optional[Type[Exception]], + exc_value: Optional[Exception], + tb: Optional[TracebackType], + ) -> None: + assert self.pwm + self.pwm.stop() + GPIO.cleanup() + + @ravel.method(in_signature="", out_signature="i") + def CurrentBrightness(self) -> int: + return self.brightness + + @ravel.method(in_signature="i", out_signature="") + def DecreaseBrightness(self, percent: int = 10) -> None: + if percent < 0 or percent > 100: + raise ValueError(f"Invalid percentage: {percent}") + log.info("Decreasing brightness by %d%%", percent) + self.brightness = max(self.brightness - percent, 0) + + @ravel.method(in_signature="i", out_signature="") + def IncreaseBrightness(self, percent: int = 10) -> None: + if percent < 0 or percent > 100: + raise ValueError(f"Invalid percentage: {percent}") + log.info("Increasing brightness by %d%%", percent) + self.brightness = min(self.brightness + percent, 100) + self._set_brightness() + + @ravel.method(in_signature="i", out_signature="") + def SetBrightness(self, percent: int) -> None: + if percent < 0 or percent > 100: + raise ValueError(f"Invalid percentage: {percent}") + log.info("Setting brigness to %d%%", percent) + self.brightness = percent + self._set_brightness() + + def _set_brightness(self) -> None: + assert self.pwm + log.debug("Setting PWM duty cycle to %d", 100 - self.brightness) + self.pwm.ChangeDutyCycle(100 - self.brightness) + + +class ScreensaverWatcher: + def __init__( + self, service: Backlight, loop: asyncio.AbstractEventLoop + ) -> None: + self.service = service + self.loop = loop + + self.done: asyncio.Future + + async def run(self) -> None: + assert self.loop + self.done = self.loop.create_future() + cmd = [ + "xscreensaver-command", + "-watch", + ] + p = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=None, + loop=self.loop, + ) + assert p.stdout + + async def process_stdout(): + brightness = 100 + while 1: + line = await p.stdout.readline() + if not line: + break + event = line.split(None, 1)[0].decode() + if event == "BLANK": + log.debug("Screensaver activated, disabling backlight") + brightness = self.service.CurrentBrightness() + self.service.SetBrightness(0) + elif event == "UNBLANK": + log.debug("Screensaver deactivated, restoring backlight") + self.service.SetBrightness(brightness) + else: + log.warning("Unknown event: %s", line) + + task = self.loop.create_task(process_stdout()) + await self.done + p.terminate() + await task + + def stop(self) -> None: + log.debug("Stopping screensaver watcher") + self.loop.call_soon(self.done.set_result, True) + + +class Daemon: + def __init__(self, loop: asyncio.AbstractEventLoop) -> None: + self.loop = loop + self.bus: ravel.Connection + self.watcher: ScreensaverWatcher + + async def run(self) -> None: + service = Backlight() + self.loop.add_signal_handler(signal.SIGINT, self.stop, signal.SIGINT) + self.loop.add_signal_handler(signal.SIGTERM, self.stop, signal.SIGTERM) + self.bus = await ravel.session_bus_async(self.loop) + self.bus.register( + "/me/dustinhatch/home/thermostat/Backlight", + False, + service, + ) + await self.bus.request_name_async( + "me.dustinhatch.home.thermostat.Backlight", + ravel.DBUS.NAME_FLAG_DO_NOT_QUEUE, + ) + + self.watcher = ScreensaverWatcher(service, self.loop) + with service: + await self.watcher.run() + + def stop(self, signum: int) -> None: + log.debug("Got signal %d", signum) + log.info("Shutting down backlight daemon") + self.watcher.stop() + + +def main() -> None: + os.environ.setdefault("DISPLAY", ":0.0") + logging.basicConfig(level=logging.DEBUG) + + loop = asyncio.get_event_loop() + daemon = Daemon(loop) + try: + loop.run_until_complete(daemon.run()) + finally: + loop.close() + + +if __name__ == "__main__": + main()