1
0
Fork 0

Compare commits

..

7 Commits
0.1 ... master

Author SHA1 Message Date
Dustin 990de7fbb9 sensor: Improve reported value precision
Casting the measured values to `int` loses a lot of precision.  Sending
the raw values, however, results in uselessly over-precise values.
Thus, we need to compromise by truncating the values at the tenths
place.
2021-06-18 18:31:34 -05:00
Dustin 2afae103d9 sensor: Set retain on config/availability messages
When Home Assistant restarts, it puts the sensor in "Unavailable" state
until it receives an "online" message.  Since the sensor has no idea
Home Assistant is waiting for such a message, it will never send one.
By setting the "retain" flag on availability and configuration messages,
the broker will automatically resend them to Home Assistant when it
subscribes to the topic again, which resolves this issue.
2021-06-10 08:51:18 -05:00
Dustin 257bac9d86 sensor: Add device metadata
Associating sensor entities with a device improves organization within
the Home Assistant UI.
2021-06-09 09:04:43 -05:00
Dustin e26363a4c8 sensor: Add unique IDs to sensors
Home Assistant seems to lose data from MQTT sensors that do not have a
unique ID when it restarts.
2021-06-09 09:04:43 -05:00
Dustin e30e8aaedc sensor: Add availability messages
Home Assistant can mark MQTT-based sensors as "unavailable" when it
receives a special message (`offline`) on a designated topic.  We send
an `online` message when the sensor process starts, and `offline` when
the process shuts down.  Additionally, we set the last will and
testament message to `offline` as well, so that Home Assistant will
still get the notification, even if the sensor does not shut down
cleanly.
2021-05-01 15:57:03 -05:00
Dustin 205989aefd sensor: Use pipe to interrupt loop
The `time.sleep` function cannot be stopped prematurely.  This means
that when the program receives a signal indicating it should stop, it
will not be able to do so until the running `sleep` completes.  This
means it could take up to 10 seconds for the process to stop.

A better way to handle the shutdown signal is to use a pipe.  When the
signal is received, the write end of the pipe is closed.  The event loop
will detect this immediately, as `select.select` returns as soon as any
event occurs on any of the polled file descriptors, or the timeout
expires.
2021-05-01 15:27:33 -05:00
Dustin 9503e87b4b 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.

https://www.acmesystems.it/CM3-PANEL-7-BASIC_backlight
2021-04-30 08:24:14 -05:00
6 changed files with 234 additions and 11 deletions

View File

@ -1,3 +1,4 @@
{
"python.pythonPath": ".venv/bin/python3.9"
"python.pythonPath": ".venv/bin/python",
"python.formatting.provider": "black"
}

8
backlight.service Normal file
View File

@ -0,0 +1,8 @@
[Unit]
Description=CM3-Panel backlight brightness control service
[Service]
ExecStart=/usr/bin/python3 -m thermostat.backlightd
[Install]
WantedBy=default.target

13
poetry.lock generated
View File

@ -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"},

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "thermostat"
version = "0.1.0"
version = "0.3.0dev2"
description = ""
authors = ["Dustin C. Hatch <dustin@hatch.name>"]
@ -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"

169
src/thermostat/backlight.py Normal file
View File

@ -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()

View File

@ -1,7 +1,9 @@
import json
import logging
import os
import select
import signal
import threading
import time
from contextlib import closing
from types import FrameType
@ -20,30 +22,45 @@ PORT = 8883
USERNAME = os.environ.get("MQTT_USERNAME", "")
PASSWORD = os.environ.get("MQTT_PASSWORD", "")
TOPIC = "homeassistant/sensor/thermostat"
AVAILABILITY_TOPIC = f"{TOPIC}/availability"
I2CPORT = 1
SENSOR_ADDR = 0x77
DEVICE = {
"manufacturer": "Dustin C. Hatch",
"name": "RPi Thermostat Display",
"model": "RPi Thermostat Display",
"identifiers": [
os.uname().nodename,
],
}
SENSOR_CONFIG = {
"thermostat_temperature": {
"device_class": "temperature",
"name": "Thermostat Temperature",
"device": DEVICE,
"state_topic": TOPIC,
"availability_topic": AVAILABILITY_TOPIC,
"unit_of_measurement": "°C",
"value_template": r"{{ value_json.temperature }}",
},
"thermostat_pressure": {
"device_class": "pressure",
"name": "Thermostat Pressure",
"device": DEVICE,
"state_topic": TOPIC,
"availability_topic": AVAILABILITY_TOPIC,
"unit_of_measurement": "hPa",
"value_template": r"{{ value_json.pressure }}",
},
"thermostat_humidity": {
"device_class": "humidity",
"name": "Thermostat Humidity",
"device": DEVICE,
"state_topic": TOPIC,
"availability_topic": AVAILABILITY_TOPIC,
"unit_of_measurement": "%",
"value_template": r"{{ value_json.humidity }}",
},
@ -52,18 +69,20 @@ SENSOR_CONFIG = {
class Daemon:
def __init__(self) -> None:
self.running = True
self.quitpipe = os.pipe()
self._ready = threading.Event()
def on_signal(self, signum: int, frame: FrameType) -> None:
log.debug("Got signal %d at %s", signum, frame)
log.info("Stopping")
self.running = False
os.close(self.quitpipe[1])
def run(self):
signal.signal(signal.SIGINT, self.on_signal)
signal.signal(signal.SIGTERM, self.on_signal)
client = mqtt.Client()
client.will_set(AVAILABILITY_TOPIC, "offline", retain=True)
client.on_connect = self.on_connect
client.on_message = self.on_message
client.on_disconnect = self.on_disconnect
@ -73,15 +92,20 @@ class Daemon:
client.loop_start()
with closing(smbus2.SMBus(I2CPORT)) as bus:
params = bme280.load_calibration_params(bus, SENSOR_ADDR)
while self.running:
self._ready.wait()
while 1:
data = bme280.sample(bus, SENSOR_ADDR, params)
values = {
"temperature": int(data.temperature),
"pressure": int(data.pressure),
"humidity": int(data.humidity),
"temperature": adj(data.temperature),
"pressure": adj(data.pressure),
"humidity": adj(data.humidity),
}
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.loop_stop()
@ -94,9 +118,14 @@ class Daemon:
):
log.info("Successfully connected to MQTT broker")
for key, value in SENSOR_CONFIG.items():
value["unique_id"] = f"sensor.{key}"
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):
log.error("Lost connection to MQTT broker")
@ -113,6 +142,10 @@ class Daemon:
print("Message", client, userdata, msg)
def adj(value, p=10):
return int(value * p) / p
def main():
logging.basicConfig(level=logging.DEBUG)
daemon = Daemon()