Compare commits
7 Commits
Author | SHA1 | Date |
---|---|---|
|
990de7fbb9 | |
|
2afae103d9 | |
|
257bac9d86 | |
|
e26363a4c8 | |
|
e30e8aaedc | |
|
205989aefd | |
|
9503e87b4b |
|
@ -1,3 +1,4 @@
|
||||||
{
|
{
|
||||||
"python.pythonPath": ".venv/bin/python3.9"
|
"python.pythonPath": ".venv/bin/python",
|
||||||
|
"python.formatting.provider": "black"
|
||||||
}
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
[Unit]
|
||||||
|
Description=CM3-Panel backlight brightness control service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/python3 -m thermostat.backlightd
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
|
@ -58,6 +58,14 @@ category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
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]]
|
[[package]]
|
||||||
name = "flake8"
|
name = "flake8"
|
||||||
version = "3.9.1"
|
version = "3.9.1"
|
||||||
|
@ -274,7 +282,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.7"
|
python-versions = "^3.7"
|
||||||
content-hash = "b45f2d08de21121b2d91ec83f040e748438c74c4d10a1480d56504fc242568da"
|
content-hash = "8ec699d6ce96b5082d8ff5f190cca908fe5043c6b74fdfbfd825a281ac230264"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
appdirs = [
|
appdirs = [
|
||||||
|
@ -297,6 +305,9 @@ colorama = [
|
||||||
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
|
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
|
||||||
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
|
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
|
||||||
]
|
]
|
||||||
|
dbussy = [
|
||||||
|
{file = "DBussy-1.3-py35-none-any.whl", hash = "sha256:511cf4c76b9c82fa08075ebee01eb9331fe1277404fab52924a083d9ae3f62b3"},
|
||||||
|
]
|
||||||
flake8 = [
|
flake8 = [
|
||||||
{file = "flake8-3.9.1-py2.py3-none-any.whl", hash = "sha256:3b9f848952dddccf635be78098ca75010f073bfe14d2c6bda867154bea728d2a"},
|
{file = "flake8-3.9.1-py2.py3-none-any.whl", hash = "sha256:3b9f848952dddccf635be78098ca75010f073bfe14d2c6bda867154bea728d2a"},
|
||||||
{file = "flake8-3.9.1.tar.gz", hash = "sha256:1aa8990be1e689d96c745c5682b687ea49f2e05a443aff1f8251092b0014e378"},
|
{file = "flake8-3.9.1.tar.gz", hash = "sha256:1aa8990be1e689d96c745c5682b687ea49f2e05a443aff1f8251092b0014e378"},
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "thermostat"
|
name = "thermostat"
|
||||||
version = "0.1.0"
|
version = "0.3.0dev2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Dustin C. Hatch <dustin@hatch.name>"]
|
authors = ["Dustin C. Hatch <dustin@hatch.name>"]
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ python = "^3.7"
|
||||||
smbus2 = "^0.4.1"
|
smbus2 = "^0.4.1"
|
||||||
"RPi.bme280" = "^0.2.3"
|
"RPi.bme280" = "^0.2.3"
|
||||||
paho-mqtt = "^1.5.1"
|
paho-mqtt = "^1.5.1"
|
||||||
|
DBussy = "^1.3"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
black = "^21.4b2"
|
black = "^21.4b2"
|
||||||
|
|
|
@ -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()
|
|
@ -1,7 +1,9 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import select
|
||||||
import signal
|
import signal
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from types import FrameType
|
from types import FrameType
|
||||||
|
@ -20,30 +22,45 @@ PORT = 8883
|
||||||
USERNAME = os.environ.get("MQTT_USERNAME", "")
|
USERNAME = os.environ.get("MQTT_USERNAME", "")
|
||||||
PASSWORD = os.environ.get("MQTT_PASSWORD", "")
|
PASSWORD = os.environ.get("MQTT_PASSWORD", "")
|
||||||
TOPIC = "homeassistant/sensor/thermostat"
|
TOPIC = "homeassistant/sensor/thermostat"
|
||||||
|
AVAILABILITY_TOPIC = f"{TOPIC}/availability"
|
||||||
|
|
||||||
I2CPORT = 1
|
I2CPORT = 1
|
||||||
SENSOR_ADDR = 0x77
|
SENSOR_ADDR = 0x77
|
||||||
|
|
||||||
|
DEVICE = {
|
||||||
|
"manufacturer": "Dustin C. Hatch",
|
||||||
|
"name": "RPi Thermostat Display",
|
||||||
|
"model": "RPi Thermostat Display",
|
||||||
|
"identifiers": [
|
||||||
|
os.uname().nodename,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
SENSOR_CONFIG = {
|
SENSOR_CONFIG = {
|
||||||
"thermostat_temperature": {
|
"thermostat_temperature": {
|
||||||
"device_class": "temperature",
|
"device_class": "temperature",
|
||||||
"name": "Thermostat Temperature",
|
"name": "Thermostat Temperature",
|
||||||
|
"device": DEVICE,
|
||||||
"state_topic": TOPIC,
|
"state_topic": TOPIC,
|
||||||
|
"availability_topic": AVAILABILITY_TOPIC,
|
||||||
"unit_of_measurement": "°C",
|
"unit_of_measurement": "°C",
|
||||||
"value_template": r"{{ value_json.temperature }}",
|
"value_template": r"{{ value_json.temperature }}",
|
||||||
},
|
},
|
||||||
"thermostat_pressure": {
|
"thermostat_pressure": {
|
||||||
"device_class": "pressure",
|
"device_class": "pressure",
|
||||||
"name": "Thermostat Pressure",
|
"name": "Thermostat Pressure",
|
||||||
|
"device": DEVICE,
|
||||||
"state_topic": TOPIC,
|
"state_topic": TOPIC,
|
||||||
|
"availability_topic": AVAILABILITY_TOPIC,
|
||||||
"unit_of_measurement": "hPa",
|
"unit_of_measurement": "hPa",
|
||||||
"value_template": r"{{ value_json.pressure }}",
|
"value_template": r"{{ value_json.pressure }}",
|
||||||
},
|
},
|
||||||
"thermostat_humidity": {
|
"thermostat_humidity": {
|
||||||
"device_class": "humidity",
|
"device_class": "humidity",
|
||||||
"name": "Thermostat Humidity",
|
"name": "Thermostat Humidity",
|
||||||
|
"device": DEVICE,
|
||||||
"state_topic": TOPIC,
|
"state_topic": TOPIC,
|
||||||
|
"availability_topic": AVAILABILITY_TOPIC,
|
||||||
"unit_of_measurement": "%",
|
"unit_of_measurement": "%",
|
||||||
"value_template": r"{{ value_json.humidity }}",
|
"value_template": r"{{ value_json.humidity }}",
|
||||||
},
|
},
|
||||||
|
@ -52,18 +69,20 @@ SENSOR_CONFIG = {
|
||||||
|
|
||||||
class Daemon:
|
class Daemon:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.running = True
|
self.quitpipe = os.pipe()
|
||||||
|
self._ready = threading.Event()
|
||||||
|
|
||||||
def on_signal(self, signum: int, frame: FrameType) -> None:
|
def on_signal(self, signum: int, frame: FrameType) -> None:
|
||||||
log.debug("Got signal %d at %s", signum, frame)
|
log.debug("Got signal %d at %s", signum, frame)
|
||||||
log.info("Stopping")
|
log.info("Stopping")
|
||||||
self.running = False
|
os.close(self.quitpipe[1])
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
signal.signal(signal.SIGINT, self.on_signal)
|
signal.signal(signal.SIGINT, self.on_signal)
|
||||||
signal.signal(signal.SIGTERM, self.on_signal)
|
signal.signal(signal.SIGTERM, self.on_signal)
|
||||||
|
|
||||||
client = mqtt.Client()
|
client = mqtt.Client()
|
||||||
|
client.will_set(AVAILABILITY_TOPIC, "offline", retain=True)
|
||||||
client.on_connect = self.on_connect
|
client.on_connect = self.on_connect
|
||||||
client.on_message = self.on_message
|
client.on_message = self.on_message
|
||||||
client.on_disconnect = self.on_disconnect
|
client.on_disconnect = self.on_disconnect
|
||||||
|
@ -73,15 +92,20 @@ class Daemon:
|
||||||
client.loop_start()
|
client.loop_start()
|
||||||
with closing(smbus2.SMBus(I2CPORT)) as bus:
|
with closing(smbus2.SMBus(I2CPORT)) as bus:
|
||||||
params = bme280.load_calibration_params(bus, SENSOR_ADDR)
|
params = bme280.load_calibration_params(bus, SENSOR_ADDR)
|
||||||
while self.running:
|
self._ready.wait()
|
||||||
|
while 1:
|
||||||
data = bme280.sample(bus, SENSOR_ADDR, params)
|
data = bme280.sample(bus, SENSOR_ADDR, params)
|
||||||
values = {
|
values = {
|
||||||
"temperature": int(data.temperature),
|
"temperature": adj(data.temperature),
|
||||||
"pressure": int(data.pressure),
|
"pressure": adj(data.pressure),
|
||||||
"humidity": int(data.humidity),
|
"humidity": adj(data.humidity),
|
||||||
}
|
}
|
||||||
client.publish(TOPIC, json.dumps(values))
|
client.publish(TOPIC, json.dumps(values))
|
||||||
time.sleep(10)
|
ready = select.select((self.quitpipe[0],), (), (), 10)[0]
|
||||||
|
if self.quitpipe[0] in ready:
|
||||||
|
os.close(self.quitpipe[0])
|
||||||
|
break
|
||||||
|
client.publish(AVAILABILITY_TOPIC, "offline", retain=True)
|
||||||
client.disconnect()
|
client.disconnect()
|
||||||
client.loop_stop()
|
client.loop_stop()
|
||||||
|
|
||||||
|
@ -94,9 +118,14 @@ class Daemon:
|
||||||
):
|
):
|
||||||
log.info("Successfully connected to MQTT broker")
|
log.info("Successfully connected to MQTT broker")
|
||||||
for key, value in SENSOR_CONFIG.items():
|
for key, value in SENSOR_CONFIG.items():
|
||||||
|
value["unique_id"] = f"sensor.{key}"
|
||||||
client.publish(
|
client.publish(
|
||||||
f"homeassistant/sensor/{key}/config", json.dumps(value)
|
f"homeassistant/sensor/{key}/config",
|
||||||
|
json.dumps(value),
|
||||||
|
retain=True,
|
||||||
)
|
)
|
||||||
|
client.publish(AVAILABILITY_TOPIC, "online", retain=True)
|
||||||
|
self._ready.set()
|
||||||
|
|
||||||
def on_disconnect(self, client, userdata, rc):
|
def on_disconnect(self, client, userdata, rc):
|
||||||
log.error("Lost connection to MQTT broker")
|
log.error("Lost connection to MQTT broker")
|
||||||
|
@ -113,6 +142,10 @@ class Daemon:
|
||||||
print("Message", client, userdata, msg)
|
print("Message", client, userdata, msg)
|
||||||
|
|
||||||
|
|
||||||
|
def adj(value, p=10):
|
||||||
|
return int(value * p) / p
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
daemon = Daemon()
|
daemon = Daemon()
|
||||||
|
|
Loading…
Reference in New Issue