From 3ef379b0b7f04ae58dabccd47fef7692661d8381 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Sun, 9 Jun 2024 10:58:55 +0200 Subject: [PATCH 01/24] Initial commit --- .gitignore | 2 + oin/__main__.py | 86 +++++++++++++++++++++++++++++++++++++++++++ oin/tempo/__init__.py | 71 +++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 .gitignore create mode 100644 oin/__main__.py create mode 100644 oin/tempo/__init__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad6bfc1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +/config.toml diff --git a/oin/__main__.py b/oin/__main__.py new file mode 100644 index 0000000..ce90135 --- /dev/null +++ b/oin/__main__.py @@ -0,0 +1,86 @@ +import tomllib +from datetime import date, datetime, timedelta, timezone +import json +from threading import Timer +import psycopg2 +from sense_hat import SenseHat +from signal import pause +from urllib import request, parse + +from .tempo import TempoAPI, convert_color + +with open("config.toml", "rb") as config_file: + config = tomllib.load(config_file) + +sense = SenseHat() +tempo_api = TempoAPI(config.get("rte_api_key")) + + +def show_trash(): + if date.today().isocalendar().week % 2: + sense.show_message("DR", text_colour=[255, 255, 0]) + else: + sense.show_message("OM", text_colour=[0, 127, 0]) + + +def show_temp(): + sense.show_message(f"{sense.temperature:.1f}", text_colour=[255, 0, 255]) + + +def show_humidity(): + sense.show_message(f"{sense.humidity:.1f}", text_colour=[0, 0, 255]) + + +def show_tempo(): + tempo_colors = [convert_color(c) for c in tempo_api.tempo] + sense.set_pixels([tempo_colors[1]] * 48 + [tempo_colors[0]] * 16) + tc = Timer(5, sense.clear) + tc.start() + + +def stick_loop(): + event = sense.stick.wait_for_event(emptybuffer=True) + match event.direction: + case "up": + show_trash() + case "right": + show_temp() + case "left": + show_humidity() + case "down": + show_tempo() + case "middle": + sense.clear() + + ts = Timer(0, stick_loop) + ts.start() + + +def save_loop(conn): + tp = Timer(30, save_loop, (conn,)) + tp.start() + dt = datetime.now() + + temp = sense.temperature + pres = sense.pressure + humi = sense.humidity + + print(dt) + cursor = conn.cursor() + INS = "INSERT INTO sensor_data (time, sensor, value) VALUES (%s, %s, %s);" + try: + cursor.execute(INS, (dt, "temp", temp)) + cursor.execute(INS, (dt, "pres", pres)) + cursor.execute(INS, (dt, "humi", humi)) + except (Exception, psycopg2.Error) as error: + print(error.pgerror) + conn.commit() + + +with psycopg2.connect(database="tsdb", user="edpibu", host="/run/postgresql") as conn: + tp = Timer(0, save_loop, (conn,)) + tp.start() + +ts = Timer(0, stick_loop) +ts.start() +sense.show_message(">") diff --git a/oin/tempo/__init__.py b/oin/tempo/__init__.py new file mode 100644 index 0000000..b7660b7 --- /dev/null +++ b/oin/tempo/__init__.py @@ -0,0 +1,71 @@ +from datetime import date, datetime, timedelta, timezone +from threading import Timer +from urllib import request, parse +import json + + +class TempoAPI: + def __init__(self, api_key): + self.rte_api_key = api_key + self.update_rte_access_token() + + def update_rte_access_token(self): + with request.urlopen( + request.Request( + "https://digital.iservices.rte-france.com/token/oauth/", + headers={ + "Authorization": f"Basic {self.rte_api_key}", + "Content-Type": "application/x-www-form-urlencoded", + }, + method="POST", + ) + ) as res: + data = json.load(res) + + self.rte_access_token = data.get("access_token") + self.renewal_timer = Timer(data.get("expires_in"), self.update_rte_access_token) + self.renewal_timer.start() + + @property + def tempo(self): + today = datetime.combine(date.today(), datetime.min.time()).astimezone() + url = parse.urljoin( + "https://digital.iservices.rte-france.com/open_api/tempo_like_supply_contract/v1/tempo_like_calendars", + "?" + + parse.urlencode( + { + "start_date": today.isoformat(), + "end_date": (today + timedelta(days=2)).isoformat(), + }, + safe=":", + ), + ) + with request.urlopen( + request.Request( + url, + headers={ + "Authorization": f"Bearer {self.rte_access_token}", + "Accept": "application/json", + }, + ) + ) as res: + api_data = json.load(res) + + print(api_data.get("tempo_like_calendars").get("values")) + + return [ + val.get("value") + for val in api_data.get("tempo_like_calendars").get("values") + ] + + +def convert_color(color): + match color: + case "BLUE": + return [0, 0, 255] + case "WHITE": + return [255, 255, 255] + case "RED": + return [255, 0, 0] + case _: + return [255, 255, 0] From 2bb3159385f02aa2ba70c6b349fb1da7bcd39388 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Sun, 9 Jun 2024 13:11:34 +0200 Subject: [PATCH 02/24] Reorganize code --- oin/__main__.py | 73 ++-------------------- oin/info/__init__.py | 65 ++++++++++++++++++++ oin/info/utils.py | 133 +++++++++++++++++++++++++++++++++++++++++ oin/logger/__init__.py | 40 +++++++++++++ oin/tempo/__init__.py | 2 - 5 files changed, 243 insertions(+), 70 deletions(-) create mode 100644 oin/info/__init__.py create mode 100644 oin/info/utils.py create mode 100644 oin/logger/__init__.py diff --git a/oin/__main__.py b/oin/__main__.py index ce90135..6f7c73c 100644 --- a/oin/__main__.py +++ b/oin/__main__.py @@ -2,12 +2,13 @@ import tomllib from datetime import date, datetime, timedelta, timezone import json from threading import Timer -import psycopg2 from sense_hat import SenseHat from signal import pause from urllib import request, parse -from .tempo import TempoAPI, convert_color +from .tempo import TempoAPI +from .logger import SenseLogger +from .info import InfoPanel with open("config.toml", "rb") as config_file: config = tomllib.load(config_file) @@ -16,71 +17,7 @@ sense = SenseHat() tempo_api = TempoAPI(config.get("rte_api_key")) -def show_trash(): - if date.today().isocalendar().week % 2: - sense.show_message("DR", text_colour=[255, 255, 0]) - else: - sense.show_message("OM", text_colour=[0, 127, 0]) +sense_logger = SenseLogger(sense, "dbname=tsdb user=edpibu host=/run/postgresql") +info_panel = InfoPanel(sense, tempo_api) - -def show_temp(): - sense.show_message(f"{sense.temperature:.1f}", text_colour=[255, 0, 255]) - - -def show_humidity(): - sense.show_message(f"{sense.humidity:.1f}", text_colour=[0, 0, 255]) - - -def show_tempo(): - tempo_colors = [convert_color(c) for c in tempo_api.tempo] - sense.set_pixels([tempo_colors[1]] * 48 + [tempo_colors[0]] * 16) - tc = Timer(5, sense.clear) - tc.start() - - -def stick_loop(): - event = sense.stick.wait_for_event(emptybuffer=True) - match event.direction: - case "up": - show_trash() - case "right": - show_temp() - case "left": - show_humidity() - case "down": - show_tempo() - case "middle": - sense.clear() - - ts = Timer(0, stick_loop) - ts.start() - - -def save_loop(conn): - tp = Timer(30, save_loop, (conn,)) - tp.start() - dt = datetime.now() - - temp = sense.temperature - pres = sense.pressure - humi = sense.humidity - - print(dt) - cursor = conn.cursor() - INS = "INSERT INTO sensor_data (time, sensor, value) VALUES (%s, %s, %s);" - try: - cursor.execute(INS, (dt, "temp", temp)) - cursor.execute(INS, (dt, "pres", pres)) - cursor.execute(INS, (dt, "humi", humi)) - except (Exception, psycopg2.Error) as error: - print(error.pgerror) - conn.commit() - - -with psycopg2.connect(database="tsdb", user="edpibu", host="/run/postgresql") as conn: - tp = Timer(0, save_loop, (conn,)) - tp.start() - -ts = Timer(0, stick_loop) -ts.start() sense.show_message(">") diff --git a/oin/info/__init__.py b/oin/info/__init__.py new file mode 100644 index 0000000..0c6b8d4 --- /dev/null +++ b/oin/info/__init__.py @@ -0,0 +1,65 @@ +from threading import Thread, Timer +from datetime import date + +from .utils import num_to_pixels +from ..tempo import convert_color + + +class InfoPanel: + def __init__(self, sense, tempo_api, rotation=0): + self.sense = sense + self.stick = self.sense.stick + self.rotation = rotation + self.sense.set_rotation(self.rotation) + + self.tempo_api = tempo_api + + self.start_stick_info() + self.clear_timer = Timer(5, self.sense.clear) + + def start_stick_info(self): + self.thread = Thread(target=self.stick_info) + self.thread.start() + + def start_clear_timer(self): + self.clear_timer.cancel() + self.clear_timer = Timer(5, self.sense.clear) + self.clear_timer.start() + + def stick_info(self): + event = self.stick.wait_for_event(emptybuffer=True) + self.start_stick_info() + + match event.direction: + case "up": + self.show_trash() + case "right": + self.show_temp() + case "left": + self.show_humidity() + case "down": + self.show_tempo() + case "middle": + self.sense.clear() + + self.start_clear_timer() + + def show_trash(self): + today = date.today().isocalendar() + if (today.week % 2) ^ (today.weekday >= 4): + self.sense.set_pixels(64 * [[255, 255, 0]]) + else: + grid_line = 4 * [[0, 127, 0], [0, 15, 0]] + self.sense.set_pixels(4 * (grid_line + grid_line[::-1])) + + def show_temp(self): + self.sense.set_pixels( + num_to_pixels(self.sense.temperature, color=[255, 0, 255]) + ) + + def show_humidity(self): + self.sense.set_pixels(num_to_pixels(self.sense.humidity, color=[0, 255, 255])) + + def show_tempo(self): + tempo_colors = [convert_color(c) for c in self.tempo_api.tempo] + self.sense.set_pixels([tempo_colors[1]] * 48 + [tempo_colors[0]] * 16) diff --git a/oin/info/utils.py b/oin/info/utils.py new file mode 100644 index 0000000..ea6685c --- /dev/null +++ b/oin/info/utils.py @@ -0,0 +1,133 @@ +from itertools import chain + + +def digit_to_pixels(n, color=[255, 255, 255], bg_color=[0, 0, 0]): + X = color + O = bg_color + match n: + case "0": + return [ + [O, O, O, O], + [O, X, X, O], + [X, O, O, X], + [X, O, O, X], + [X, O, O, X], + [X, O, O, X], + [O, X, X, O], + [O, O, O, O], + ] + case "1": + return [ + [O, O, O, O], + [O, O, O, X], + [O, O, X, X], + [O, X, O, X], + [O, O, O, X], + [O, O, O, X], + [O, O, O, X], + [O, O, O, O], + ] + case "2": + return [ + [O, O, O, O], + [O, X, X, O], + [X, O, O, X], + [O, O, O, X], + [O, O, X, O], + [O, X, O, O], + [X, X, X, X], + [O, O, O, O], + ] + case "3": + return [ + [O, O, O, O], + [X, X, X, X], + [O, O, O, X], + [O, O, X, O], + [O, O, O, X], + [X, O, O, X], + [O, X, X, O], + [O, O, O, O], + ] + case "4": + return [ + [O, O, O, O], + [O, O, O, X], + [O, O, X, X], + [O, X, O, X], + [X, O, X, X], + [X, X, X, X], + [O, O, O, X], + [O, O, O, O], + ] + case "5": + return [ + [O, O, O, O], + [X, X, X, X], + [X, O, O, O], + [X, X, X, O], + [O, O, O, X], + [X, O, O, X], + [O, X, X, O], + [O, O, O, O], + ] + case "6": + return [ + [O, O, O, O], + [O, X, X, X], + [X, O, O, O], + [X, X, X, O], + [X, O, O, X], + [X, O, O, X], + [O, X, X, O], + [O, O, O, O], + ] + case "7": + return [ + [O, O, O, O], + [X, X, X, X], + [O, O, O, X], + [O, O, X, O], + [O, X, O, O], + [X, O, O, O], + [X, O, O, O], + [O, O, O, O], + ] + case "8": + return [ + [O, O, O, O], + [O, X, X, O], + [X, O, O, X], + [O, X, X, O], + [X, O, O, X], + [X, O, O, X], + [O, X, X, O], + [O, O, O, O], + ] + case "9": + return [ + [O, O, O, O], + [O, X, X, O], + [X, O, O, X], + [X, O, O, X], + [O, X, X, X], + [O, O, O, X], + [O, X, X, O], + [O, O, O, O], + ] + case _: + return 8 * [4 * [O]] + + +def num_to_pixels(n, color=[255, 255, 255], bg_color=[0, 0, 0]): + digits = f"{n:02.0f}" + if len(digits) > 2: + digits = "XX" + + return list( + chain.from_iterable( + chain.from_iterable( + zip(*[digit_to_pixels(i, color, bg_color) for i in digits]) + ) + ) + ) diff --git a/oin/logger/__init__.py b/oin/logger/__init__.py new file mode 100644 index 0000000..b45b6f5 --- /dev/null +++ b/oin/logger/__init__.py @@ -0,0 +1,40 @@ +from datetime import date, datetime, timedelta, timezone +from threading import Timer +import psycopg +from psycopg_pool import ConnectionPool + + +class SenseLogger: + def __init__(self, sense, conninfo): + self.sense = sense + self.database_pool = ConnectionPool(conninfo) + + self.timer = Timer(0, self.log) + self.timer.start() + + def log(self): + self.timer = Timer(30, self.log) + self.timer.start() + + dt = datetime.now() + + temp = self.sense.temperature + pres = self.sense.pressure + humi = self.sense.humidity + + print(dt) + INS = "INSERT INTO sensor_data (time, sensor, value) VALUES (%s, %s, %s);" + + conn = self.database_pool.getconn() + try: + with conn.cursor() as cursor: + cursor.execute(INS, (dt, "temp", temp)) + cursor.execute(INS, (dt, "pres", pres)) + cursor.execute(INS, (dt, "humi", humi)) + + conn.commit() + except: + conn.rollback() + raise + finally: + self.database_pool.putconn(conn) diff --git a/oin/tempo/__init__.py b/oin/tempo/__init__.py index b7660b7..9bc453a 100644 --- a/oin/tempo/__init__.py +++ b/oin/tempo/__init__.py @@ -51,8 +51,6 @@ class TempoAPI: ) as res: api_data = json.load(res) - print(api_data.get("tempo_like_calendars").get("values")) - return [ val.get("value") for val in api_data.get("tempo_like_calendars").get("values") From 8b4521a4475cec12824ce8308a69f54135091a39 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 11 Nov 2024 15:22:09 +0100 Subject: [PATCH 03/24] Initial commit --- .gitignore | 2 + .pre-commit-config.yaml | 17 + oin_ha/__main__.py | 44 + oin_ha/display/__init__.py | 226 ++++ oin_ha/sensors/__init__.py | 129 ++ src/tom-thumb.bdf | 2353 ++++++++++++++++++++++++++++++++++++ 6 files changed, 2771 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 oin_ha/__main__.py create mode 100644 oin_ha/display/__init__.py create mode 100644 oin_ha/sensors/__init__.py create mode 100644 src/tom-thumb.bdf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e73ce05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +/env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4d8e2f2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + args: ["--profile", "black"] + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + - repo: https://github.com/PyCQA/flake8 + rev: "6.0.0" + hooks: + - id: flake8 + args: ["--max-line-length=88", "--extend-ignore=E203"] + exclude: "lyceedupaysdesoule/settings/|migrations" + diff --git a/oin_ha/__main__.py b/oin_ha/__main__.py new file mode 100644 index 0000000..0dff414 --- /dev/null +++ b/oin_ha/__main__.py @@ -0,0 +1,44 @@ +from threading import Timer + +import paho.mqtt.client as mqtt + +from .display import Display +from .sensors import Sensors + +dt = 10 +mqttc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) +hat_sensors = Sensors(mqttc, "oin", "Oin") +hat_display = Display(mqttc, "oin", "Oin") + + +@mqttc.connect_callback() +def on_connect(client, userdata, flags, reason_code, properties): + print(f"Connected with result code {reason_code}") + + hat_sensors.publish_discovery() + hat_display.publish_discovery() + + hat_sensors.publish_online() + hat_display.publish_online() + + timer = Timer(0, send_data) + timer.start() + + +@mqttc.message_callback() +def on_message(*args, **kwargs): + hat_display.on_message(*args, **kwargs) + + +mqttc.username_pw_set(username="oin", password="n+Bi58l7LxbH5nEJ") +mqttc.connect("homeassistant.local", 1883, 60) + + +def send_data(): + timer = Timer(dt, send_data) + timer.start() + + hat_sensors.publish_state() + + +mqttc.loop_forever() diff --git a/oin_ha/display/__init__.py b/oin_ha/display/__init__.py new file mode 100644 index 0000000..829ed6a --- /dev/null +++ b/oin_ha/display/__init__.py @@ -0,0 +1,226 @@ +import json +from threading import Timer + +import bdfparser +from sense_hat import SenseHat + + +class Display: + def __init__(self, mqttc, uid, name): + self.mqttc = mqttc + self.sense = SenseHat() + self.uid = uid + self.name = name + + self.init_lights() + self.mqttc.will_set(self.availability_topic, "offline", retain=True) + + @property + def device(self): + return { + "identifiers": [self.uid], + "name": self.name, + } + + @property + def availability_topic(self): + return f"{self.uid}/display/availability" + + def init_lights(self): + options = { + "device": self.device, + "availability_topic": self.availability_topic, + } + + self.main_light = Light("LED", self.uid, "led", self.sense, **options) + + def publish_discovery(self): + self.main_light.publish_discovery(self.mqttc) + + def publish_online(self): + self.subscribe() + self.mqttc.publish(self.availability_topic, "online", retain=True) + + def subscribe(self): + self.main_light.subscribe(self.mqttc) + + def on_message(self, *args, **kwargs): + self.main_light.on_message(*args, **kwargs) + + +class Light: + def __init__(self, name, parent_uid, slug, sense, **kwargs): + self.name = name + self.parent_uid = parent_uid + self.slug = slug + self.sense = sense + self.options = kwargs + + self._switch = False + self._color = [255, 255, 255] + self._pre = "" + self._value = "" + + self.font = bdfparser.Font("src/tom-thumb.bdf") + self.timer = Timer(0, self.__init__) + + def publish_discovery(self, mqttc): + mqttc.publish( + self.discovery_topic, + json.dumps( + { + "command_topic": self.command_topic("command"), + "effect_command_topic": self.command_topic("effect"), + "effect_list": ["Low Light", "Normal"], + "effect_state_topie": self.state_topic, + "effect_value_template": "{{ value_json.effect }}", + "icon": "mdi:dots-grid", + "name": self.name, + "on_command_type": "brightness", + "rgb_command_topic": self.command_topic("rgb"), + "rgb_state_topic": self.state_topic, + "rgb_value_template": "{{ value_json.rgb }}", + "retain": True, + "unique_id": self.uid, + "state_topic": self.state_topic, + "state_value_template": "{{ value_json.state }}", + } + | self.options + ), + retain=True, + ) + self.publish_state(mqttc) + + def subscribe(self, mqttc): + mqttc.subscribe(self.command_topic("command")) + mqttc.subscribe(self.command_topic("effect")) + mqttc.subscribe(self.command_topic("rgb")) + + mqttc.subscribe(self.command_topic("value")) + + def on_message(self, client, userdata, message): + data = message.payload.decode() + print(data) + + match message.topic.rsplit("/", maxsplit=1): + case [self.base_topic, "command"]: + self.switch = data == "ON" + case [self.base_topic, "rgb"]: + self.color = list(map(int, data.split(","))) + case [self.base_topic, "effect"]: + self.sense.low_light = data == "Low Light" + case [self.base_topic, "value"]: + self.value = data + case _: + return + self.publish_state(client) + + def publish_state(self, mqttc): + mqttc.publish( + self.state_topic, + json.dumps( + { + "effect": "Low Light" if self.sense.low_light else "Normal", + "rgb": self.rgb, + "state": self.state, + } + ), + retain=True, + ) + + @property + def uid(self): + return f"{self.parent_uid}_{self.slug}" + + @property + def discovery_topic(self): + return f"homeassistant/light/{self.uid}/config" + + @property + def base_topic(self): + return f"{self.parent_uid}/display/{self.slug}" + + def command_topic(self, cmd): + return f"{self.base_topic}/{cmd}" + + @property + def state_topic(self): + return f"{self.parent_uid}/display/{self.slug}/state" + + @property + def switch(self): + return self._switch + + @switch.setter + def switch(self, value): + self._switch = value + if value: + self.update_value() + else: + self.sense.clear() + + @property + def state(self): + return "ON" if self.switch else "OFF" + + @property + def color(self): + return self._color + + @color.setter + def color(self, value): + self._color = value + if not self.switch: + self.switch = True + self.display_value() + + @property + def rgb(self): + return ",".join(map(str, self.color)) + + @property + def value(self): + return f"{self._pre} {self._value}" + + @value.setter + def value(self, value): + match value.split(): + case [val]: + self._pre = "" + self._value = val + case [pre, val, *_]: + self._pre = pre + self._value = val + self.display_value() + + def update_value(self): + if not self.switch: + return + + if not self._pre: + self.display_value() + return + + self.timer.cancel() + + pixels = self.to_pixels(self._pre) + self.sense.set_pixels(pixels) + + self.timer = Timer(1, self.display_value, kwargs=dict(timer=True)) + self.timer.start() + + def display_value(self, timer=False): + if (not timer and self.timer.is_alive()) or not self.switch: + return + + pixels = self.to_pixels(self._value) + self.sense.set_pixels(pixels) + + def to_pixels(self, text): + if text: + return [ + self.color if x else [0, 0, 0] + for x in self.font.draw(text).crop(8, 8, yoff=-2).todata(3) + ] + + return [self.color] * 64 diff --git a/oin_ha/sensors/__init__.py b/oin_ha/sensors/__init__.py new file mode 100644 index 0000000..1ca1595 --- /dev/null +++ b/oin_ha/sensors/__init__.py @@ -0,0 +1,129 @@ +import json + +from sense_hat import SenseHat + + +class Sensors: + def __init__(self, mqttc, uid, name): + self.mqttc = mqttc + self.sense = SenseHat() + self.uid = uid + self.name = name + + self.init_sensors() + self.mqttc.will_set(self.availability_topic, "offline", retain=True) + + @property + def device(self): + return { + "identifiers": [self.uid], + "name": self.name, + } + + @property + def availability_topic(self): + return f"{self.uid}/availability" + + @property + def state_topic(self): + return f"{self.uid}/state" + + def init_sensors(self): + options = { + "device": self.device, + "availability_topic": self.availability_topic, + "state_topic": self.state_topic, + "entity_category": "diagnostic", + } + + self.sensors = [ + TemperatureSensor( + "Température", + self.uid, + "temperature", + self.sense.get_temperature, + **options, + ), + HumiditySensor( + "Humidité", self.uid, "humidity", self.sense.get_humidity, **options + ), + PressureSensor( + "Pression", self.uid, "pressure", self.sense.get_pressure, **options + ), + ] + + def publish_discovery(self): + for sensor in self.sensors: + sensor.publish_discovery(self.mqttc) + + def publish_online(self): + self.mqttc.publish(self.availability_topic, "online", retain=True) + + def publish_state(self): + self.mqttc.publish( + self.state_topic, + json.dumps({sensor.slug: sensor.get_value() for sensor in self.sensors}), + ) + + +class SingleSensor: + def __init__(self, name, parent_uid, slug, get_value, **kwargs): + self.name = name + self.parent_uid = parent_uid + self.slug = slug + self.get_value = get_value + self.options = kwargs + + def publish_discovery(self, mqttc): + mqttc.publish( + self.discovery_topic, + json.dumps( + { + "name": self.name, + "state_class": "MEASUREMENT", + "unique_id": self.uid, + "value_template": self.value_template, + } + | self.options + ), + retain=True, + ) + + @property + def uid(self): + return f"{self.parent_uid}_{self.slug}" + + @property + def discovery_topic(self): + return f"homeassistant/sensor/{self.uid}/config" + + @property + def value_template(self): + return "{{" + f"value_json.{self.slug}" + "}}" + + +class TemperatureSensor(SingleSensor): + def __init__(self, name, parent_uid, slug, get_value, **kwargs): + super().__init__(name, parent_uid, slug, get_value, **kwargs) + self.options["device_class"] = "temperature" + self.options["icon"] = "mdi:thermometer" + self.options["suggested_display_precision"] = 1 + self.options["unit_of_measurement"] = "°C" + + +class HumiditySensor(SingleSensor): + def __init__(self, name, parent_uid, slug, get_value, **kwargs): + super().__init__(name, parent_uid, slug, get_value, **kwargs) + self.options["device_class"] = "humidity" + self.options["icon"] = "mdi:water-percent" + self.options["suggested_display_precision"] = 0 + self.options["unit_of_measurement"] = "%" + + +class PressureSensor(SingleSensor): + def __init__(self, name, parent_uid, slug, get_value, **kwargs): + super().__init__(name, parent_uid, slug, get_value, **kwargs) + self.options["device_class"] = "pressure" + self.options["icon"] = "mdi:gauge" + self.options["suggested_display_precision"] = 0 + self.options["unit_of_measurement"] = "mbar" diff --git a/src/tom-thumb.bdf b/src/tom-thumb.bdf new file mode 100644 index 0000000..3896c97 --- /dev/null +++ b/src/tom-thumb.bdf @@ -0,0 +1,2353 @@ +STARTFONT 2.1 +FONT -Raccoon-Fixed4x6-Medium-R-Normal--6-60-75-75-P-40-ISO10646-1 +SIZE 6 75 75 +FONTBOUNDINGBOX 3 6 0 -1 +STARTPROPERTIES 25 +FONT_NAME "Fixed4x6" +FONT_ASCENT 5 +FONT_DESCENT 1 +QUAD_WIDTH 6 +X_HEIGHT 3 +CAP_HEIGHT 4 +FONTNAME_REGISTRY "" +FAMILY_NAME "Fixed4x6" +FOUNDRY "Raccoon" +WEIGHT_NAME "Medium" +SETWIDTH_NAME "Normal" +SLANT "R" +ADD_STYLE_NAME "" +PIXEL_SIZE 6 +POINT_SIZE 60 +RESOLUTION_X 75 +RESOLUTION_Y 75 +RESOLUTION 75 +SPACING "P" +AVERAGE_WIDTH 40 +CHARSET_REGISTRY "ISO10646" +CHARSET_ENCODING "1" +CHARSET_COLLECTIONS "ASCII ISOLatin1Encoding ISO10646-1" +FULL_NAME "Fixed4x6" +COPYRIGHT """""MIT""""" +ENDPROPERTIES +CHARS 203 +STARTCHAR space +ENCODING 32 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 1 1 3 4 +BITMAP +00 +ENDCHAR +STARTCHAR exclam +ENCODING 33 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 1 5 1 0 +BITMAP +80 +80 +80 +00 +80 +ENDCHAR +STARTCHAR quotedbl +ENCODING 34 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 2 0 3 +BITMAP +A0 +A0 +ENDCHAR +STARTCHAR numbersign +ENCODING 35 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +E0 +A0 +E0 +A0 +ENDCHAR +STARTCHAR dollar +ENCODING 36 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +C0 +60 +C0 +40 +ENDCHAR +STARTCHAR percent +ENCODING 37 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +80 +20 +40 +80 +20 +ENDCHAR +STARTCHAR ampersand +ENCODING 38 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +C0 +E0 +A0 +60 +ENDCHAR +STARTCHAR quotesingle +ENCODING 39 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 1 2 1 3 +BITMAP +80 +80 +ENDCHAR +STARTCHAR parenleft +ENCODING 40 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 2 5 1 0 +BITMAP +40 +80 +80 +80 +40 +ENDCHAR +STARTCHAR parenright +ENCODING 41 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 2 5 0 0 +BITMAP +80 +40 +40 +40 +80 +ENDCHAR +STARTCHAR asterisk +ENCODING 42 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 3 0 2 +BITMAP +A0 +40 +A0 +ENDCHAR +STARTCHAR plus +ENCODING 43 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 3 0 1 +BITMAP +40 +E0 +40 +ENDCHAR +STARTCHAR comma +ENCODING 44 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 2 2 0 0 +BITMAP +40 +80 +ENDCHAR +STARTCHAR hyphen +ENCODING 45 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 1 0 2 +BITMAP +E0 +ENDCHAR +STARTCHAR period +ENCODING 46 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 1 1 1 0 +BITMAP +80 +ENDCHAR +STARTCHAR slash +ENCODING 47 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +20 +20 +40 +80 +80 +ENDCHAR +STARTCHAR zero +ENCODING 48 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +A0 +A0 +A0 +C0 +ENDCHAR +STARTCHAR one +ENCODING 49 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 2 5 0 0 +BITMAP +40 +C0 +40 +40 +40 +ENDCHAR +STARTCHAR two +ENCODING 50 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +20 +40 +80 +E0 +ENDCHAR +STARTCHAR three +ENCODING 51 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +20 +40 +20 +C0 +ENDCHAR +STARTCHAR four +ENCODING 52 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +A0 +E0 +20 +20 +ENDCHAR +STARTCHAR five +ENCODING 53 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +80 +C0 +20 +C0 +ENDCHAR +STARTCHAR six +ENCODING 54 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +80 +E0 +A0 +E0 +ENDCHAR +STARTCHAR seven +ENCODING 55 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +20 +40 +80 +80 +ENDCHAR +STARTCHAR eight +ENCODING 56 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +A0 +E0 +A0 +E0 +ENDCHAR +STARTCHAR nine +ENCODING 57 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +A0 +E0 +20 +C0 +ENDCHAR +STARTCHAR colon +ENCODING 58 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 1 3 1 1 +BITMAP +80 +00 +80 +ENDCHAR +STARTCHAR semicolon +ENCODING 59 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 2 4 0 0 +BITMAP +40 +00 +40 +80 +ENDCHAR +STARTCHAR less +ENCODING 60 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +20 +40 +80 +40 +20 +ENDCHAR +STARTCHAR equal +ENCODING 61 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 3 0 1 +BITMAP +E0 +00 +E0 +ENDCHAR +STARTCHAR greater +ENCODING 62 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +80 +40 +20 +40 +80 +ENDCHAR +STARTCHAR question +ENCODING 63 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +20 +40 +00 +40 +ENDCHAR +STARTCHAR at +ENCODING 64 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +A0 +E0 +80 +60 +ENDCHAR +STARTCHAR A +ENCODING 65 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +A0 +E0 +A0 +A0 +ENDCHAR +STARTCHAR B +ENCODING 66 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +A0 +C0 +A0 +C0 +ENDCHAR +STARTCHAR C +ENCODING 67 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +80 +80 +80 +60 +ENDCHAR +STARTCHAR D +ENCODING 68 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +A0 +A0 +A0 +C0 +ENDCHAR +STARTCHAR E +ENCODING 69 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +80 +E0 +80 +E0 +ENDCHAR +STARTCHAR F +ENCODING 70 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +80 +E0 +80 +80 +ENDCHAR +STARTCHAR G +ENCODING 71 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +80 +E0 +A0 +60 +ENDCHAR +STARTCHAR H +ENCODING 72 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +A0 +E0 +A0 +A0 +ENDCHAR +STARTCHAR I +ENCODING 73 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +40 +40 +40 +E0 +ENDCHAR +STARTCHAR J +ENCODING 74 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +20 +20 +20 +A0 +40 +ENDCHAR +STARTCHAR K +ENCODING 75 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +A0 +C0 +A0 +A0 +ENDCHAR +STARTCHAR L +ENCODING 76 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +80 +80 +80 +80 +E0 +ENDCHAR +STARTCHAR M +ENCODING 77 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +E0 +E0 +A0 +A0 +ENDCHAR +STARTCHAR N +ENCODING 78 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +E0 +E0 +E0 +A0 +ENDCHAR +STARTCHAR O +ENCODING 79 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +A0 +A0 +A0 +40 +ENDCHAR +STARTCHAR P +ENCODING 80 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +A0 +C0 +80 +80 +ENDCHAR +STARTCHAR Q +ENCODING 81 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +A0 +A0 +E0 +60 +ENDCHAR +STARTCHAR R +ENCODING 82 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +A0 +E0 +C0 +A0 +ENDCHAR +STARTCHAR S +ENCODING 83 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +80 +40 +20 +C0 +ENDCHAR +STARTCHAR T +ENCODING 84 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +40 +40 +40 +40 +ENDCHAR +STARTCHAR U +ENCODING 85 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +A0 +A0 +A0 +60 +ENDCHAR +STARTCHAR V +ENCODING 86 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +A0 +A0 +40 +40 +ENDCHAR +STARTCHAR W +ENCODING 87 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +A0 +E0 +E0 +A0 +ENDCHAR +STARTCHAR X +ENCODING 88 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +A0 +40 +A0 +A0 +ENDCHAR +STARTCHAR Y +ENCODING 89 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +A0 +40 +40 +40 +ENDCHAR +STARTCHAR Z +ENCODING 90 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +20 +40 +80 +E0 +ENDCHAR +STARTCHAR bracketleft +ENCODING 91 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +80 +80 +80 +E0 +ENDCHAR +STARTCHAR backslash +ENCODING 92 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 3 0 1 +BITMAP +80 +40 +20 +ENDCHAR +STARTCHAR bracketright +ENCODING 93 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +20 +20 +20 +E0 +ENDCHAR +STARTCHAR asciicircum +ENCODING 94 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 2 0 3 +BITMAP +40 +A0 +ENDCHAR +STARTCHAR underscore +ENCODING 95 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 1 0 0 +BITMAP +E0 +ENDCHAR +STARTCHAR grave +ENCODING 96 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 2 2 0 3 +BITMAP +80 +40 +ENDCHAR +STARTCHAR a +ENCODING 97 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +C0 +60 +A0 +E0 +ENDCHAR +STARTCHAR b +ENCODING 98 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +80 +C0 +A0 +A0 +C0 +ENDCHAR +STARTCHAR c +ENCODING 99 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +60 +80 +80 +60 +ENDCHAR +STARTCHAR d +ENCODING 100 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +20 +60 +A0 +A0 +60 +ENDCHAR +STARTCHAR e +ENCODING 101 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +60 +A0 +C0 +60 +ENDCHAR +STARTCHAR f +ENCODING 102 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +20 +40 +E0 +40 +40 +ENDCHAR +STARTCHAR g +ENCODING 103 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 -1 +BITMAP +60 +A0 +E0 +20 +40 +ENDCHAR +STARTCHAR h +ENCODING 104 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +80 +C0 +A0 +A0 +A0 +ENDCHAR +STARTCHAR i +ENCODING 105 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 1 5 1 0 +BITMAP +80 +00 +80 +80 +80 +ENDCHAR +STARTCHAR j +ENCODING 106 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 6 0 -1 +BITMAP +20 +00 +20 +20 +A0 +40 +ENDCHAR +STARTCHAR k +ENCODING 107 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +80 +A0 +C0 +C0 +A0 +ENDCHAR +STARTCHAR l +ENCODING 108 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +40 +40 +40 +E0 +ENDCHAR +STARTCHAR m +ENCODING 109 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +E0 +E0 +E0 +A0 +ENDCHAR +STARTCHAR n +ENCODING 110 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +C0 +A0 +A0 +A0 +ENDCHAR +STARTCHAR o +ENCODING 111 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +40 +A0 +A0 +40 +ENDCHAR +STARTCHAR p +ENCODING 112 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 -1 +BITMAP +C0 +A0 +A0 +C0 +80 +ENDCHAR +STARTCHAR q +ENCODING 113 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 -1 +BITMAP +60 +A0 +A0 +60 +20 +ENDCHAR +STARTCHAR r +ENCODING 114 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +60 +80 +80 +80 +ENDCHAR +STARTCHAR s +ENCODING 115 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +60 +C0 +60 +C0 +ENDCHAR +STARTCHAR t +ENCODING 116 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +E0 +40 +40 +60 +ENDCHAR +STARTCHAR u +ENCODING 117 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +A0 +A0 +A0 +60 +ENDCHAR +STARTCHAR v +ENCODING 118 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +A0 +A0 +E0 +40 +ENDCHAR +STARTCHAR w +ENCODING 119 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +A0 +E0 +E0 +E0 +ENDCHAR +STARTCHAR x +ENCODING 120 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +A0 +40 +40 +A0 +ENDCHAR +STARTCHAR y +ENCODING 121 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 -1 +BITMAP +A0 +A0 +60 +20 +40 +ENDCHAR +STARTCHAR z +ENCODING 122 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +E0 +60 +C0 +E0 +ENDCHAR +STARTCHAR braceleft +ENCODING 123 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +40 +80 +40 +60 +ENDCHAR +STARTCHAR bar +ENCODING 124 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 1 5 1 0 +BITMAP +80 +80 +00 +80 +80 +ENDCHAR +STARTCHAR braceright +ENCODING 125 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +40 +20 +40 +C0 +ENDCHAR +STARTCHAR asciitilde +ENCODING 126 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 2 0 3 +BITMAP +60 +C0 +ENDCHAR +STARTCHAR exclamdown +ENCODING 161 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 1 5 1 0 +BITMAP +80 +00 +80 +80 +80 +ENDCHAR +STARTCHAR cent +ENCODING 162 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +E0 +80 +E0 +40 +ENDCHAR +STARTCHAR sterling +ENCODING 163 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +40 +E0 +40 +E0 +ENDCHAR +STARTCHAR currency +ENCODING 164 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +40 +E0 +40 +A0 +ENDCHAR +STARTCHAR yen +ENCODING 165 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +A0 +40 +E0 +40 +ENDCHAR +STARTCHAR brokenbar +ENCODING 166 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 1 5 1 0 +BITMAP +80 +80 +00 +80 +80 +ENDCHAR +STARTCHAR section +ENCODING 167 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +40 +A0 +40 +C0 +ENDCHAR +STARTCHAR dieresis +ENCODING 168 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 1 0 4 +BITMAP +A0 +ENDCHAR +STARTCHAR copyright +ENCODING 169 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 3 0 2 +BITMAP +60 +80 +60 +ENDCHAR +STARTCHAR ordfeminine +ENCODING 170 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +A0 +E0 +00 +E0 +ENDCHAR +STARTCHAR guillemotleft +ENCODING 171 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 2 3 0 2 +BITMAP +40 +80 +40 +ENDCHAR +STARTCHAR logicalnot +ENCODING 172 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 2 0 2 +BITMAP +E0 +20 +ENDCHAR +STARTCHAR softhyphen +ENCODING 173 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 2 1 0 2 +BITMAP +C0 +ENDCHAR +STARTCHAR registered +ENCODING 174 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 3 0 2 +BITMAP +C0 +C0 +A0 +ENDCHAR +STARTCHAR macron +ENCODING 175 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 1 0 4 +BITMAP +E0 +ENDCHAR +STARTCHAR degree +ENCODING 176 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 3 0 2 +BITMAP +40 +A0 +40 +ENDCHAR +STARTCHAR plusminus +ENCODING 177 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +E0 +40 +00 +E0 +ENDCHAR +STARTCHAR twosuperior +ENCODING 178 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 3 0 2 +BITMAP +C0 +40 +60 +ENDCHAR +STARTCHAR threesuperior +ENCODING 179 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 3 0 2 +BITMAP +E0 +60 +E0 +ENDCHAR +STARTCHAR acute +ENCODING 180 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 2 2 1 3 +BITMAP +40 +80 +ENDCHAR +STARTCHAR mu +ENCODING 181 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +A0 +A0 +C0 +80 +ENDCHAR +STARTCHAR paragraph +ENCODING 182 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +A0 +60 +60 +60 +ENDCHAR +STARTCHAR periodcentered +ENCODING 183 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 3 0 1 +BITMAP +E0 +E0 +E0 +ENDCHAR +STARTCHAR cedilla +ENCODING 184 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 3 0 0 +BITMAP +40 +20 +C0 +ENDCHAR +STARTCHAR onesuperior +ENCODING 185 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 1 3 1 2 +BITMAP +80 +80 +80 +ENDCHAR +STARTCHAR ordmasculine +ENCODING 186 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +A0 +40 +00 +E0 +ENDCHAR +STARTCHAR guillemotright +ENCODING 187 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 2 3 1 2 +BITMAP +80 +40 +80 +ENDCHAR +STARTCHAR onequarter +ENCODING 188 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +80 +80 +00 +60 +20 +ENDCHAR +STARTCHAR onehalf +ENCODING 189 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +80 +80 +00 +C0 +60 +ENDCHAR +STARTCHAR threequarters +ENCODING 190 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +C0 +00 +60 +20 +ENDCHAR +STARTCHAR questiondown +ENCODING 191 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +00 +40 +80 +E0 +ENDCHAR +STARTCHAR Agrave +ENCODING 192 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +20 +40 +E0 +A0 +ENDCHAR +STARTCHAR Aacute +ENCODING 193 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +80 +40 +E0 +A0 +ENDCHAR +STARTCHAR Acircumflex +ENCODING 194 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +00 +40 +E0 +A0 +ENDCHAR +STARTCHAR Atilde +ENCODING 195 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +C0 +40 +E0 +A0 +ENDCHAR +STARTCHAR Adieresis +ENCODING 196 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +40 +A0 +E0 +A0 +ENDCHAR +STARTCHAR Aring +ENCODING 197 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +C0 +A0 +E0 +A0 +ENDCHAR +STARTCHAR AE +ENCODING 198 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +C0 +E0 +C0 +E0 +ENDCHAR +STARTCHAR Ccedilla +ENCODING 199 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 6 0 -1 +BITMAP +60 +80 +80 +60 +20 +40 +ENDCHAR +STARTCHAR Egrave +ENCODING 200 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +20 +E0 +C0 +E0 +ENDCHAR +STARTCHAR Eacute +ENCODING 201 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +80 +E0 +C0 +E0 +ENDCHAR +STARTCHAR Ecircumflex +ENCODING 202 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +00 +E0 +C0 +E0 +ENDCHAR +STARTCHAR Edieresis +ENCODING 203 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +00 +E0 +C0 +E0 +ENDCHAR +STARTCHAR Igrave +ENCODING 204 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +20 +E0 +40 +E0 +ENDCHAR +STARTCHAR Iacute +ENCODING 205 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +80 +E0 +40 +E0 +ENDCHAR +STARTCHAR Icircumflex +ENCODING 206 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +00 +E0 +40 +E0 +ENDCHAR +STARTCHAR Idieresis +ENCODING 207 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +00 +E0 +40 +E0 +ENDCHAR +STARTCHAR Eth +ENCODING 208 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +A0 +E0 +A0 +C0 +ENDCHAR +STARTCHAR Ntilde +ENCODING 209 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +60 +A0 +E0 +A0 +ENDCHAR +STARTCHAR Ograve +ENCODING 210 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +20 +E0 +A0 +E0 +ENDCHAR +STARTCHAR Oacute +ENCODING 211 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +80 +E0 +A0 +E0 +ENDCHAR +STARTCHAR Ocircumflex +ENCODING 212 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +00 +E0 +A0 +E0 +ENDCHAR +STARTCHAR Otilde +ENCODING 213 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +60 +E0 +A0 +E0 +ENDCHAR +STARTCHAR Odieresis +ENCODING 214 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +00 +E0 +A0 +E0 +ENDCHAR +STARTCHAR multiply +ENCODING 215 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 3 0 1 +BITMAP +A0 +40 +A0 +ENDCHAR +STARTCHAR Oslash +ENCODING 216 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +A0 +E0 +A0 +C0 +ENDCHAR +STARTCHAR Ugrave +ENCODING 217 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +80 +40 +A0 +A0 +E0 +ENDCHAR +STARTCHAR Uacute +ENCODING 218 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +20 +40 +A0 +A0 +E0 +ENDCHAR +STARTCHAR Ucircumflex +ENCODING 219 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +00 +A0 +A0 +E0 +ENDCHAR +STARTCHAR Udieresis +ENCODING 220 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +00 +A0 +A0 +E0 +ENDCHAR +STARTCHAR Yacute +ENCODING 221 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +20 +40 +A0 +E0 +40 +ENDCHAR +STARTCHAR Thorn +ENCODING 222 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +80 +E0 +A0 +E0 +80 +ENDCHAR +STARTCHAR germandbls +ENCODING 223 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 6 0 -1 +BITMAP +60 +A0 +C0 +A0 +C0 +80 +ENDCHAR +STARTCHAR agrave +ENCODING 224 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +20 +60 +A0 +E0 +ENDCHAR +STARTCHAR aacute +ENCODING 225 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +80 +60 +A0 +E0 +ENDCHAR +STARTCHAR acircumflex +ENCODING 226 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +00 +60 +A0 +E0 +ENDCHAR +STARTCHAR atilde +ENCODING 227 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +C0 +60 +A0 +E0 +ENDCHAR +STARTCHAR adieresis +ENCODING 228 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +00 +60 +A0 +E0 +ENDCHAR +STARTCHAR aring +ENCODING 229 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +60 +60 +A0 +E0 +ENDCHAR +STARTCHAR ae +ENCODING 230 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +60 +E0 +E0 +C0 +ENDCHAR +STARTCHAR ccedilla +ENCODING 231 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 -1 +BITMAP +60 +80 +60 +20 +40 +ENDCHAR +STARTCHAR egrave +ENCODING 232 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +20 +60 +E0 +60 +ENDCHAR +STARTCHAR eacute +ENCODING 233 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +80 +60 +E0 +60 +ENDCHAR +STARTCHAR ecircumflex +ENCODING 234 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +00 +60 +E0 +60 +ENDCHAR +STARTCHAR edieresis +ENCODING 235 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +00 +60 +E0 +60 +ENDCHAR +STARTCHAR igrave +ENCODING 236 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 2 5 1 0 +BITMAP +80 +40 +80 +80 +80 +ENDCHAR +STARTCHAR iacute +ENCODING 237 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 2 5 0 0 +BITMAP +40 +80 +40 +40 +40 +ENDCHAR +STARTCHAR icircumflex +ENCODING 238 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +00 +40 +40 +40 +ENDCHAR +STARTCHAR idieresis +ENCODING 239 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +00 +40 +40 +40 +ENDCHAR +STARTCHAR eth +ENCODING 240 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +C0 +60 +A0 +60 +ENDCHAR +STARTCHAR ntilde +ENCODING 241 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +60 +C0 +A0 +A0 +ENDCHAR +STARTCHAR ograve +ENCODING 242 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +20 +40 +A0 +40 +ENDCHAR +STARTCHAR oacute +ENCODING 243 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +80 +40 +A0 +40 +ENDCHAR +STARTCHAR ocircumflex +ENCODING 244 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +00 +40 +A0 +40 +ENDCHAR +STARTCHAR otilde +ENCODING 245 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +C0 +60 +40 +A0 +40 +ENDCHAR +STARTCHAR odieresis +ENCODING 246 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +00 +40 +A0 +40 +ENDCHAR +STARTCHAR divide +ENCODING 247 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +40 +00 +E0 +00 +40 +ENDCHAR +STARTCHAR oslash +ENCODING 248 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +60 +E0 +A0 +C0 +ENDCHAR +STARTCHAR ugrave +ENCODING 249 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +80 +40 +A0 +A0 +60 +ENDCHAR +STARTCHAR uacute +ENCODING 250 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +20 +40 +A0 +A0 +60 +ENDCHAR +STARTCHAR ucircumflex +ENCODING 251 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +E0 +00 +A0 +A0 +60 +ENDCHAR +STARTCHAR udieresis +ENCODING 252 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +00 +A0 +A0 +60 +ENDCHAR +STARTCHAR yacute +ENCODING 253 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 6 0 -1 +BITMAP +20 +40 +A0 +60 +20 +40 +ENDCHAR +STARTCHAR thorn +ENCODING 254 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 5 0 -1 +BITMAP +80 +C0 +A0 +C0 +80 +ENDCHAR +STARTCHAR ydieresis +ENCODING 255 +SWIDTH 1000 0 +DWIDTH 4 0 +BBX 3 6 0 -1 +BITMAP +A0 +00 +A0 +60 +20 +40 +ENDCHAR +STARTCHAR gcircumflex +ENCODING 285 +SWIDTH 1000 0 +DWIDTH 6 0 +BBX 1 1 0 0 +BITMAP +00 +ENDCHAR +STARTCHAR OE +ENCODING 338 +SWIDTH 666 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +C0 +E0 +C0 +60 +ENDCHAR +STARTCHAR oe +ENCODING 339 +SWIDTH 666 0 +DWIDTH 4 0 +BBX 3 4 0 0 +BITMAP +60 +E0 +C0 +E0 +ENDCHAR +STARTCHAR Scaron +ENCODING 352 +SWIDTH 666 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +60 +C0 +60 +C0 +ENDCHAR +STARTCHAR scaron +ENCODING 353 +SWIDTH 666 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +60 +C0 +60 +C0 +ENDCHAR +STARTCHAR Ydieresis +ENCODING 376 +SWIDTH 666 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +00 +A0 +40 +40 +ENDCHAR +STARTCHAR Zcaron +ENCODING 381 +SWIDTH 666 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +E0 +60 +C0 +E0 +ENDCHAR +STARTCHAR zcaron +ENCODING 382 +SWIDTH 666 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +A0 +E0 +60 +C0 +E0 +ENDCHAR +STARTCHAR uni0EA4 +ENCODING 3748 +SWIDTH 1000 0 +DWIDTH 6 0 +BBX 1 1 0 0 +BITMAP +00 +ENDCHAR +STARTCHAR uni13A0 +ENCODING 5024 +SWIDTH 1000 0 +DWIDTH 6 0 +BBX 1 1 0 0 +BITMAP +00 +ENDCHAR +STARTCHAR bullet +ENCODING 8226 +SWIDTH 666 0 +DWIDTH 4 0 +BBX 1 1 1 2 +BITMAP +80 +ENDCHAR +STARTCHAR ellipsis +ENCODING 8230 +SWIDTH 666 0 +DWIDTH 4 0 +BBX 3 1 0 0 +BITMAP +A0 +ENDCHAR +STARTCHAR Euro +ENCODING 8364 +SWIDTH 666 0 +DWIDTH 4 0 +BBX 3 5 0 0 +BITMAP +60 +E0 +E0 +C0 +60 +ENDCHAR +ENDFONT From 9ad46a717a1b7ff251e0022fefa1cf14805647bd Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 11 Nov 2024 16:49:43 +0100 Subject: [PATCH 04/24] Multiple views --- oin_ha/display/__init__.py | 155 +++++++++++++++++++++---------------- 1 file changed, 88 insertions(+), 67 deletions(-) diff --git a/oin_ha/display/__init__.py b/oin_ha/display/__init__.py index 829ed6a..58a5396 100644 --- a/oin_ha/display/__init__.py +++ b/oin_ha/display/__init__.py @@ -2,7 +2,7 @@ import json from threading import Timer import bdfparser -from sense_hat import SenseHat +from sense_hat import ACTION_RELEASED, SenseHat class Display: @@ -49,7 +49,7 @@ class Display: class Light: - def __init__(self, name, parent_uid, slug, sense, **kwargs): + def __init__(self, name, parent_uid, slug, sense, n=3, **kwargs): self.name = name self.parent_uid = parent_uid self.slug = slug @@ -57,60 +57,69 @@ class Light: self.options = kwargs self._switch = False - self._color = [255, 255, 255] - self._pre = "" - self._value = "" + self._color = [[255, 255, 255]] * n + self._pres = [""] * n + self._values = [""] * n + self._n = n + self._i = 0 self.font = bdfparser.Font("src/tom-thumb.bdf") self.timer = Timer(0, self.__init__) + self.sense.stick.direction_right = self.switch_screen + self.sense.stick.direction_left = self.switch_screen_rev + def publish_discovery(self, mqttc): - mqttc.publish( - self.discovery_topic, - json.dumps( - { - "command_topic": self.command_topic("command"), - "effect_command_topic": self.command_topic("effect"), - "effect_list": ["Low Light", "Normal"], - "effect_state_topie": self.state_topic, - "effect_value_template": "{{ value_json.effect }}", - "icon": "mdi:dots-grid", - "name": self.name, - "on_command_type": "brightness", - "rgb_command_topic": self.command_topic("rgb"), - "rgb_state_topic": self.state_topic, - "rgb_value_template": "{{ value_json.rgb }}", - "retain": True, - "unique_id": self.uid, - "state_topic": self.state_topic, - "state_value_template": "{{ value_json.state }}", - } - | self.options - ), - retain=True, - ) - self.publish_state(mqttc) + for i in range(self._n): + mqttc.publish( + self.get_discovery_topic(i), + json.dumps( + { + "command_topic": self.command_topic(i, "command"), + "effect_command_topic": self.command_topic(i, "effect"), + "effect_list": ["Low Light", "Normal"], + "effect_state_topie": self.state_topic, + "effect_value_template": "{{ value_json.effect }}", + "icon": "mdi:dots-grid", + "name": f"{self.name} {i}", + "on_command_type": "brightness", + "rgb_command_topic": self.command_topic(i, "command"), + "rgb_state_topic": self.state_topic, + "rgb_value_template": "{{" + f"value_json.rgb[{i}]" + "}}", + "retain": True, + "unique_id": f"{self.uid}_{i}", + "state_topic": self.state_topic, + "state_value_template": "{{ value_json.state }}", + } + | self.options + ), + retain=True, + ) + self.publish_state(mqttc) def subscribe(self, mqttc): - mqttc.subscribe(self.command_topic("command")) - mqttc.subscribe(self.command_topic("effect")) - mqttc.subscribe(self.command_topic("rgb")) - - mqttc.subscribe(self.command_topic("value")) + for i in range(self._n): + mqttc.subscribe(self.command_topic(i, "command")) + mqttc.subscribe(self.command_topic(i, "effect")) + mqttc.subscribe(self.command_topic(i, "rgb")) + mqttc.subscribe(self.command_topic(i, "value")) def on_message(self, client, userdata, message): data = message.payload.decode() + print(message.topic) print(data) - match message.topic.rsplit("/", maxsplit=1): - case [self.base_topic, "command"]: - self.switch = data == "ON" - case [self.base_topic, "rgb"]: - self.color = list(map(int, data.split(","))) - case [self.base_topic, "effect"]: + match message.topic.rsplit("/", maxsplit=2): + case [self.base_topic, i, "command"]: + match data.split(","): + case ["OFF"]: + self.switch = False + case [*rgb]: + self.set_color(int(i), list(map(int, rgb))) + case [self.base_topic, i, "effect"]: self.sense.low_light = data == "Low Light" - case [self.base_topic, "value"]: - self.value = data + case [self.base_topic, i, "value"]: + self.set_value(int(i), data) case _: return self.publish_state(client) @@ -132,16 +141,15 @@ class Light: def uid(self): return f"{self.parent_uid}_{self.slug}" - @property - def discovery_topic(self): - return f"homeassistant/light/{self.uid}/config" + def get_discovery_topic(self, i): + return f"homeassistant/light/{self.uid}_{i}/config" @property def base_topic(self): return f"{self.parent_uid}/display/{self.slug}" - def command_topic(self, cmd): - return f"{self.base_topic}/{cmd}" + def command_topic(self, i, cmd): + return f"{self.base_topic}/{i}/{cmd}" @property def state_topic(self): @@ -167,60 +175,73 @@ class Light: def color(self): return self._color - @color.setter - def color(self, value): - self._color = value + def set_color(self, i, value): + self._color[i] = value if not self.switch: self.switch = True - self.display_value() + if i == self._i: + self.display_value() @property def rgb(self): - return ",".join(map(str, self.color)) + return [",".join(map(str, self.color[i])) for i in range(self._n)] @property def value(self): - return f"{self._pre} {self._value}" + return f"{self._pres[self._i]} {self._values[self._i]}" - @value.setter - def value(self, value): + def set_value(self, i, value): match value.split(): case [val]: - self._pre = "" - self._value = val + self._pres[i] = "" + self._values[i] = val case [pre, val, *_]: - self._pre = pre - self._value = val - self.display_value() + self._pres[i] = pre + self._values[i] = val + if i == self._i: + self.display_value() def update_value(self): if not self.switch: return - if not self._pre: + if not self._pres[self._i]: self.display_value() return self.timer.cancel() - pixels = self.to_pixels(self._pre) + pixels = self.to_pixels(self._pres[self._i]) + print(pixels) self.sense.set_pixels(pixels) - self.timer = Timer(1, self.display_value, kwargs=dict(timer=True)) + self.timer = Timer(0.25, self.display_value, kwargs=dict(timer=True)) self.timer.start() def display_value(self, timer=False): if (not timer and self.timer.is_alive()) or not self.switch: return - pixels = self.to_pixels(self._value) + pixels = self.to_pixels(self._values[self._i]) self.sense.set_pixels(pixels) def to_pixels(self, text): if text: return [ - self.color if x else [0, 0, 0] + self.color[self._i] if x else [0, 0, 0] for x in self.font.draw(text).crop(8, 8, yoff=-2).todata(3) ] - return [self.color] * 64 + return [self.color[self._i]] * 64 + + def switch_screen(self, event): + if event.action == ACTION_RELEASED: + self._i = (self._i + 1) % self._n + self.update_value() + print(f"switch to {self._i}") + + def switch_screen_rev(self, event): + if event.action == ACTION_RELEASED: + self._i = (self._i - 1) % self._n + self.update_value() + print(f"switch to {self._i}") From e7ca999711d95625106b6cb0528b88f28268cffb Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 11 Nov 2024 16:50:14 +0100 Subject: [PATCH 05/24] Remove prints --- oin_ha/display/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/oin_ha/display/__init__.py b/oin_ha/display/__init__.py index 58a5396..d2dd9d4 100644 --- a/oin_ha/display/__init__.py +++ b/oin_ha/display/__init__.py @@ -106,8 +106,6 @@ class Light: def on_message(self, client, userdata, message): data = message.payload.decode() - print(message.topic) - print(data) match message.topic.rsplit("/", maxsplit=2): case [self.base_topic, i, "command"]: @@ -212,7 +210,6 @@ class Light: self.timer.cancel() pixels = self.to_pixels(self._pres[self._i]) - print(pixels) self.sense.set_pixels(pixels) self.timer = Timer(0.25, self.display_value, kwargs=dict(timer=True)) @@ -238,10 +235,8 @@ class Light: if event.action == ACTION_RELEASED: self._i = (self._i + 1) % self._n self.update_value() - print(f"switch to {self._i}") def switch_screen_rev(self, event): if event.action == ACTION_RELEASED: self._i = (self._i - 1) % self._n self.update_value() - print(f"switch to {self._i}") From 5b7f4c29e11889ebc12bb6156ccc413d85b1e4ba Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 11 Nov 2024 17:53:25 +0100 Subject: [PATCH 06/24] Add topic for color change without switch on, add toggle on joystick click --- oin_ha/display/__init__.py | 85 +++++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/oin_ha/display/__init__.py b/oin_ha/display/__init__.py index d2dd9d4..5104719 100644 --- a/oin_ha/display/__init__.py +++ b/oin_ha/display/__init__.py @@ -32,28 +32,39 @@ class Display: "availability_topic": self.availability_topic, } - self.main_light = Light("LED", self.uid, "led", self.sense, **options) + self.main_light = Light( + "LED", self.uid, "led", self.sense, mqttc=self.mqttc, **options + ) def publish_discovery(self): - self.main_light.publish_discovery(self.mqttc) + self.main_light.publish_discovery() def publish_online(self): self.subscribe() self.mqttc.publish(self.availability_topic, "online", retain=True) def subscribe(self): - self.main_light.subscribe(self.mqttc) + self.main_light.subscribe() def on_message(self, *args, **kwargs): self.main_light.on_message(*args, **kwargs) class Light: - def __init__(self, name, parent_uid, slug, sense, n=3, **kwargs): + _colors = { + "Bleu": [0, 0, 255], + "Blanc": [255, 255, 255], + "Rouge": [255, 0, 0], + "Verte": [0, 255, 0], + "Jaune": [255, 255, 0], + } + + def __init__(self, name, parent_uid, slug, sense, mqttc, n=6, **kwargs): self.name = name self.parent_uid = parent_uid self.slug = slug self.sense = sense + self.mqttc = mqttc self.options = kwargs self._switch = False @@ -68,17 +79,18 @@ class Light: self.sense.stick.direction_right = self.switch_screen self.sense.stick.direction_left = self.switch_screen_rev + self.sense.stick.direction_middle = self.toggle - def publish_discovery(self, mqttc): + def publish_discovery(self): for i in range(self._n): - mqttc.publish( + self.mqttc.publish( self.get_discovery_topic(i), json.dumps( { "command_topic": self.command_topic(i, "command"), "effect_command_topic": self.command_topic(i, "effect"), "effect_list": ["Low Light", "Normal"], - "effect_state_topie": self.state_topic, + "effect_state_topic": self.state_topic, "effect_value_template": "{{ value_json.effect }}", "icon": "mdi:dots-grid", "name": f"{self.name} {i}", @@ -95,14 +107,15 @@ class Light: ), retain=True, ) - self.publish_state(mqttc) + self.publish_state() - def subscribe(self, mqttc): + def subscribe(self): for i in range(self._n): - mqttc.subscribe(self.command_topic(i, "command")) - mqttc.subscribe(self.command_topic(i, "effect")) - mqttc.subscribe(self.command_topic(i, "rgb")) - mqttc.subscribe(self.command_topic(i, "value")) + self.mqttc.subscribe(self.command_topic(i, "command")) + self.mqttc.subscribe(self.command_topic(i, "effect")) + self.mqttc.subscribe(self.command_topic(i, "rgb")) + self.mqttc.subscribe(self.command_topic(i, "value")) + self.mqttc.subscribe(self.command_topic(i, "action_color")) def on_message(self, client, userdata, message): data = message.payload.decode() @@ -115,19 +128,20 @@ class Light: case [*rgb]: self.set_color(int(i), list(map(int, rgb))) case [self.base_topic, i, "effect"]: - self.sense.low_light = data == "Low Light" + self.low_light = data == "Low Light" case [self.base_topic, i, "value"]: self.set_value(int(i), data) + case [self.base_topic, i, "action_color"]: + self.set_color(int(i), self._colors.get(data, [0, 0, 0]), False) case _: return - self.publish_state(client) - def publish_state(self, mqttc): - mqttc.publish( + def publish_state(self): + self.mqttc.publish( self.state_topic, json.dumps( { - "effect": "Low Light" if self.sense.low_light else "Normal", + "effect": "Low Light" if self.low_light else "Normal", "rgb": self.rgb, "state": self.state, } @@ -164,6 +178,7 @@ class Light: self.update_value() else: self.sense.clear() + self.publish_state() @property def state(self): @@ -173,12 +188,13 @@ class Light: def color(self): return self._color - def set_color(self, i, value): + def set_color(self, i, value, switch=True): self._color[i] = value - if not self.switch: + if switch and not self.switch: self.switch = True if i == self._i: self.display_value() + self.publish_state() @property def rgb(self): @@ -188,6 +204,15 @@ class Light: def value(self): return f"{self._pres[self._i]} {self._values[self._i]}" + @property + def low_light(self): + return self.sense.low_light + + @low_light.setter + def low_light(self, value): + self.sense.low_light = value + self.publish_state() + def set_value(self, i, value): match value.split(): case [val]: @@ -198,6 +223,7 @@ class Light: self._values[i] = val if i == self._i: self.display_value() + self.publish_state() def update_value(self): if not self.switch: @@ -207,17 +233,16 @@ class Light: self.display_value() return - self.timer.cancel() - pixels = self.to_pixels(self._pres[self._i]) self.sense.set_pixels(pixels) - self.timer = Timer(0.25, self.display_value, kwargs=dict(timer=True)) + self.timer = Timer(0.5, self.display_value, kwargs=dict(timer=True)) self.timer.start() def display_value(self, timer=False): if (not timer and self.timer.is_alive()) or not self.switch: return + self.timer.cancel() pixels = self.to_pixels(self._values[self._i]) self.sense.set_pixels(pixels) @@ -234,9 +259,19 @@ class Light: def switch_screen(self, event): if event.action == ACTION_RELEASED: self._i = (self._i + 1) % self._n - self.update_value() + self.switch = True def switch_screen_rev(self, event): if event.action == ACTION_RELEASED: self._i = (self._i - 1) % self._n - self.update_value() + self.switch = True + + def toggle(self, event): + if event.action == ACTION_RELEASED: + if not self.switch: + self.low_light = False + self.switch = True + elif self.low_light: + self.switch = False + else: + self.low_light = True From bd125904603e7ca4a819de2a875cc3c5eca2c984 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 11 Nov 2024 18:06:48 +0100 Subject: [PATCH 07/24] Fix joystick power switch --- oin_ha/display/__init__.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/oin_ha/display/__init__.py b/oin_ha/display/__init__.py index 5104719..5283925 100644 --- a/oin_ha/display/__init__.py +++ b/oin_ha/display/__init__.py @@ -2,7 +2,7 @@ import json from threading import Timer import bdfparser -from sense_hat import ACTION_RELEASED, SenseHat +from sense_hat import ACTION_HELD, ACTION_RELEASED, SenseHat class Display: @@ -73,6 +73,7 @@ class Light: self._values = [""] * n self._n = n self._i = 0 + self._toggled = False self.font = bdfparser.Font("src/tom-thumb.bdf") self.timer = Timer(0, self.__init__) @@ -95,7 +96,7 @@ class Light: "icon": "mdi:dots-grid", "name": f"{self.name} {i}", "on_command_type": "brightness", - "rgb_command_topic": self.command_topic(i, "command"), + "rgb_command_topic": self.command_topic(i, "rgb"), "rgb_state_topic": self.state_topic, "rgb_value_template": "{{" + f"value_json.rgb[{i}]" + "}}", "retain": True, @@ -119,14 +120,13 @@ class Light: def on_message(self, client, userdata, message): data = message.payload.decode() + print(message.topic, data) match message.topic.rsplit("/", maxsplit=2): case [self.base_topic, i, "command"]: - match data.split(","): - case ["OFF"]: - self.switch = False - case [*rgb]: - self.set_color(int(i), list(map(int, rgb))) + self.switch = False + case [self.base_topic, i, "rgb"]: + self.set_color(int(i), list(map(int, data.split(",")))) case [self.base_topic, i, "effect"]: self.low_light = data == "Low Light" case [self.base_topic, i, "value"]: @@ -251,7 +251,7 @@ class Light: if text: return [ self.color[self._i] if x else [0, 0, 0] - for x in self.font.draw(text).crop(8, 8, yoff=-2).todata(3) + for x in self.font.draw(text).crop(8, 8).todata(3) ] return [self.color[self._i]] * 64 @@ -267,11 +267,12 @@ class Light: self.switch = True def toggle(self, event): + if event.action == ACTION_HELD: + if not self._toggled: + self.low_light = not self.low_light + self._toggled = True if event.action == ACTION_RELEASED: - if not self.switch: - self.low_light = False - self.switch = True - elif self.low_light: - self.switch = False + if self._toggled: + self._toggled = False else: - self.low_light = True + self.switch = not self.switch From cb53e91a4dba95fdc94b42767def4248fd0557d5 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 11 Nov 2024 18:20:13 +0100 Subject: [PATCH 08/24] Enable switching from home assistant --- oin_ha/display/__init__.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/oin_ha/display/__init__.py b/oin_ha/display/__init__.py index 5283925..fa628d3 100644 --- a/oin_ha/display/__init__.py +++ b/oin_ha/display/__init__.py @@ -102,7 +102,7 @@ class Light: "retain": True, "unique_id": f"{self.uid}_{i}", "state_topic": self.state_topic, - "state_value_template": "{{ value_json.state }}", + "state_value_template": "{{" + f"value_json.state[{i}]" + "}}", } | self.options ), @@ -120,19 +120,19 @@ class Light: def on_message(self, client, userdata, message): data = message.payload.decode() - print(message.topic, data) match message.topic.rsplit("/", maxsplit=2): case [self.base_topic, i, "command"]: self.switch = False case [self.base_topic, i, "rgb"]: + self._i = int(i) self.set_color(int(i), list(map(int, data.split(",")))) case [self.base_topic, i, "effect"]: self.low_light = data == "Low Light" case [self.base_topic, i, "value"]: self.set_value(int(i), data) case [self.base_topic, i, "action_color"]: - self.set_color(int(i), self._colors.get(data, [0, 0, 0]), False) + self.set_color(int(i), self._colors.get(data, [0, 0, 0]), switch=False) case _: return @@ -143,7 +143,9 @@ class Light: { "effect": "Low Light" if self.low_light else "Normal", "rgb": self.rgb, - "state": self.state, + "state": [ + self.state if i == self._i else "OFF" for i in range(self._n) + ], } ), retain=True, @@ -192,7 +194,9 @@ class Light: self._color[i] = value if switch and not self.switch: self.switch = True - if i == self._i: + if switch: + self.update_value() + elif i == self._i: self.display_value() self.publish_state() From a0e7ea91aa9442ce19f20702072f122a393a29dd Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Sat, 7 Dec 2024 15:22:08 +0100 Subject: [PATCH 09/24] New version : Oin Thermostat --- oin_ha/__main__.py | 44 ----- oin_ha/display/__init__.py | 282 ------------------------------- oin_ha/sensors/__init__.py | 129 -------------- oin_thermostat/__main__.py | 15 ++ oin_thermostat/mqtt.py | 118 +++++++++++++ oin_thermostat/screen.py | 140 +++++++++++++++ oin_thermostat/select.py | 90 ++++++++++ oin_thermostat/src/tom-thumb.bdf | 0 8 files changed, 363 insertions(+), 455 deletions(-) delete mode 100644 oin_ha/__main__.py delete mode 100644 oin_ha/display/__init__.py delete mode 100644 oin_ha/sensors/__init__.py create mode 100644 oin_thermostat/__main__.py create mode 100644 oin_thermostat/mqtt.py create mode 100644 oin_thermostat/screen.py create mode 100644 oin_thermostat/select.py create mode 100644 oin_thermostat/src/tom-thumb.bdf diff --git a/oin_ha/__main__.py b/oin_ha/__main__.py deleted file mode 100644 index 0dff414..0000000 --- a/oin_ha/__main__.py +++ /dev/null @@ -1,44 +0,0 @@ -from threading import Timer - -import paho.mqtt.client as mqtt - -from .display import Display -from .sensors import Sensors - -dt = 10 -mqttc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) -hat_sensors = Sensors(mqttc, "oin", "Oin") -hat_display = Display(mqttc, "oin", "Oin") - - -@mqttc.connect_callback() -def on_connect(client, userdata, flags, reason_code, properties): - print(f"Connected with result code {reason_code}") - - hat_sensors.publish_discovery() - hat_display.publish_discovery() - - hat_sensors.publish_online() - hat_display.publish_online() - - timer = Timer(0, send_data) - timer.start() - - -@mqttc.message_callback() -def on_message(*args, **kwargs): - hat_display.on_message(*args, **kwargs) - - -mqttc.username_pw_set(username="oin", password="n+Bi58l7LxbH5nEJ") -mqttc.connect("homeassistant.local", 1883, 60) - - -def send_data(): - timer = Timer(dt, send_data) - timer.start() - - hat_sensors.publish_state() - - -mqttc.loop_forever() diff --git a/oin_ha/display/__init__.py b/oin_ha/display/__init__.py deleted file mode 100644 index fa628d3..0000000 --- a/oin_ha/display/__init__.py +++ /dev/null @@ -1,282 +0,0 @@ -import json -from threading import Timer - -import bdfparser -from sense_hat import ACTION_HELD, ACTION_RELEASED, SenseHat - - -class Display: - def __init__(self, mqttc, uid, name): - self.mqttc = mqttc - self.sense = SenseHat() - self.uid = uid - self.name = name - - self.init_lights() - self.mqttc.will_set(self.availability_topic, "offline", retain=True) - - @property - def device(self): - return { - "identifiers": [self.uid], - "name": self.name, - } - - @property - def availability_topic(self): - return f"{self.uid}/display/availability" - - def init_lights(self): - options = { - "device": self.device, - "availability_topic": self.availability_topic, - } - - self.main_light = Light( - "LED", self.uid, "led", self.sense, mqttc=self.mqttc, **options - ) - - def publish_discovery(self): - self.main_light.publish_discovery() - - def publish_online(self): - self.subscribe() - self.mqttc.publish(self.availability_topic, "online", retain=True) - - def subscribe(self): - self.main_light.subscribe() - - def on_message(self, *args, **kwargs): - self.main_light.on_message(*args, **kwargs) - - -class Light: - _colors = { - "Bleu": [0, 0, 255], - "Blanc": [255, 255, 255], - "Rouge": [255, 0, 0], - "Verte": [0, 255, 0], - "Jaune": [255, 255, 0], - } - - def __init__(self, name, parent_uid, slug, sense, mqttc, n=6, **kwargs): - self.name = name - self.parent_uid = parent_uid - self.slug = slug - self.sense = sense - self.mqttc = mqttc - self.options = kwargs - - self._switch = False - self._color = [[255, 255, 255]] * n - self._pres = [""] * n - self._values = [""] * n - self._n = n - self._i = 0 - self._toggled = False - - self.font = bdfparser.Font("src/tom-thumb.bdf") - self.timer = Timer(0, self.__init__) - - self.sense.stick.direction_right = self.switch_screen - self.sense.stick.direction_left = self.switch_screen_rev - self.sense.stick.direction_middle = self.toggle - - def publish_discovery(self): - for i in range(self._n): - self.mqttc.publish( - self.get_discovery_topic(i), - json.dumps( - { - "command_topic": self.command_topic(i, "command"), - "effect_command_topic": self.command_topic(i, "effect"), - "effect_list": ["Low Light", "Normal"], - "effect_state_topic": self.state_topic, - "effect_value_template": "{{ value_json.effect }}", - "icon": "mdi:dots-grid", - "name": f"{self.name} {i}", - "on_command_type": "brightness", - "rgb_command_topic": self.command_topic(i, "rgb"), - "rgb_state_topic": self.state_topic, - "rgb_value_template": "{{" + f"value_json.rgb[{i}]" + "}}", - "retain": True, - "unique_id": f"{self.uid}_{i}", - "state_topic": self.state_topic, - "state_value_template": "{{" + f"value_json.state[{i}]" + "}}", - } - | self.options - ), - retain=True, - ) - self.publish_state() - - def subscribe(self): - for i in range(self._n): - self.mqttc.subscribe(self.command_topic(i, "command")) - self.mqttc.subscribe(self.command_topic(i, "effect")) - self.mqttc.subscribe(self.command_topic(i, "rgb")) - self.mqttc.subscribe(self.command_topic(i, "value")) - self.mqttc.subscribe(self.command_topic(i, "action_color")) - - def on_message(self, client, userdata, message): - data = message.payload.decode() - - match message.topic.rsplit("/", maxsplit=2): - case [self.base_topic, i, "command"]: - self.switch = False - case [self.base_topic, i, "rgb"]: - self._i = int(i) - self.set_color(int(i), list(map(int, data.split(",")))) - case [self.base_topic, i, "effect"]: - self.low_light = data == "Low Light" - case [self.base_topic, i, "value"]: - self.set_value(int(i), data) - case [self.base_topic, i, "action_color"]: - self.set_color(int(i), self._colors.get(data, [0, 0, 0]), switch=False) - case _: - return - - def publish_state(self): - self.mqttc.publish( - self.state_topic, - json.dumps( - { - "effect": "Low Light" if self.low_light else "Normal", - "rgb": self.rgb, - "state": [ - self.state if i == self._i else "OFF" for i in range(self._n) - ], - } - ), - retain=True, - ) - - @property - def uid(self): - return f"{self.parent_uid}_{self.slug}" - - def get_discovery_topic(self, i): - return f"homeassistant/light/{self.uid}_{i}/config" - - @property - def base_topic(self): - return f"{self.parent_uid}/display/{self.slug}" - - def command_topic(self, i, cmd): - return f"{self.base_topic}/{i}/{cmd}" - - @property - def state_topic(self): - return f"{self.parent_uid}/display/{self.slug}/state" - - @property - def switch(self): - return self._switch - - @switch.setter - def switch(self, value): - self._switch = value - if value: - self.update_value() - else: - self.sense.clear() - self.publish_state() - - @property - def state(self): - return "ON" if self.switch else "OFF" - - @property - def color(self): - return self._color - - def set_color(self, i, value, switch=True): - self._color[i] = value - if switch and not self.switch: - self.switch = True - if switch: - self.update_value() - elif i == self._i: - self.display_value() - self.publish_state() - - @property - def rgb(self): - return [",".join(map(str, self.color[i])) for i in range(self._n)] - - @property - def value(self): - return f"{self._pres[self._i]} {self._values[self._i]}" - - @property - def low_light(self): - return self.sense.low_light - - @low_light.setter - def low_light(self, value): - self.sense.low_light = value - self.publish_state() - - def set_value(self, i, value): - match value.split(): - case [val]: - self._pres[i] = "" - self._values[i] = val - case [pre, val, *_]: - self._pres[i] = pre - self._values[i] = val - if i == self._i: - self.display_value() - self.publish_state() - - def update_value(self): - if not self.switch: - return - - if not self._pres[self._i]: - self.display_value() - return - - pixels = self.to_pixels(self._pres[self._i]) - self.sense.set_pixels(pixels) - - self.timer = Timer(0.5, self.display_value, kwargs=dict(timer=True)) - self.timer.start() - - def display_value(self, timer=False): - if (not timer and self.timer.is_alive()) or not self.switch: - return - self.timer.cancel() - - pixels = self.to_pixels(self._values[self._i]) - self.sense.set_pixels(pixels) - - def to_pixels(self, text): - if text: - return [ - self.color[self._i] if x else [0, 0, 0] - for x in self.font.draw(text).crop(8, 8).todata(3) - ] - - return [self.color[self._i]] * 64 - - def switch_screen(self, event): - if event.action == ACTION_RELEASED: - self._i = (self._i + 1) % self._n - self.switch = True - - def switch_screen_rev(self, event): - if event.action == ACTION_RELEASED: - self._i = (self._i - 1) % self._n - self.switch = True - - def toggle(self, event): - if event.action == ACTION_HELD: - if not self._toggled: - self.low_light = not self.low_light - self._toggled = True - if event.action == ACTION_RELEASED: - if self._toggled: - self._toggled = False - else: - self.switch = not self.switch diff --git a/oin_ha/sensors/__init__.py b/oin_ha/sensors/__init__.py deleted file mode 100644 index 1ca1595..0000000 --- a/oin_ha/sensors/__init__.py +++ /dev/null @@ -1,129 +0,0 @@ -import json - -from sense_hat import SenseHat - - -class Sensors: - def __init__(self, mqttc, uid, name): - self.mqttc = mqttc - self.sense = SenseHat() - self.uid = uid - self.name = name - - self.init_sensors() - self.mqttc.will_set(self.availability_topic, "offline", retain=True) - - @property - def device(self): - return { - "identifiers": [self.uid], - "name": self.name, - } - - @property - def availability_topic(self): - return f"{self.uid}/availability" - - @property - def state_topic(self): - return f"{self.uid}/state" - - def init_sensors(self): - options = { - "device": self.device, - "availability_topic": self.availability_topic, - "state_topic": self.state_topic, - "entity_category": "diagnostic", - } - - self.sensors = [ - TemperatureSensor( - "Température", - self.uid, - "temperature", - self.sense.get_temperature, - **options, - ), - HumiditySensor( - "Humidité", self.uid, "humidity", self.sense.get_humidity, **options - ), - PressureSensor( - "Pression", self.uid, "pressure", self.sense.get_pressure, **options - ), - ] - - def publish_discovery(self): - for sensor in self.sensors: - sensor.publish_discovery(self.mqttc) - - def publish_online(self): - self.mqttc.publish(self.availability_topic, "online", retain=True) - - def publish_state(self): - self.mqttc.publish( - self.state_topic, - json.dumps({sensor.slug: sensor.get_value() for sensor in self.sensors}), - ) - - -class SingleSensor: - def __init__(self, name, parent_uid, slug, get_value, **kwargs): - self.name = name - self.parent_uid = parent_uid - self.slug = slug - self.get_value = get_value - self.options = kwargs - - def publish_discovery(self, mqttc): - mqttc.publish( - self.discovery_topic, - json.dumps( - { - "name": self.name, - "state_class": "MEASUREMENT", - "unique_id": self.uid, - "value_template": self.value_template, - } - | self.options - ), - retain=True, - ) - - @property - def uid(self): - return f"{self.parent_uid}_{self.slug}" - - @property - def discovery_topic(self): - return f"homeassistant/sensor/{self.uid}/config" - - @property - def value_template(self): - return "{{" + f"value_json.{self.slug}" + "}}" - - -class TemperatureSensor(SingleSensor): - def __init__(self, name, parent_uid, slug, get_value, **kwargs): - super().__init__(name, parent_uid, slug, get_value, **kwargs) - self.options["device_class"] = "temperature" - self.options["icon"] = "mdi:thermometer" - self.options["suggested_display_precision"] = 1 - self.options["unit_of_measurement"] = "°C" - - -class HumiditySensor(SingleSensor): - def __init__(self, name, parent_uid, slug, get_value, **kwargs): - super().__init__(name, parent_uid, slug, get_value, **kwargs) - self.options["device_class"] = "humidity" - self.options["icon"] = "mdi:water-percent" - self.options["suggested_display_precision"] = 0 - self.options["unit_of_measurement"] = "%" - - -class PressureSensor(SingleSensor): - def __init__(self, name, parent_uid, slug, get_value, **kwargs): - super().__init__(name, parent_uid, slug, get_value, **kwargs) - self.options["device_class"] = "pressure" - self.options["icon"] = "mdi:gauge" - self.options["suggested_display_precision"] = 0 - self.options["unit_of_measurement"] = "mbar" diff --git a/oin_thermostat/__main__.py b/oin_thermostat/__main__.py new file mode 100644 index 0000000..853abdb --- /dev/null +++ b/oin_thermostat/__main__.py @@ -0,0 +1,15 @@ +import logging + +from .mqtt import HAClient + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + + client = HAClient( + "climate.chaudiere", + ["sensor.esptic_tempo", "sensor.rte_tempo_prochaine_couleur"], + ) + + client.connect() + + client.loop() diff --git a/oin_thermostat/mqtt.py b/oin_thermostat/mqtt.py new file mode 100644 index 0000000..7a71055 --- /dev/null +++ b/oin_thermostat/mqtt.py @@ -0,0 +1,118 @@ +import json +import logging + +import paho.mqtt.client as mqtt + +from .screen import Screen +from .select import Selector + +logger = logging.getLogger(__name__) + + +class HAClient: + def __init__(self, entity, secondary_entities=[]): + self.entity = entity + self.secondary_entities = secondary_entities + + self.state_topic = "oin/state" + self.availability_topic = "oin/availability" + + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + self.client.username_pw_set(username="oin", password="n+Bi58l7LxbH5nEJ") + + self.screen = Screen() + self.selector = Selector(self.send_data) + + @property + def ha_options(self): + return { + "dev": { + "ids": "oin", + "name": "Oin", + }, + "o": { + "name": "Oin", + }, + "availability_topic": self.availability_topic, + "state_topic": self.state_topic, + "cmps": self.selector.ha_options, + } + + def connect(self): + logger.debug("Connecting to HA...") + self.client.will_set(self.availability_topic, "offline", retain=True) + self.client.connect("homeassistant.local", 1883, 60) + + self.subscribe(entity_topic(self.entity), self.state_update) + for entity in self.secondary_entities: + self.subscribe(entity_topic(entity, "state"), self.secondary_state_update) + + self.publish("homeassistant/device/oin/config", self.ha_options, retain=True) + self.client.publish(self.availability_topic, "online", retain=True) + + def publish(self, topic, data, **kwargs): + logger.debug(f"Sending message on topic <{topic}>: {json.dumps(data)}") + self.client.publish(topic, json.dumps(data), **kwargs) + + def subscribe(self, topic, callback): + logger.debug(f"Subscribe to <{topic}>") + self.client.subscribe(topic) + self.client.message_callback_add(topic, callback) + + def unsubscribe(self, topic): + logger.debug(f"Unsubscribe from <{topic}>") + self.client.unsubscribe(topic) + + def loop(self): + logger.info("Starting MQTT client loop") + self.client.loop_forever() + + def state_update(self, client: mqtt.Client, userdata, message: mqtt.MQTTMessage): + logger.debug(f"Message received on topic <{message.topic}>: {message.payload}.") + + subtopic = message.topic.rsplit("/", maxsplit=1)[1] + + match subtopic: + case "current_temperature": + self.screen.value = parse(message) + case "temperature": + if (value := parse(message)) != self.selector.temperature: + self.screen.tmp_value = value + self.selector.temperature = value + case "hvac_action": + self.screen.mode = parse(message) + case "preset_modes": + if (value := parse(message)) != self.selector.preset_modes: + self.selector.preset_modes = value + case "preset_mode": + if (value := parse(message)) != self.selector.mode: + self.selector.mode = value + case "state": + match message.payload.decode(): + case "heat": + self.selector.switch = True + case "off": + self.selector.switch = False + + def secondary_state_update( + self, client: mqtt.Client, userdata, message: mqtt.MQTTMessage + ): + logger.debug(f"Message received on topic <{message.topic}>: {message.payload}.") + + _, grp, ent, subtopic = message.topic.split("/") + idx = self.secondary_entities.index(f"{grp}.{ent}") + + if subtopic == "state": + self.screen.secondary |= {idx: message.payload.decode()} + + def send_data(self, data): + self.publish(self.state_topic, data) + + +def parse(message): + return json.loads(message.payload.decode()) + + +def entity_topic(entity, subtopic="#"): + topic = entity.replace(".", "/") + return f"homeassistant/{topic}/{subtopic}" diff --git a/oin_thermostat/screen.py b/oin_thermostat/screen.py new file mode 100644 index 0000000..9a32a95 --- /dev/null +++ b/oin_thermostat/screen.py @@ -0,0 +1,140 @@ +import logging +import math +from threading import Timer + +import bdfparser +from sense_hat import SenseHat + +logger = logging.getLogger(__name__) + +COLORS = { + "Bleu": [0, 0, 255], + "Blanc": [0, 0, 0], + "Rouge": [255, 0, 0], + "Verte": [0, 127, 31], + "Jaune": [255, 255, 0], + "heat": [255, 0, 0], + "heating": [255, 0, 0], + "idle": [127, 0, 255], + "off": [127, 127, 127], + "on_setting": [255, 255, 0], + "off_setting": [255, 255, 255], + None: [0, 0, 0], +} + + +class Screen: + def __init__(self): + self.sense = SenseHat() + self._value = "" + self._tmp = False + self._tmp_value = None + self._mode = None + self.font = bdfparser.Font("src/tom-thumb.bdf") + self._secondary = dict() + self._secondary_pixels = [[0, 0, 0]] * 8 + + self.timer = Timer(0, self.set_pixels) + + self._held = False + self.sense.stick.direction_middle = self.stick_click + + @property + def value(self): + return self._value + + @value.setter + def value(self, value): + logger.debug(f"Updated value: <{value}>") + + self._value = format_value(value) + if not self._tmp: + self.set_pixels() + + @property + def color(self): + return COLORS.get(self.mode, [0, 0, 0]) + + @property + def mode(self): + return self._mode + + @mode.setter + def mode(self, value): + self._mode = value + if not self._tmp: + self.set_pixels() + + @property + def tmp_value(self): + return self._tmp_value + + @tmp_value.setter + def tmp_value(self, value): + logger.debug(f"Show value: <{value}>") + + self.timer.cancel() + self._tmp_value = format_value(value) + self.show_tmp() + + def show_tmp(self): + self._tmp = True + self.set_pixels( + self.tmp_value, + color=COLORS.get("off_setting" if self.mode == "off" else "on_setting"), + ) + self.timer = Timer(3, self.set_pixels) + self.timer.start() + + def set_pixels(self, value=None, color=None, bg_color=[0, 0, 0]): + if value is None: + value = self.value + self._tmp = False + if color is None: + color = self.color + + if value: + pixels = [ + color if x else bg_color + for x in self.font.draw(value, mode=0).crop(8, 7).todata(3) + ] + else: + pixels = 48 * [[0, 0, 0]] + pixels += self.secondary_pixels + + self.sense.set_pixels(pixels) + + @property + def secondary(self): + return self._secondary + + @property + def secondary_pixels(self): + return self._secondary_pixels + + @secondary.setter + def secondary(self, value): + self._secondary = value + + for idx in range(2): + self._secondary_pixels[4 * idx : 4 * (idx + 1)] = [ + COLORS.get(value.get(idx, None), [0, 0, 0]) + ] * 4 + + if not self._tmp: + self.set_pixels() + + def stick_click(self, event): + match (event.action, self._held): + case ("held", False): + self._held = True + case ("released", True): + self._held = False + case ("released", False): + self.show_tmp() + + +def format_value(value): + v = math.trunc(value) + d = "." if (value - v) >= 0.5 else "" + return f"{v}{d}" diff --git a/oin_thermostat/select.py b/oin_thermostat/select.py new file mode 100644 index 0000000..a6c1976 --- /dev/null +++ b/oin_thermostat/select.py @@ -0,0 +1,90 @@ +import logging +import math + +from sense_hat import ACTION_HELD, ACTION_RELEASED, SenseHat + +logger = logging.getLogger(__name__) + + +class Selector: + def __init__(self, send_data): + self.stick = SenseHat().stick + self.temperature = None + self.mode = None + self.switch = None + self.preset_modes = [] + self.send_data = send_data + self.switch_held = False + self.default_data = {"temperature": None, "mode": None, "switch": None} + + self.stick.direction_middle = self.toggle + + self.stick.direction_up = self.increase_temperature + self.stick.direction_down = self.decrease_temperature + + self.stick.direction_right = self.next_mode + self.stick.direction_left = self.prev_mode + + @property + def ha_options(self): + return { + "temperature": { + "p": "sensor", + "value_template": "{{ value_json.temperature }}", + "unique_id": "oin_temp", + "device_class": "temperature", + "entity_category": "diagnostic", + "icon": "mdi:thermostat", + "unit_of_measurement": "°C", + }, + "mode": { + "p": "sensor", + "name": "Mode", + "value_template": "{{ value_json.mode }}", + "unique_id": "oin_mode", + "entity_category": "diagnostic", + "icon": "mdi:thermostat-auto", + }, + "switch": { + "p": "sensor", + "name": "Switch", + "value_template": "{{ value_json.switch }}", + "unique_id": "oin_switch", + "entity_category": "diagnostic", + "icon": "mdi:thermostat-auto", + }, + } + + def increase_temperature(self, event): + if event.action != ACTION_RELEASED and self.temperature is not None: + self.callback(temperature=math.floor(self.temperature * 2) / 2 + 0.5) + + def decrease_temperature(self, event): + if event.action != ACTION_RELEASED and self.temperature is not None: + self.callback(temperature=math.ceil(self.temperature * 2) / 2 - 0.5) + + def next_mode(self, event): + if event.action != ACTION_RELEASED and self.mode is not None: + self.callback( + mode=self.preset_modes[ + (self.preset_modes.index(self.mode) + 1) % len(self.preset_modes) + ] + ) + + def prev_mode(self, event): + if event.action != ACTION_RELEASED and self.mode is not None: + self.callback( + mode=self.preset_modes[ + (self.preset_modes.index(self.mode) - 1) % len(self.preset_modes) + ] + ) + + def toggle(self, event): + if not self.switch_held and event.action == ACTION_HELD: + self.switch_held = True + self.callback(switch="off" if self.switch else "heat") + elif self.switch_held and event.action == ACTION_RELEASED: + self.switch_held = False + + def callback(self, **kwargs): + self.send_data(self.default_data | kwargs) diff --git a/oin_thermostat/src/tom-thumb.bdf b/oin_thermostat/src/tom-thumb.bdf new file mode 100644 index 0000000..e69de29 From 4d5824cfb52758cd9623a618e34f0f6ec5864a7c Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Sat, 7 Dec 2024 15:22:52 +0100 Subject: [PATCH 10/24] Remove duplicate --- oin_thermostat/src/tom-thumb.bdf | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 oin_thermostat/src/tom-thumb.bdf diff --git a/oin_thermostat/src/tom-thumb.bdf b/oin_thermostat/src/tom-thumb.bdf deleted file mode 100644 index e69de29..0000000 From a55e7c7bfe0767407b4dd9e5a0b685814f0b1a55 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Sun, 8 Dec 2024 10:43:31 +0100 Subject: [PATCH 11/24] Add config file ; add static types --- .pre-commit-config.yaml | 4 ++++ config.toml | 4 ++++ oin_thermostat/__main__.py | 5 +++++ oin_thermostat/mqtt.py | 19 ++++++++++++++----- 4 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 config.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4d8e2f2..0cb2f3c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,3 +15,7 @@ repos: args: ["--max-line-length=88", "--extend-ignore=E203"] exclude: "lyceedupaysdesoule/settings/|migrations" + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v1.13.0" + hooks: + - id: mypy diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..516ba25 --- /dev/null +++ b/config.toml @@ -0,0 +1,4 @@ +[mqtt] +username = "oin" +password = "n+Bi58l7LxbH5nEJ" +host = "homeassistant.local" diff --git a/oin_thermostat/__main__.py b/oin_thermostat/__main__.py index 853abdb..1e497d7 100644 --- a/oin_thermostat/__main__.py +++ b/oin_thermostat/__main__.py @@ -1,13 +1,18 @@ import logging +import tomllib from .mqtt import HAClient if __name__ == "__main__": + with open("config.toml", "rb") as config_file: + config = tomllib.load(config_file) + logging.basicConfig(level=logging.DEBUG) client = HAClient( "climate.chaudiere", ["sensor.esptic_tempo", "sensor.rte_tempo_prochaine_couleur"], + config.get("mqtt", dict()), ) client.connect() diff --git a/oin_thermostat/mqtt.py b/oin_thermostat/mqtt.py index 7a71055..abf34ff 100644 --- a/oin_thermostat/mqtt.py +++ b/oin_thermostat/mqtt.py @@ -10,21 +10,30 @@ logger = logging.getLogger(__name__) class HAClient: - def __init__(self, entity, secondary_entities=[]): + def __init__( + self, + entity: str, + secondary_entities: list[str] = [], + config: dict = dict(), + ) -> None: self.entity = entity self.secondary_entities = secondary_entities + self.config = config self.state_topic = "oin/state" self.availability_topic = "oin/availability" self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) - self.client.username_pw_set(username="oin", password="n+Bi58l7LxbH5nEJ") + self.client.username_pw_set( + username=self.config.get("username", None), + password=self.config.get("password", None), + ) self.screen = Screen() self.selector = Selector(self.send_data) @property - def ha_options(self): + def ha_options(self) -> dict[str, str | dict[str, str]]: return { "dev": { "ids": "oin", @@ -38,10 +47,10 @@ class HAClient: "cmps": self.selector.ha_options, } - def connect(self): + def connect(self) -> None: logger.debug("Connecting to HA...") self.client.will_set(self.availability_topic, "offline", retain=True) - self.client.connect("homeassistant.local", 1883, 60) + self.client.connect(self.config.get("host"), self.config.get("port", 1883)) self.subscribe(entity_topic(self.entity), self.state_update) for entity in self.secondary_entities: From 477cc99247425d68d634b0138add36889bdf9de3 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Sun, 8 Dec 2024 10:46:43 +0100 Subject: [PATCH 12/24] Fix issue with mypy : moved setter next to property --- oin_thermostat/screen.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/oin_thermostat/screen.py b/oin_thermostat/screen.py index 9a32a95..e28b143 100644 --- a/oin_thermostat/screen.py +++ b/oin_thermostat/screen.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) COLORS = { "Bleu": [0, 0, 255], - "Blanc": [0, 0, 0], + "Blanc": [255, 255, 255], "Rouge": [255, 0, 0], "Verte": [0, 127, 31], "Jaune": [255, 255, 0], @@ -108,10 +108,6 @@ class Screen: def secondary(self): return self._secondary - @property - def secondary_pixels(self): - return self._secondary_pixels - @secondary.setter def secondary(self, value): self._secondary = value @@ -124,6 +120,10 @@ class Screen: if not self._tmp: self.set_pixels() + @property + def secondary_pixels(self): + return self._secondary_pixels + def stick_click(self, event): match (event.action, self._held): case ("held", False): From 119feee5e5044c688722eb6535a10f968c658916 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Sun, 8 Dec 2024 11:17:25 +0100 Subject: [PATCH 13/24] Move main config to config.toml --- config.toml | 12 +++++++++++- oin_thermostat/__main__.py | 27 +++++++++++++++++++++------ oin_thermostat/mqtt.py | 15 ++++++++++----- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/config.toml b/config.toml index 516ba25..498e003 100644 --- a/config.toml +++ b/config.toml @@ -1,4 +1,14 @@ -[mqtt] +[logging] +level = "DEBUG" + +[homeassistant] +entity = "climate.chaudiere" +secondary_entities = [ + "sensor.esptic_tempo", + "sensor.rte_tempo_prochaine_couleur", +] + +[homeassistant.mqtt] username = "oin" password = "n+Bi58l7LxbH5nEJ" host = "homeassistant.local" diff --git a/oin_thermostat/__main__.py b/oin_thermostat/__main__.py index 1e497d7..b64e819 100644 --- a/oin_thermostat/__main__.py +++ b/oin_thermostat/__main__.py @@ -1,20 +1,35 @@ import logging +import sys import tomllib from .mqtt import HAClient -if __name__ == "__main__": - with open("config.toml", "rb") as config_file: +logger = logging.getLogger(__name__) +config_path = "config.toml" + + +def main(): + with open(config_path, "rb") as config_file: config = tomllib.load(config_file) - logging.basicConfig(level=logging.DEBUG) + logging.basicConfig(**config.get("logging", dict())) + + ha_config = config.get("homeassistant") + if ha_config is None: + logger.error(f"Missing home assistant config in <{config_path}>") + logger.error(f"\t{config}") + sys.exit(1) client = HAClient( - "climate.chaudiere", - ["sensor.esptic_tempo", "sensor.rte_tempo_prochaine_couleur"], - config.get("mqtt", dict()), + ha_config.get("entity"), + ha_config.get("secondary_entities"), + mqtt_config=ha_config.get("mqtt"), ) client.connect() client.loop() + + +if __name__ == "__main__": + main() diff --git a/oin_thermostat/mqtt.py b/oin_thermostat/mqtt.py index abf34ff..c20dc86 100644 --- a/oin_thermostat/mqtt.py +++ b/oin_thermostat/mqtt.py @@ -14,18 +14,20 @@ class HAClient: self, entity: str, secondary_entities: list[str] = [], - config: dict = dict(), + mqtt_config: dict = dict(), ) -> None: self.entity = entity self.secondary_entities = secondary_entities - self.config = config + self.config = mqtt_config self.state_topic = "oin/state" self.availability_topic = "oin/availability" self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + username = self.config.get("username", None) + logger.debug(f"Setting up MQTT with user <{username}>") self.client.username_pw_set( - username=self.config.get("username", None), + username=username, password=self.config.get("password", None), ) @@ -48,9 +50,12 @@ class HAClient: } def connect(self) -> None: - logger.debug("Connecting to HA...") self.client.will_set(self.availability_topic, "offline", retain=True) - self.client.connect(self.config.get("host"), self.config.get("port", 1883)) + + host = self.config.get("host") + port = self.config.get("port", 1883) + logger.debug(f"Connecting to <{host}> on port <{port}>") + self.client.connect(host, port) self.subscribe(entity_topic(self.entity), self.state_update) for entity in self.secondary_entities: From 9f0c6ba3dba2de123e57e4d15ac1e5ab75914cd9 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Sun, 8 Dec 2024 11:51:05 +0100 Subject: [PATCH 14/24] Typed mqtt.py --- oin_thermostat/mqtt.py | 63 +++++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/oin_thermostat/mqtt.py b/oin_thermostat/mqtt.py index c20dc86..934e002 100644 --- a/oin_thermostat/mqtt.py +++ b/oin_thermostat/mqtt.py @@ -1,5 +1,8 @@ import json import logging +import sys +from collections.abc import Callable +from typing import Any import paho.mqtt.client as mqtt @@ -14,7 +17,7 @@ class HAClient: self, entity: str, secondary_entities: list[str] = [], - mqtt_config: dict = dict(), + mqtt_config: dict[str, str] = dict(), ) -> None: self.entity = entity self.secondary_entities = secondary_entities @@ -54,34 +57,48 @@ class HAClient: host = self.config.get("host") port = self.config.get("port", 1883) - logger.debug(f"Connecting to <{host}> on port <{port}>") + logger.debug(f"Connecting to <{host}> on port <{port}>.") self.client.connect(host, port) self.subscribe(entity_topic(self.entity), self.state_update) - for entity in self.secondary_entities: - self.subscribe(entity_topic(entity, "state"), self.secondary_state_update) + self.subscribe( + [entity_topic(entity) for entity in self.secondary_entities], + self.secondary_state_update, + ) self.publish("homeassistant/device/oin/config", self.ha_options, retain=True) self.client.publish(self.availability_topic, "online", retain=True) - def publish(self, topic, data, **kwargs): + def publish(self, topic: str, data: Any, **kwargs) -> mqtt.MQTTMessageInfo: logger.debug(f"Sending message on topic <{topic}>: {json.dumps(data)}") - self.client.publish(topic, json.dumps(data), **kwargs) + return self.client.publish(topic, json.dumps(data), **kwargs) - def subscribe(self, topic, callback): - logger.debug(f"Subscribe to <{topic}>") - self.client.subscribe(topic) - self.client.message_callback_add(topic, callback) + def subscribe(self, topic: str | list[str], callback: Callable) -> None: + logger.debug(f"Subscribing to <{topic}>.") - def unsubscribe(self, topic): - logger.debug(f"Unsubscribe from <{topic}>") - self.client.unsubscribe(topic) + match topic: + case str(): + self.client.message_callback_add(topic, callback) + code, _ = self.client.subscribe(topic) + case list(): + for top in topic: + self.client.message_callback_add(top, callback) + code, _ = self.client.subscribe([(top, 0) for top in topic]) - def loop(self): - logger.info("Starting MQTT client loop") - self.client.loop_forever() + if code != 0: + logger.error(f"Failed subscribing to topic <{topic}> with code <{code}>.") + sys.exit(1) - def state_update(self, client: mqtt.Client, userdata, message: mqtt.MQTTMessage): + def loop(self) -> mqtt.MQTTErrorCode: + logger.info("Starting MQTT client loop.") + code = self.client.loop_forever(retry_first_connection=True) + + if code != 0: + logger.error("MQTT client loop failed with code <{code}>.") + + def state_update( + self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage + ) -> None: logger.debug(f"Message received on topic <{message.topic}>: {message.payload}.") subtopic = message.topic.rsplit("/", maxsplit=1)[1] @@ -109,8 +126,8 @@ class HAClient: self.selector.switch = False def secondary_state_update( - self, client: mqtt.Client, userdata, message: mqtt.MQTTMessage - ): + self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage + ) -> None: logger.debug(f"Message received on topic <{message.topic}>: {message.payload}.") _, grp, ent, subtopic = message.topic.split("/") @@ -119,14 +136,14 @@ class HAClient: if subtopic == "state": self.screen.secondary |= {idx: message.payload.decode()} - def send_data(self, data): - self.publish(self.state_topic, data) + def send_data(self, data: Any) -> mqtt.MQTTMessageInfo: + return self.publish(self.state_topic, data) -def parse(message): +def parse(message: mqtt.MQTTMessage) -> Any: return json.loads(message.payload.decode()) -def entity_topic(entity, subtopic="#"): +def entity_topic(entity: str, subtopic: str = "#") -> str: topic = entity.replace(".", "/") return f"homeassistant/{topic}/{subtopic}" From 7b26d3a1606af1e078145c0aecaa5f170d403064 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 9 Dec 2024 12:16:26 +0100 Subject: [PATCH 15/24] Enable mypy strict compliance --- .gitignore | 1 + .pre-commit-config.yaml | 1 - mypy.ini | 4 ++ oin_thermostat/__main__.py | 16 +++++-- oin_thermostat/mqtt.py | 97 ++++++++++++++++++++++++++------------ oin_thermostat/screen.py | 75 +++++++++++++++-------------- oin_thermostat/select.py | 47 ++++++++++-------- 7 files changed, 151 insertions(+), 90 deletions(-) create mode 100644 mypy.ini diff --git a/.gitignore b/.gitignore index e73ce05..1dab207 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__ /env +/out diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0cb2f3c..5f7ffe1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,6 @@ repos: hooks: - id: flake8 args: ["--max-line-length=88", "--extend-ignore=E203"] - exclude: "lyceedupaysdesoule/settings/|migrations" - repo: https://github.com/pre-commit/mirrors-mypy rev: "v1.13.0" diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..8a26835 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,4 @@ +[mypy] +python_executable = ./env/bin/python +strict = True +pretty = True diff --git a/oin_thermostat/__main__.py b/oin_thermostat/__main__.py index b64e819..4e8e737 100644 --- a/oin_thermostat/__main__.py +++ b/oin_thermostat/__main__.py @@ -8,7 +8,7 @@ logger = logging.getLogger(__name__) config_path = "config.toml" -def main(): +def main() -> int: with open(config_path, "rb") as config_file: config = tomllib.load(config_file) @@ -18,7 +18,7 @@ def main(): if ha_config is None: logger.error(f"Missing home assistant config in <{config_path}>") logger.error(f"\t{config}") - sys.exit(1) + return 1 client = HAClient( ha_config.get("entity"), @@ -26,10 +26,16 @@ def main(): mqtt_config=ha_config.get("mqtt"), ) - client.connect() + code = client.connect() + if code != 0: + return 1 - client.loop() + code = client.loop() + if code != 0: + return 1 + + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/oin_thermostat/mqtt.py b/oin_thermostat/mqtt.py index 934e002..b5fa0e5 100644 --- a/oin_thermostat/mqtt.py +++ b/oin_thermostat/mqtt.py @@ -1,10 +1,10 @@ import json import logging -import sys from collections.abc import Callable from typing import Any import paho.mqtt.client as mqtt +from paho.mqtt.enums import CallbackAPIVersion, MQTTErrorCode from .screen import Screen from .select import Selector @@ -26,7 +26,7 @@ class HAClient: self.state_topic = "oin/state" self.availability_topic = "oin/availability" - self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + self.client = mqtt.Client(CallbackAPIVersion.VERSION2) username = self.config.get("username", None) logger.debug(f"Setting up MQTT with user <{username}>") self.client.username_pw_set( @@ -52,28 +52,68 @@ class HAClient: "cmps": self.selector.ha_options, } - def connect(self) -> None: + def connect(self) -> int: self.client.will_set(self.availability_topic, "offline", retain=True) host = self.config.get("host") port = self.config.get("port", 1883) - logger.debug(f"Connecting to <{host}> on port <{port}>.") - self.client.connect(host, port) - self.subscribe(entity_topic(self.entity), self.state_update) - self.subscribe( + if host is None: + logger.error("Host not found in config.") + logger.error(f"\t{self.config}") + return 1 + if not isinstance(port, int): + logger.warning(f"Invalid port config : <{port}> ; using port 1883.") + port = 1883 + + logger.debug(f"Connecting to <{host}> on port <{port}>.") + code = self.client.connect(host, port) + if code != 0: + logger.error(f"Could not connect to host <{host}> on port <{port}>.") + return 1 + + code = self.subscribe(entity_topic(self.entity), self.state_update) + if code != 0: + return 1 + code = self.subscribe( [entity_topic(entity) for entity in self.secondary_entities], self.secondary_state_update, ) + if code != 0: + return 1 - self.publish("homeassistant/device/oin/config", self.ha_options, retain=True) - self.client.publish(self.availability_topic, "online", retain=True) + m_info = self.publish_json( + "homeassistant/device/oin/config", self.ha_options, retain=True + ) + m_info.wait_for_publish(60) + if not m_info.is_published(): + logger.error("Config message timed out") + return 1 - def publish(self, topic: str, data: Any, **kwargs) -> mqtt.MQTTMessageInfo: - logger.debug(f"Sending message on topic <{topic}>: {json.dumps(data)}") - return self.client.publish(topic, json.dumps(data), **kwargs) + m_info = self.publish(self.availability_topic, "online", retain=True) + m_info.wait_for_publish(60) + if not m_info.is_published(): + logger.error("Availability message timed out") + return 1 - def subscribe(self, topic: str | list[str], callback: Callable) -> None: + return 0 + + def publish( + self, topic: str, data: str, retain: bool = False + ) -> mqtt.MQTTMessageInfo: + logger.debug(f"Sending message on topic <{topic}>.") + return self.client.publish(topic, data, retain=retain) + + def publish_json( + self, topic: str, data: Any, retain: bool = False + ) -> mqtt.MQTTMessageInfo: + return self.publish(topic, json.dumps(data), retain) + + def subscribe( + self, + topic: str | list[str], + callback: Callable[[mqtt.Client, Any, mqtt.MQTTMessage], None], + ) -> MQTTErrorCode: logger.debug(f"Subscribing to <{topic}>.") match topic: @@ -87,39 +127,41 @@ class HAClient: if code != 0: logger.error(f"Failed subscribing to topic <{topic}> with code <{code}>.") - sys.exit(1) + return code - def loop(self) -> mqtt.MQTTErrorCode: + def loop(self) -> MQTTErrorCode: logger.info("Starting MQTT client loop.") code = self.client.loop_forever(retry_first_connection=True) if code != 0: logger.error("MQTT client loop failed with code <{code}>.") + return code def state_update( self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage ) -> None: - logger.debug(f"Message received on topic <{message.topic}>: {message.payload}.") + data = message.payload.decode() + logger.debug(f"Message received on topic <{message.topic}>: {data}.") subtopic = message.topic.rsplit("/", maxsplit=1)[1] match subtopic: case "current_temperature": - self.screen.value = parse(message) + self.screen.value = json.loads(data) case "temperature": - if (value := parse(message)) != self.selector.temperature: + if (value := json.loads(data)) != self.selector.temperature: self.screen.tmp_value = value self.selector.temperature = value case "hvac_action": - self.screen.mode = parse(message) + self.screen.mode = json.loads(data) case "preset_modes": - if (value := parse(message)) != self.selector.preset_modes: + if (value := json.loads(data)) != self.selector.preset_modes: self.selector.preset_modes = value case "preset_mode": - if (value := parse(message)) != self.selector.mode: + if (value := json.loads(data)) != self.selector.mode: self.selector.mode = value case "state": - match message.payload.decode(): + match data: case "heat": self.selector.switch = True case "off": @@ -128,20 +170,17 @@ class HAClient: def secondary_state_update( self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage ) -> None: - logger.debug(f"Message received on topic <{message.topic}>: {message.payload}.") + data = message.payload.decode() + logger.debug(f"Message received on topic <{message.topic}>: {data}.") _, grp, ent, subtopic = message.topic.split("/") idx = self.secondary_entities.index(f"{grp}.{ent}") if subtopic == "state": - self.screen.secondary |= {idx: message.payload.decode()} + self.screen.secondary |= {idx: data} def send_data(self, data: Any) -> mqtt.MQTTMessageInfo: - return self.publish(self.state_topic, data) - - -def parse(message: mqtt.MQTTMessage) -> Any: - return json.loads(message.payload.decode()) + return self.publish_json(self.state_topic, data) def entity_topic(entity: str, subtopic: str = "#") -> str: diff --git a/oin_thermostat/screen.py b/oin_thermostat/screen.py index e28b143..a9bf6ee 100644 --- a/oin_thermostat/screen.py +++ b/oin_thermostat/screen.py @@ -3,36 +3,36 @@ import math from threading import Timer import bdfparser -from sense_hat import SenseHat +from sense_hat import InputEvent, SenseHat logger = logging.getLogger(__name__) COLORS = { - "Bleu": [0, 0, 255], - "Blanc": [255, 255, 255], - "Rouge": [255, 0, 0], - "Verte": [0, 127, 31], - "Jaune": [255, 255, 0], - "heat": [255, 0, 0], - "heating": [255, 0, 0], - "idle": [127, 0, 255], - "off": [127, 127, 127], - "on_setting": [255, 255, 0], - "off_setting": [255, 255, 255], - None: [0, 0, 0], + "Bleu": (0, 0, 255), + "Blanc": (255, 255, 255), + "Rouge": (255, 0, 0), + "Verte": (0, 127, 31), + "Jaune": (255, 255, 0), + "heat": (255, 0, 0), + "heating": (255, 0, 0), + "idle": (127, 0, 255), + "off": (127, 127, 127), + "on_setting": (255, 255, 0), + "off_setting": (255, 255, 255), + None: (0, 0, 0), } class Screen: - def __init__(self): + def __init__(self) -> None: self.sense = SenseHat() self._value = "" self._tmp = False - self._tmp_value = None - self._mode = None + self._tmp_value = "" + self._mode = "" self.font = bdfparser.Font("src/tom-thumb.bdf") - self._secondary = dict() - self._secondary_pixels = [[0, 0, 0]] * 8 + self._secondary: dict[int, str] = dict() + self._secondary_pixels: list[tuple[int, int, int]] = [(0, 0, 0)] * 8 self.timer = Timer(0, self.set_pixels) @@ -40,11 +40,11 @@ class Screen: self.sense.stick.direction_middle = self.stick_click @property - def value(self): + def value(self) -> None | str: return self._value @value.setter - def value(self, value): + def value(self, value: float) -> None: logger.debug(f"Updated value: <{value}>") self._value = format_value(value) @@ -52,32 +52,32 @@ class Screen: self.set_pixels() @property - def color(self): - return COLORS.get(self.mode, [0, 0, 0]) + def color(self) -> tuple[int, int, int]: + return COLORS.get(self.mode, (0, 0, 0)) @property - def mode(self): + def mode(self) -> str: return self._mode @mode.setter - def mode(self, value): + def mode(self, value: str) -> None: self._mode = value if not self._tmp: self.set_pixels() @property - def tmp_value(self): + def tmp_value(self) -> None | str: return self._tmp_value @tmp_value.setter - def tmp_value(self, value): + def tmp_value(self, value: float) -> None: logger.debug(f"Show value: <{value}>") self.timer.cancel() self._tmp_value = format_value(value) self.show_tmp() - def show_tmp(self): + def show_tmp(self) -> None: self._tmp = True self.set_pixels( self.tmp_value, @@ -86,7 +86,12 @@ class Screen: self.timer = Timer(3, self.set_pixels) self.timer.start() - def set_pixels(self, value=None, color=None, bg_color=[0, 0, 0]): + def set_pixels( + self, + value: str | None = None, + color: tuple[int, int, int] | None = None, + bg_color: tuple[int, int, int] = (0, 0, 0), + ) -> None: if value is None: value = self.value self._tmp = False @@ -99,32 +104,32 @@ class Screen: for x in self.font.draw(value, mode=0).crop(8, 7).todata(3) ] else: - pixels = 48 * [[0, 0, 0]] + pixels = 48 * [(0, 0, 0)] pixels += self.secondary_pixels self.sense.set_pixels(pixels) @property - def secondary(self): + def secondary(self) -> dict[int, str]: return self._secondary @secondary.setter - def secondary(self, value): + def secondary(self, value: dict[int, str]) -> None: self._secondary = value for idx in range(2): self._secondary_pixels[4 * idx : 4 * (idx + 1)] = [ - COLORS.get(value.get(idx, None), [0, 0, 0]) + COLORS.get(value.get(idx, None), (0, 0, 0)) ] * 4 if not self._tmp: self.set_pixels() @property - def secondary_pixels(self): + def secondary_pixels(self) -> list[tuple[int, int, int]]: return self._secondary_pixels - def stick_click(self, event): + def stick_click(self, event: InputEvent) -> None: match (event.action, self._held): case ("held", False): self._held = True @@ -134,7 +139,7 @@ class Screen: self.show_tmp() -def format_value(value): +def format_value(value: float) -> str: v = math.trunc(value) d = "." if (value - v) >= 0.5 else "" return f"{v}{d}" diff --git a/oin_thermostat/select.py b/oin_thermostat/select.py index a6c1976..f8ca95f 100644 --- a/oin_thermostat/select.py +++ b/oin_thermostat/select.py @@ -1,18 +1,19 @@ import logging import math +from collections.abc import Callable -from sense_hat import ACTION_HELD, ACTION_RELEASED, SenseHat +from sense_hat import ACTION_HELD, ACTION_RELEASED, InputEvent, SenseHat logger = logging.getLogger(__name__) class Selector: - def __init__(self, send_data): + def __init__(self, send_data: Callable[[dict[str, float | str | None]], None]): self.stick = SenseHat().stick self.temperature = None self.mode = None self.switch = None - self.preset_modes = [] + self.preset_modes: list[str] = [] self.send_data = send_data self.switch_held = False self.default_data = {"temperature": None, "mode": None, "switch": None} @@ -26,7 +27,7 @@ class Selector: self.stick.direction_left = self.prev_mode @property - def ha_options(self): + def ha_options(self) -> dict[str, dict[str, str]]: return { "temperature": { "p": "sensor", @@ -55,36 +56,42 @@ class Selector: }, } - def increase_temperature(self, event): + def increase_temperature(self, event: InputEvent) -> None: if event.action != ACTION_RELEASED and self.temperature is not None: - self.callback(temperature=math.floor(self.temperature * 2) / 2 + 0.5) + self.callback({"temperature": math.floor(self.temperature * 2) / 2 + 0.5}) - def decrease_temperature(self, event): + def decrease_temperature(self, event: InputEvent) -> None: if event.action != ACTION_RELEASED and self.temperature is not None: - self.callback(temperature=math.ceil(self.temperature * 2) / 2 - 0.5) + self.callback({"temperature": math.ceil(self.temperature * 2) / 2 - 0.5}) - def next_mode(self, event): + def next_mode(self, event: InputEvent) -> None: if event.action != ACTION_RELEASED and self.mode is not None: self.callback( - mode=self.preset_modes[ - (self.preset_modes.index(self.mode) + 1) % len(self.preset_modes) - ] + { + "mode": self.preset_modes[ + (self.preset_modes.index(self.mode) + 1) + % len(self.preset_modes) + ] + } ) - def prev_mode(self, event): + def prev_mode(self, event: InputEvent) -> None: if event.action != ACTION_RELEASED and self.mode is not None: self.callback( - mode=self.preset_modes[ - (self.preset_modes.index(self.mode) - 1) % len(self.preset_modes) - ] + { + "mode": self.preset_modes[ + (self.preset_modes.index(self.mode) - 1) + % len(self.preset_modes) + ] + } ) - def toggle(self, event): + def toggle(self, event: InputEvent) -> None: if not self.switch_held and event.action == ACTION_HELD: self.switch_held = True - self.callback(switch="off" if self.switch else "heat") + self.callback({"switch": "off" if self.switch else "heat"}) elif self.switch_held and event.action == ACTION_RELEASED: self.switch_held = False - def callback(self, **kwargs): - self.send_data(self.default_data | kwargs) + def callback(self, data: dict[str, float | str | None]) -> None: + self.send_data(self.default_data | data) From cc0c978f0c7e419e51b9e616073c6b86d97d3ae9 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 9 Dec 2024 13:25:00 +0100 Subject: [PATCH 16/24] Switch from mypy to pyright --- .pre-commit-config.yaml | 5 - oin_thermostat/mqtt.py | 6 +- oin_thermostat/screen.py | 13 +- oin_thermostat/select.py | 14 +- pyrightconfig.json | 5 + typings/bdfparser/__init__.pyi | 12 ++ typings/bdfparser/bdfparser.pyi | 345 +++++++++++++++++++++++++++++++ typings/sense_hat/__init__.pyi | 19 ++ typings/sense_hat/colour.pyi | 222 ++++++++++++++++++++ typings/sense_hat/exceptions.pyi | 20 ++ typings/sense_hat/sense_hat.pyi | 224 ++++++++++++++++++++ typings/sense_hat/stick.pyi | 132 ++++++++++++ 12 files changed, 1000 insertions(+), 17 deletions(-) create mode 100644 pyrightconfig.json create mode 100644 typings/bdfparser/__init__.pyi create mode 100644 typings/bdfparser/bdfparser.pyi create mode 100644 typings/sense_hat/__init__.pyi create mode 100644 typings/sense_hat/colour.pyi create mode 100644 typings/sense_hat/exceptions.pyi create mode 100644 typings/sense_hat/sense_hat.pyi create mode 100644 typings/sense_hat/stick.pyi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5f7ffe1..7f51c04 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,8 +13,3 @@ repos: hooks: - id: flake8 args: ["--max-line-length=88", "--extend-ignore=E203"] - - - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.13.0" - hooks: - - id: mypy diff --git a/oin_thermostat/mqtt.py b/oin_thermostat/mqtt.py index b5fa0e5..e6279ac 100644 --- a/oin_thermostat/mqtt.py +++ b/oin_thermostat/mqtt.py @@ -38,7 +38,7 @@ class HAClient: self.selector = Selector(self.send_data) @property - def ha_options(self) -> dict[str, str | dict[str, str]]: + def ha_options(self) -> dict[str, Any]: return { "dev": { "ids": "oin", @@ -166,6 +166,10 @@ class HAClient: self.selector.switch = True case "off": self.selector.switch = False + case other: + logger.warning(f"Unknown state received: <{other}>.") + case _: + pass def secondary_state_update( self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage diff --git a/oin_thermostat/screen.py b/oin_thermostat/screen.py index a9bf6ee..256cb64 100644 --- a/oin_thermostat/screen.py +++ b/oin_thermostat/screen.py @@ -3,7 +3,8 @@ import math from threading import Timer import bdfparser -from sense_hat import InputEvent, SenseHat +from sense_hat.sense_hat import SenseHat +from sense_hat.stick import InputEvent logger = logging.getLogger(__name__) @@ -99,10 +100,7 @@ class Screen: color = self.color if value: - pixels = [ - color if x else bg_color - for x in self.font.draw(value, mode=0).crop(8, 7).todata(3) - ] + pixels = [color if x else bg_color for x in self.data_from_value(value)] else: pixels = 48 * [(0, 0, 0)] pixels += self.secondary_pixels @@ -137,6 +135,11 @@ class Screen: self._held = False case ("released", False): self.show_tmp() + case _: + pass + + def data_from_value(self, value: str) -> list[int]: + return self.font.draw(value, mode=0).crop(8, 7).todata(3) def format_value(value: float) -> str: diff --git a/oin_thermostat/select.py b/oin_thermostat/select.py index f8ca95f..d25a195 100644 --- a/oin_thermostat/select.py +++ b/oin_thermostat/select.py @@ -1,18 +1,20 @@ import logging import math from collections.abc import Callable +from typing import Any -from sense_hat import ACTION_HELD, ACTION_RELEASED, InputEvent, SenseHat +from sense_hat.sense_hat import SenseHat +from sense_hat.stick import ACTION_HELD, ACTION_RELEASED, InputEvent logger = logging.getLogger(__name__) class Selector: - def __init__(self, send_data: Callable[[dict[str, float | str | None]], None]): + def __init__(self, send_data: Callable[[dict[str, Any]], Any]): self.stick = SenseHat().stick - self.temperature = None - self.mode = None - self.switch = None + self.temperature: float | None = None + self.mode: str | None = None + self.switch: bool | None = None self.preset_modes: list[str] = [] self.send_data = send_data self.switch_held = False @@ -93,5 +95,5 @@ class Selector: elif self.switch_held and event.action == ACTION_RELEASED: self.switch_held = False - def callback(self, data: dict[str, float | str | None]) -> None: + def callback(self, data: dict[str, Any]) -> None: self.send_data(self.default_data | data) diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..6266d5e --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,5 @@ +{ + "include": ["oin_thermostat"], + "strict": ["oin_thermostat"], + "venvPath": "env" +} diff --git a/typings/bdfparser/__init__.pyi b/typings/bdfparser/__init__.pyi new file mode 100644 index 0000000..d4ea928 --- /dev/null +++ b/typings/bdfparser/__init__.pyi @@ -0,0 +1,12 @@ +""" +This type stub file was generated by pyright. +""" + +from .bdfparser import * + +""" +BDF (Glyph Bitmap Distribution Format) Bitmap Font File Parser in Python +Copyright (c) 2017-2021 Tom CHEN (tomchen.org), MIT License +https://font.tomchen.org/bdfparser_py/ +""" +__version__ = ... diff --git a/typings/bdfparser/bdfparser.pyi b/typings/bdfparser/bdfparser.pyi new file mode 100644 index 0000000..06f2233 --- /dev/null +++ b/typings/bdfparser/bdfparser.pyi @@ -0,0 +1,345 @@ +""" +This type stub file was generated by pyright. +""" +from typing import Any + +def format_warning(message, category, filename, lineno, file=..., line=...): ... + +class Font: + """ + `Font` object + + https://font.tomchen.org/bdfparser_py/font + """ + + __PATTERN_VVECTOR_DELIMITER = ... + __META_TITLES = ... + __EMPTY_GLYPH = ... + def __init__(self, *argv) -> None: + """ + Initialize a `Font` object. Load the BDF font file if a file path string or a file object is present. + + https://font.tomchen.org/bdfparser_py/font#font + """ + ... + def load_file_path(self, file_path): # -> Self: + """ + Load the BDF font file in the file path. + + https://font.tomchen.org/bdfparser_py/font#load_file_path + """ + ... + def load_file_obj(self, file_obj): # -> Self: + """ + Load the BDF font file object. + + https://font.tomchen.org/bdfparser_py/font#load_file_obj + """ + ... + def length(self): # -> int: + """ + Returns how many glyphs actually exist in the font. + + https://font.tomchen.org/bdfparser_py/font#length + """ + ... + def __len__(self): # -> int: + """ + Same as `.length()` + """ + ... + def itercps(self, order=..., r=...): # -> filter[Any] | Iterator[Any]: + """ + Almost identical to `.iterglyphs()`, except it returns an `iterator` of glyph codepoints instead of an `iterator` of `Glyph` objects. + + https://font.tomchen.org/bdfparser_py/font#itercps + """ + ... + def iterglyphs(self, order=..., r=...): # -> Generator[Glyph | None, Any, None]: + """ + Returns an iterator of all the glyphs (as `Glyph` objects) in the font (default) or in the specified codepoint range in the font, sorted by the specified order (or by the ascending codepoint order by default). + + https://font.tomchen.org/bdfparser_py/font#iterglyphs + """ + ... + def glyphbycp(self, codepoint): # -> Glyph | None: + """ + Get a glyph (as Glyph object) by its codepoint. + + https://font.tomchen.org/bdfparser_py/font#glyphbycp + """ + ... + def glyph(self, character): # -> Glyph | None: + """ + Get a glyph (as `Glyph` object) by its character. + + https://font.tomchen.org/bdfparser_py/font#glyph + """ + ... + def lacksglyphs(self, string): # -> list[Any] | None: + """ + Check if there is any missing glyph and gets these glyphs' character. + + https://font.tomchen.org/bdfparser_py/font#lacksglyphs + """ + ... + def drawcps( + self, + cps, + linelimit=..., + mode=..., + direction=..., + usecurrentglyphspacing=..., + missing=..., + ): + """ + Draw the glyphs of the specified codepoints, to a `Bitmap` object. + + https://font.tomchen.org/bdfparser_py/font#drawcps + """ + ... + def draw( + self, + string: str, + linelimit: int = ..., + mode: int = ..., + direction: str = ..., + usecurrentglyphspacing: bool = ..., + missing: dict[str, Any] | Glyph = ..., + ) -> Bitmap: + """ + Draw (render) the glyphs of the specified words / setences / paragraphs (as a `str`), to a `Bitmap` object. + + https://font.tomchen.org/bdfparser_py/font#draw + """ + ... + def drawall( + self, + order=..., + r=..., + linelimit=..., + mode=..., + direction=..., + usecurrentglyphspacing=..., + ): + """ + Draw all the glyphs in the font (default) or in the specified codepoint range in the font, sorted by the specified order (or by the ascending codepoint order by default), to a `Bitmap` object. + + https://font.tomchen.org/bdfparser_py/font#drawall + """ + ... + +class Glyph: + """ + `Glyph` object + + https://font.tomchen.org/bdfparser_py/glyph + """ + + def __init__(self, meta_dict, font) -> None: + """ + Initialize a `Glyph` object. Load a `dict` of meta information and the font the glyph belongs. + + https://font.tomchen.org/bdfparser_py/glyph#glyph + """ + ... + def __str__(self) -> str: + """ + Gets a human-readable (multi-line) `str` representation of the `Glyph` object. + + https://font.tomchen.org/bdfparser_py/glyph#str-and-print + """ + ... + def __repr__(self): # -> str: + """ + Gets a programmer-readable `str` representation of the `Glyph` object. + + https://font.tomchen.org/bdfparser_py/glyph#repr + """ + ... + def cp(self): + """ + Get the codepoint of the glyph. + + https://font.tomchen.org/bdfparser_py/glyph#cp + """ + ... + def chr(self): # -> str: + """ + Get the character of the glyph. + + https://font.tomchen.org/bdfparser_py/glyph#chr + """ + ... + def draw(self, mode=..., bb=...): # -> Bitmap: + """ + Draw the glyph to a `Bitmap` object. + + https://font.tomchen.org/bdfparser_py/glyph#draw + """ + ... + def origin( + self, mode=..., fromorigin=..., xoff=..., yoff=... + ): # -> tuple[Any, Any]: + """ + Get the relative position (displacement) of the origin from the left bottom corner of the bitmap drawn by the method `.draw()`, or vice versa. + + https://font.tomchen.org/bdfparser_py/glyph#origin + """ + ... + +class Bitmap: + """ + `Bitmap` object + + https://font.tomchen.org/bdfparser_py/bitmap + """ + + def __init__(self, bin_bitmap_list) -> None: + """ + Initialize a `Bitmap` object. Load binary bitmap data (`list` of `str`s). + + https://font.tomchen.org/bdfparser_py/bitmap#bitmap + """ + ... + def __str__(self) -> str: + """ + Gets a human-readable (multi-line) `str` representation of the `Bitmap` object. + + https://font.tomchen.org/bdfparser_py/bitmap#str-and-print + """ + ... + def __repr__(self): # -> str: + """ + Gets a programmer-readable (multi-line) `str` representation of the `Bitmap` object. + + https://font.tomchen.org/bdfparser_py/bitmap#repr + """ + ... + def width(self): # -> int: + """ + Get the width of the bitmap. + + https://font.tomchen.org/bdfparser_py/bitmap#width + """ + ... + def height(self): # -> int: + """ + Get the height of the bitmap. + + https://font.tomchen.org/bdfparser_py/bitmap#height + """ + ... + def clone(self): # -> Self: + """ + Get a deep copy / clone of the `Bitmap` object. + + https://font.tomchen.org/bdfparser_py/bitmap#clone + """ + ... + def crop( + self, w: int, h: int, xoff: int = ..., yoff: int = ... + ) -> Bitmap: # -> Self: + """ + Crop and/or extend the bitmap. + + https://font.tomchen.org/bdfparser_py/bitmap#crop + """ + ... + def overlay(self, bitmap): # -> Self: + """ + Overlay another bitmap over the current one. + + https://font.tomchen.org/bdfparser_py/bitmap#overlay + """ + ... + @classmethod + def concatall( + cls, bitmaplist, direction=..., align=..., offsetlist=... + ): # -> Self: + """ + Concatenate all `Bitmap` objects in a `list`. + + https://font.tomchen.org/bdfparser_py/bitmap#bitmapconcatall + """ + ... + def __add__(self, bitmap): # -> Self: + """ + `+` is a shortcut of `Bitmap.concatall()`. Use `+` to concatenate two `Bitmap` objects and get a new `Bitmap` objects. + + https://font.tomchen.org/bdfparser_py/bitmap#-concat + """ + ... + def concat(self, bitmap, direction=..., align=..., offset=...): # -> Self: + """ + Concatenate another `Bitmap` objects to the current one. + + https://font.tomchen.org/bdfparser_py/bitmap#concat + """ + ... + def enlarge(self, x=..., y=...): # -> Self: + """ + Enlarge a `Bitmap` object, by multiplying every pixel in x (right) direction and in y (top) direction. + + https://font.tomchen.org/bdfparser_py/bitmap#enlarge + """ + ... + def __mul__(self, mul): # -> Self: + """ + `*` is a shortcut of `.enlarge()`. + + https://font.tomchen.org/bdfparser_py/bitmap#-enlarge + """ + ... + def replace(self, substr, newsubstr): # -> Self: + """ + Replace a string by another in the bitmap. + + https://font.tomchen.org/bdfparser_py/bitmap#replace + """ + ... + def shadow(self, xoff=..., yoff=...): # -> Self: + """ + Add shadow to the shape in the bitmap. + + The shadow will be filled by `'2'`s. + + https://font.tomchen.org/bdfparser_py/bitmap#shadow + """ + ... + def glow(self, mode=...): # -> Self: + """ + Add glow effect to the shape in the bitmap. + + The glowing area is one pixel up, right, bottom and left to the original pixels (corners will not be filled in default mode 0 but will in mode 1), and will be filled by `'2'`s. + + https://font.tomchen.org/bdfparser_py/bitmap#glow + """ + ... + def bytepad(self, bits=...): # -> Self: + """ + Pad each line (row) to multiple of 8 (or other numbers) bits/pixels, with `'0'`s. + + Do this before using the bitmap for a glyph in a BDF font. + + https://font.tomchen.org/bdfparser_py/bitmap#bytepad + """ + ... + def todata( + self, datatype: int = ... + ) -> ( + Any + ): # -> LiteralString | Any | list[list[int]] | list[int] | list[str] | None: + """ + Get the bitmap's data in the specified type and format. + + https://font.tomchen.org/bdfparser_py/bitmap#todata + """ + ... + def tobytes(self, mode=..., bytesdict=...): # -> bytes: + """ + Get the bitmap's data as `bytes` to be used with Pillow library's `Image.frombytes(mode, size, data)`. + + https://font.tomchen.org/bdfparser_py/bitmap#tobytes + """ + ... diff --git a/typings/sense_hat/__init__.pyi b/typings/sense_hat/__init__.pyi new file mode 100644 index 0000000..1048665 --- /dev/null +++ b/typings/sense_hat/__init__.pyi @@ -0,0 +1,19 @@ +""" +This type stub file was generated by pyright. +""" + +from .sense_hat import SenseHat +from .stick import ( + ACTION_HELD, + ACTION_PRESSED, + ACTION_RELEASED, + DIRECTION_DOWN, + DIRECTION_LEFT, + DIRECTION_MIDDLE, + DIRECTION_RIGHT, + DIRECTION_UP, + InputEvent, + SenseStick, +) + +__version__ = ... diff --git a/typings/sense_hat/colour.pyi b/typings/sense_hat/colour.pyi new file mode 100644 index 0000000..2cc1368 --- /dev/null +++ b/typings/sense_hat/colour.pyi @@ -0,0 +1,222 @@ +""" +This type stub file was generated by pyright. +""" + +""" +Python library for the TCS3472x and TCS340x Color Sensors +Documentation (including datasheet): https://ams.com/tcs34725#tab/documents + https://ams.com/tcs3400#tab/documents +The sense hat for AstroPi on the ISS uses the TCS34725. +The sense hat v2 uses the TCS3400 the successor of the TCS34725. +The TCS34725 is not available any more. It was discontinued by ams in 2021. +""" + +class HardwareInterface: + """ + `HardwareInterface` is the abstract class that sits between the + `ColourSensor` class (providing the TCS34725/TCS3400 sensor API) + and the actual hardware. Using this intermediate layer of abstraction, + a `ColourSensor` object interacts with the hardware without being + aware of how this interaction is implemented. + Different subclasses of the `HardwareInterface` class can provide + access to the hardware through e.g. I2C, `libiio` and its system + files or even a hardware emulator. + """ + + @staticmethod + def max_value(integration_cycles): # -> Literal[65535]: + """ + The maximum raw value for the RBGC channels depends on the number + of integration cycles. + """ + ... + def get_enabled(self): + """ + Return True if the sensor is enabled and False otherwise + """ + ... + def set_enabled(self, status): + """ + Enable or disable the sensor, depending on the boolean `status` flag + """ + ... + def get_gain(self): + """ + Return the current value of the sensor gain. + See GAIN_VALUES for the set of possible values. + """ + ... + def set_gain(self, gain): + """ + Set the value for the sensor `gain`. + See GAIN_VALUES for the set of possible values. + """ + ... + def get_integration_cycles(self): + """ + Return the current number of integration_cycles (1-256). + It takes `integration_cycles` * CLOCK_STEP to obtain a new + sensor reading. + """ + ... + def set_integration_cycles(self, integration_cycles): + """ + Set the current number of integration_cycles (1-256). + It takes `integration_cycles` * CLOCK_STEP to obtain a new + sensor reading. + """ + ... + def get_raw(self): + """ + Return a tuple containing the raw values of the RGBC channels. + The maximum for these raw values depends on the number of + integration cycles and can be computed using `max_value`. + """ + ... + def get_red(self): + """ + Return the raw value of the R (red) channel. + The maximum for this raw value depends on the number of + integration cycles and can be computed using `max_value`. + """ + ... + def get_green(self): + """ + Return the raw value of the G (green) channel. + The maximum for this raw value depends on the number of + integration cycles and can be computed using `max_value`. + """ + ... + def get_blue(self): + """ + Return the raw value of the B (blue) channel. + The maximum for this raw value depends on the number of + integration cycles and can be computed using `max_value`. + """ + ... + def get_clear(self): + """ + Return the raw value of the C (clear light) channel. + The maximum for this raw value depends on the number of + integration cycles and can be computed using `max_value`. + """ + ... + +class I2C(HardwareInterface): + """ + An implementation of the `HardwareInterface` for the TCS34725/TCS3400 + sensor that uses I2C to control the sensor and retrieve measurements. + Use the datasheets as a reference. + """ + + BUS = ... + ENABLE = ... + ATIME = ... + CONTROL = ... + ID = ... + STATUS = ... + CDATA = ... + RDATA = ... + GDATA = ... + BDATA = ... + OFF = ... + PON = ... + AEN = ... + ON = ... + AVALID = ... + GAIN_REG_VALUES = ... + ADDR = ... + GAIN_VALUES = ... + CLOCK_STEP = ... + GAIN_TO_REG = ... + REG_TO_GAIN = ... + def __init__(self) -> None: ... + @staticmethod + def i2c_enabled(): # -> bool: + """Returns True if I2C is enabled or False otherwise.""" + ... + def get_enabled(self): + """ + Return True if the sensor is enabled and False otherwise + """ + ... + def set_enabled(self, status): # -> None: + """ + Enable or disable the sensor, depending on the boolean `status` flag + """ + ... + def get_gain(self): # -> Literal[1, 4, 16, 60, 64]: + """ + Return the current value of the sensor gain. + See GAIN_VALUES for the set of possible values. + """ + ... + def set_gain(self, gain): # -> None: + """ + Set the value for the sensor `gain`. + See GAIN_VALUES for the set of possible values. + """ + ... + def get_integration_cycles(self): + """ + Return the current number of integration_cycles (1-256). + It takes `integration_cycles` * CLOCK_STEP to obtain a new + sensor reading. + """ + ... + def set_integration_cycles(self, integration_cycles): # -> None: + """ + Set the current number of integration_cycles (1-256). + It takes `integration_cycles` * CLOCK_STEP to obtain a new + sensor reading. + """ + ... + def get_raw(self): # -> tuple[Any, Any, Any, Any]: + """ + Return a tuple containing the raw values of the RGBC channels. + The maximum for these raw values depends on the number of + integration cycles and can be computed using `max_value`. + """ + ... + get_red = ... + get_green = ... + get_blue = ... + get_clear = ... + +class ColourSensor: + def __init__(self, gain=..., integration_cycles=..., interface=...) -> None: ... + @property + def enabled(self): ... + @enabled.setter + def enabled(self, status): ... + @property + def gain(self): ... + @gain.setter + def gain(self, gain): ... + @property + def integration_cycles(self): ... + @integration_cycles.setter + def integration_cycles(self, integration_cycles): ... + @property + def integration_time(self): ... + @property + def max_raw(self): ... + @property + def colour_raw(self): ... + + color_raw = ... + red_raw = ... + green_raw = ... + blue_raw = ... + clear_raw = ... + brightness = ... + @property + def colour(self): ... + @property + def rgb(self): ... + + color = ... + red = ... + green = ... + blue = ... + clear = ... diff --git a/typings/sense_hat/exceptions.pyi b/typings/sense_hat/exceptions.pyi new file mode 100644 index 0000000..31dfa4f --- /dev/null +++ b/typings/sense_hat/exceptions.pyi @@ -0,0 +1,20 @@ +""" +This type stub file was generated by pyright. +""" + +class SenseHatException(Exception): + """ + The base exception class for all SenseHat exceptions. + """ + + fmt = ... + def __init__(self, **kwargs) -> None: ... + +class ColourSensorInitialisationError(SenseHatException): + fmt = ... + +class InvalidGainError(SenseHatException): + fmt = ... + +class InvalidIntegrationCyclesError(SenseHatException): + fmt = ... diff --git a/typings/sense_hat/sense_hat.pyi b/typings/sense_hat/sense_hat.pyi new file mode 100644 index 0000000..247b220 --- /dev/null +++ b/typings/sense_hat/sense_hat.pyi @@ -0,0 +1,224 @@ +""" +This type stub file was generated by pyright. +""" +from sense_hat.stick import SenseStick + +class SenseHat: + SENSE_HAT_FB_NAME = ... + SENSE_HAT_FB_FBIOGET_GAMMA = ... + SENSE_HAT_FB_FBIOSET_GAMMA = ... + SENSE_HAT_FB_FBIORESET_GAMMA = ... + SENSE_HAT_FB_GAMMA_DEFAULT = ... + SENSE_HAT_FB_GAMMA_LOW = ... + SENSE_HAT_FB_GAMMA_USER = ... + SETTINGS_HOME_PATH = ... + def __init__(self, imu_settings_file=..., text_assets=...) -> None: ... + @property + def stick(self) -> SenseStick: ... + @property + def colour(self): ... + + color = ... + def has_colour_sensor(self): ... + @property + def rotation(self): ... + @rotation.setter + def rotation(self, r): ... + def set_rotation(self, r=..., redraw=...): # -> None: + """ + Sets the LED matrix rotation for viewing, adjust if the Pi is upside + down or sideways. 0 is with the Pi HDMI port facing downwards + """ + ... + def flip_h(self, redraw=...): # -> list[Any]: + """ + Flip LED matrix horizontal + """ + ... + def flip_v(self, redraw=...): # -> list[Any]: + """ + Flip LED matrix vertical + """ + ... + def set_pixels(self, pixel_list: list[tuple[int, int, int]]) -> None: # -> None: + """ + Accepts a list containing 64 smaller lists of [R,G,B] pixels and + updates the LED matrix. R,G,B elements must integers between 0 + and 255 + """ + ... + def get_pixels(self): # -> list[Any]: + """ + Returns a list containing 64 smaller lists of [R,G,B] pixels + representing what is currently displayed on the LED matrix + """ + ... + def set_pixel(self, x, y, *args): # -> None: + """ + Updates the single [R,G,B] pixel specified by x and y on the LED matrix + Top left = 0,0 Bottom right = 7,7 + + e.g. ap.set_pixel(x, y, r, g, b) + or + pixel = (r, g, b) + ap.set_pixel(x, y, pixel) + """ + ... + def get_pixel(self, x, y): # -> list[int]: + """ + Returns a list of [R,G,B] representing the pixel specified by x and y + on the LED matrix. Top left = 0,0 Bottom right = 7,7 + """ + ... + def load_image(self, file_path, redraw=...): # -> list[list[Any]]: + """ + Accepts a path to an 8 x 8 image file and updates the LED matrix with + the image + """ + ... + def clear(self, *args): # -> None: + """ + Clears the LED matrix with a single colour, default is black / off + + e.g. ap.clear() + or + ap.clear(r, g, b) + or + colour = (r, g, b) + ap.clear(colour) + """ + ... + def show_message( + self, text_string, scroll_speed=..., text_colour=..., back_colour=... + ): # -> None: + """ + Scrolls a string of text across the LED matrix using the specified + speed and colours + """ + ... + def show_letter(self, s, text_colour=..., back_colour=...): # -> None: + """ + Displays a single text character on the LED matrix using the specified + colours + """ + ... + @property + def gamma(self): ... + @gamma.setter + def gamma(self, buffer): ... + def gamma_reset(self): # -> None: + """ + Resets the LED matrix gamma correction to default + """ + ... + @property + def low_light(self): ... + @low_light.setter + def low_light(self, value): ... + def get_humidity(self): # -> Literal[0]: + """ + Returns the percentage of relative humidity + """ + ... + @property + def humidity(self): ... + def get_temperature_from_humidity(self): # -> Literal[0]: + """ + Returns the temperature in Celsius from the humidity sensor + """ + ... + def get_temperature_from_pressure(self): # -> Literal[0]: + """ + Returns the temperature in Celsius from the pressure sensor + """ + ... + def get_temperature(self): # -> Literal[0]: + """ + Returns the temperature in Celsius + """ + ... + @property + def temp(self): ... + @property + def temperature(self): ... + def get_pressure(self): # -> Literal[0]: + """ + Returns the pressure in Millibars + """ + ... + @property + def pressure(self): ... + def set_imu_config(self, compass_enabled, gyro_enabled, accel_enabled): # -> None: + """ + Enables and disables the gyroscope, accelerometer and/or magnetometer + input to the orientation functions + """ + ... + def get_orientation_radians(self): # -> dict[str, Any] | dict[str, int]: + """ + Returns a dictionary object to represent the current orientation in + radians using the aircraft principal axes of pitch, roll and yaw + """ + ... + @property + def orientation_radians(self): ... + def get_orientation_degrees(self): # -> dict[str, Any] | dict[str, int]: + """ + Returns a dictionary object to represent the current orientation + in degrees, 0 to 360, using the aircraft principal axes of + pitch, roll and yaw + """ + ... + def get_orientation(self): ... + @property + def orientation(self): ... + def get_compass(self): # -> int | None: + """ + Gets the direction of North from the magnetometer in degrees + """ + ... + @property + def compass(self): ... + def get_compass_raw(self): # -> dict[str, Any] | dict[str, int]: + """ + Magnetometer x y z raw data in uT (micro teslas) + """ + ... + @property + def compass_raw(self): ... + def get_gyroscope(self): # -> dict[str, Any] | dict[str, int]: + """ + Gets the orientation in degrees from the gyroscope only + """ + ... + @property + def gyro(self): ... + @property + def gyroscope(self): ... + def get_gyroscope_raw(self): # -> dict[str, Any] | dict[str, int]: + """ + Gyroscope x y z raw data in radians per second + """ + ... + @property + def gyro_raw(self): ... + @property + def gyroscope_raw(self): ... + def get_accelerometer(self): # -> dict[str, Any] | dict[str, int]: + """ + Gets the orientation in degrees from the accelerometer only + """ + ... + @property + def accel(self): ... + @property + def accelerometer(self): ... + def get_accelerometer_raw(self): # -> dict[str, Any] | dict[str, int]: + """ + Accelerometer x y z raw data in Gs + """ + ... + @property + def accel_raw(self): ... + @property + def accelerometer_raw(self): ... diff --git a/typings/sense_hat/stick.pyi b/typings/sense_hat/stick.pyi new file mode 100644 index 0000000..93572c8 --- /dev/null +++ b/typings/sense_hat/stick.pyi @@ -0,0 +1,132 @@ +""" +This type stub file was generated by pyright. +""" +from collections.abc import Callable +from typing import Any, NamedTuple + +# native_str = str +# str = ... +DIRECTION_UP = ... +DIRECTION_DOWN = ... +DIRECTION_LEFT = ... +DIRECTION_RIGHT = ... +DIRECTION_MIDDLE = ... +ACTION_PRESSED = ... +ACTION_RELEASED = ... +ACTION_HELD = ... + +class InputEvent(NamedTuple): + timestamp: int + direction: str + action: str + +class SenseStick: + """ + Represents the joystick on the Sense HAT. + """ + + SENSE_HAT_EVDEV_NAME = ... + EVENT_FORMAT = ... + EVENT_SIZE = ... + EV_KEY = ... + STATE_RELEASE = ... + STATE_PRESS = ... + STATE_HOLD = ... + KEY_UP = ... + KEY_LEFT = ... + KEY_RIGHT = ... + KEY_DOWN = ... + KEY_ENTER = ... + def __init__(self) -> None: ... + def close(self): ... + def __enter__(self): ... + def __exit__(self, exc_type, exc_value, exc_tb): ... + def wait_for_event(self, emptybuffer=...): # -> InputEvent | None: + """ + Waits until a joystick event becomes available. Returns the event, as + an `InputEvent` tuple. + + If *emptybuffer* is `True` (it defaults to `False`), any pending + events will be thrown away first. This is most useful if you are only + interested in "pressed" events. + """ + ... + def get_events(self): # -> list[Any]: + """ + Returns a list of all joystick events that have occurred since the last + call to `get_events`. The list contains events in the order that they + occurred. If no events have occurred in the intervening time, the + result is an empty list. + """ + ... + @property + def direction_up(self): # -> None: + """ + The function to be called when the joystick is pushed up. The function + can either take a parameter which will be the `InputEvent` tuple that + has occurred, or the function can take no parameters at all. + """ + ... + @direction_up.setter + def direction_up(self, value: Callable[[InputEvent], Any]) -> None: ... + @property + def direction_down(self): # -> None: + """ + The function to be called when the joystick is pushed down. The + function can either take a parameter which will be the `InputEvent` + tuple that has occurred, or the function can take no parameters at all. + + Assign `None` to prevent this event from being fired. + """ + ... + @direction_down.setter + def direction_down(self, value: Callable[[InputEvent], Any]) -> None: ... + @property + def direction_left(self): # -> None: + """ + The function to be called when the joystick is pushed left. The + function can either take a parameter which will be the `InputEvent` + tuple that has occurred, or the function can take no parameters at all. + + Assign `None` to prevent this event from being fired. + """ + ... + @direction_left.setter + def direction_left(self, value: Callable[[InputEvent], Any]) -> None: ... + @property + def direction_right(self): # -> None: + """ + The function to be called when the joystick is pushed right. The + function can either take a parameter which will be the `InputEvent` + tuple that has occurred, or the function can take no parameters at all. + + Assign `None` to prevent this event from being fired. + """ + ... + @direction_right.setter + def direction_right(self, value: Callable[[InputEvent], Any]) -> None: ... + @property + def direction_middle(self): # -> None: + """ + The function to be called when the joystick middle click is pressed. The + function can either take a parameter which will be the `InputEvent` tuple + that has occurred, or the function can take no parameters at all. + + Assign `None` to prevent this event from being fired. + """ + ... + @direction_middle.setter + def direction_middle(self, value: Callable[[InputEvent], Any]) -> None: ... + @property + def direction_any(self): # -> None: + """ + The function to be called when the joystick is used. The function + can either take a parameter which will be the `InputEvent` tuple that + has occurred, or the function can take no parameters at all. + + This event will always be called *after* events associated with a + specific action. Assign `None` to prevent this event from being fired. + """ + ... + @direction_any.setter + def direction_any(self, value: Callable[[InputEvent], Any]) -> None: ... From c77e04f508ab9de7290af87b42f2f5cdbb8dc493 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 9 Dec 2024 15:48:37 +0100 Subject: [PATCH 17/24] Remove mypy.ini --- mypy.ini | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 mypy.ini diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 8a26835..0000000 --- a/mypy.ini +++ /dev/null @@ -1,4 +0,0 @@ -[mypy] -python_executable = ./env/bin/python -strict = True -pretty = True From 9a295a75695fd7f590ccb495614699dc5faf0098 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 9 Dec 2024 15:56:30 +0100 Subject: [PATCH 18/24] Move cls attribute --- oin_thermostat/select.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/oin_thermostat/select.py b/oin_thermostat/select.py index d25a195..645e3c0 100644 --- a/oin_thermostat/select.py +++ b/oin_thermostat/select.py @@ -10,6 +10,8 @@ logger = logging.getLogger(__name__) class Selector: + default_data = {"temperature": None, "mode": None, "switch": None} + def __init__(self, send_data: Callable[[dict[str, Any]], Any]): self.stick = SenseHat().stick self.temperature: float | None = None @@ -18,7 +20,6 @@ class Selector: self.preset_modes: list[str] = [] self.send_data = send_data self.switch_held = False - self.default_data = {"temperature": None, "mode": None, "switch": None} self.stick.direction_middle = self.toggle From 483d377b6d9786f66cc585510cd5078fc5a159b5 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 9 Dec 2024 16:12:38 +0100 Subject: [PATCH 19/24] Use TypedDict to avoir using Any --- config.toml | 2 +- oin_thermostat/mqtt.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/config.toml b/config.toml index 498e003..0c37600 100644 --- a/config.toml +++ b/config.toml @@ -1,5 +1,5 @@ [logging] -level = "DEBUG" +# level = "DEBUG" [homeassistant] entity = "climate.chaudiere" diff --git a/oin_thermostat/mqtt.py b/oin_thermostat/mqtt.py index e6279ac..82ecfc6 100644 --- a/oin_thermostat/mqtt.py +++ b/oin_thermostat/mqtt.py @@ -1,7 +1,7 @@ import json import logging from collections.abc import Callable -from typing import Any +from typing import Any, TypedDict import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion, MQTTErrorCode @@ -12,6 +12,14 @@ from .select import Selector logger = logging.getLogger(__name__) +class HAOptions(TypedDict): + dev: dict[str, str] + o: dict[str, str] + availability_topic: str + state_topic: str + cmps: dict[str, dict[str, str]] + + class HAClient: def __init__( self, @@ -38,7 +46,7 @@ class HAClient: self.selector = Selector(self.send_data) @property - def ha_options(self) -> dict[str, Any]: + def ha_options(self) -> HAOptions: return { "dev": { "ids": "oin", From 3aaaad6fcf6c36a324d8281777df40bbf6344834 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 9 Dec 2024 16:18:19 +0100 Subject: [PATCH 20/24] Improve logging --- config.toml | 2 +- oin_thermostat/mqtt.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/config.toml b/config.toml index 0c37600..c3b6e80 100644 --- a/config.toml +++ b/config.toml @@ -1,5 +1,5 @@ [logging] -# level = "DEBUG" +level = "INFO" [homeassistant] entity = "climate.chaudiere" diff --git a/oin_thermostat/mqtt.py b/oin_thermostat/mqtt.py index 82ecfc6..b57ff25 100644 --- a/oin_thermostat/mqtt.py +++ b/oin_thermostat/mqtt.py @@ -104,6 +104,7 @@ class HAClient: logger.error("Availability message timed out") return 1 + logger.info("Connected to Home Assistant.") return 0 def publish( @@ -143,6 +144,8 @@ class HAClient: if code != 0: logger.error("MQTT client loop failed with code <{code}>.") + else: + logger.info("MQTT client loop successfully exited") return code def state_update( From 3706fd6ade843621b5441a325898c4053e97fd76 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 9 Dec 2024 17:01:38 +0100 Subject: [PATCH 21/24] Add auto dim led panel --- oin_thermostat/screen.py | 41 ++++++++++++++++++++++++++++++++- typings/sense_hat/sense_hat.pyi | 6 +++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/oin_thermostat/screen.py b/oin_thermostat/screen.py index 256cb64..8f87590 100644 --- a/oin_thermostat/screen.py +++ b/oin_thermostat/screen.py @@ -1,6 +1,6 @@ import logging import math -from threading import Timer +from threading import Thread, Timer import bdfparser from sense_hat.sense_hat import SenseHat @@ -36,6 +36,8 @@ class Screen: self._secondary_pixels: list[tuple[int, int, int]] = [(0, 0, 0)] * 8 self.timer = Timer(0, self.set_pixels) + self.auto_dim = AutoDim(self.sense) + self.auto_dim.start() self._held = False self.sense.stick.direction_middle = self.stick_click @@ -142,6 +144,43 @@ class Screen: return self.font.draw(value, mode=0).crop(8, 7).todata(3) +class AutoDim(Thread): + def __init__(self, sense: SenseHat): + super().__init__() + + self.daemon = True + self.sense = sense + self.dim = False + self.switching = False + self.sense.gamma_reset() + + def run(self) -> None: + while True: + self.auto_dim() + + def auto_dim(self) -> None: + accel_z = self.sense.get_accelerometer_raw()["z"] + + if not self.switching and accel_z < 0.9: + self.switching = True + self.dim = not self.dim + + elif self.switching and accel_z > 0.98: + self.switching = False + + @property + def dim(self) -> bool: + return self._dim + + @dim.setter + def dim(self, value: bool) -> None: + if value: + self.sense.gamma = [0] * 32 + else: + self.sense.gamma_reset() + self._dim = value + + def format_value(value: float) -> str: v = math.trunc(value) d = "." if (value - v) >= 0.5 else "" diff --git a/typings/sense_hat/sense_hat.pyi b/typings/sense_hat/sense_hat.pyi index 247b220..9de2560 100644 --- a/typings/sense_hat/sense_hat.pyi +++ b/typings/sense_hat/sense_hat.pyi @@ -106,7 +106,7 @@ class SenseHat: def gamma(self): ... @gamma.setter def gamma(self, buffer): ... - def gamma_reset(self): # -> None: + def gamma_reset(self) -> None: # -> None: """ Resets the LED matrix gamma correction to default """ @@ -213,7 +213,9 @@ class SenseHat: def accel(self): ... @property def accelerometer(self): ... - def get_accelerometer_raw(self): # -> dict[str, Any] | dict[str, int]: + def get_accelerometer_raw( + self, + ) -> dict[str, int]: # -> dict[str, Any] | dict[str, int]: """ Accelerometer x y z raw data in Gs """ From 91f74e3f953751ac170255d410825a4ce93e0b9a Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 9 Dec 2024 17:07:10 +0100 Subject: [PATCH 22/24] Create config example --- .gitignore | 1 + config.toml => config.example.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) rename config.toml => config.example.toml (88%) diff --git a/.gitignore b/.gitignore index 1dab207..00cb8bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__ /env /out +config.toml diff --git a/config.toml b/config.example.toml similarity index 88% rename from config.toml rename to config.example.toml index c3b6e80..9e66e7a 100644 --- a/config.toml +++ b/config.example.toml @@ -10,5 +10,5 @@ secondary_entities = [ [homeassistant.mqtt] username = "oin" -password = "n+Bi58l7LxbH5nEJ" +password = "" host = "homeassistant.local" From 1ed22e8aa8b8f33d247190a43c4d9ccebb469e00 Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Mon, 9 Dec 2024 17:09:47 +0100 Subject: [PATCH 23/24] Add README. --- README.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..db7f020 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# Oin +Use Raspberry Pi with Sense Hat as interface for Home Assistant Thermostat. From 57346674dfb98618c6ff43058daa325aeccd452c Mon Sep 17 00:00:00 2001 From: "Edgar P. Burkhart" Date: Sat, 14 Dec 2024 10:18:40 +0100 Subject: [PATCH 24/24] Undim on button press rather than tmp value --- oin_thermostat/screen.py | 8 ++++++-- typings/sense_hat/stick.pyi | 24 ++++++++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/oin_thermostat/screen.py b/oin_thermostat/screen.py index 8f87590..611a87f 100644 --- a/oin_thermostat/screen.py +++ b/oin_thermostat/screen.py @@ -41,6 +41,7 @@ class Screen: self._held = False self.sense.stick.direction_middle = self.stick_click + self.sense.stick.direction_any = self.auto_dim.undim @property def value(self) -> None | str: @@ -161,11 +162,11 @@ class AutoDim(Thread): def auto_dim(self) -> None: accel_z = self.sense.get_accelerometer_raw()["z"] - if not self.switching and accel_z < 0.9: + if not self.switching and accel_z < 0.2: self.switching = True self.dim = not self.dim - elif self.switching and accel_z > 0.98: + elif self.switching and accel_z > 0.9: self.switching = False @property @@ -180,6 +181,9 @@ class AutoDim(Thread): self.sense.gamma_reset() self._dim = value + def undim(self) -> None: + self.dim = False + def format_value(value: float) -> str: v = math.trunc(value) diff --git a/typings/sense_hat/stick.pyi b/typings/sense_hat/stick.pyi index 93572c8..374bfd9 100644 --- a/typings/sense_hat/stick.pyi +++ b/typings/sense_hat/stick.pyi @@ -68,7 +68,9 @@ class SenseStick: """ ... @direction_up.setter - def direction_up(self, value: Callable[[InputEvent], Any]) -> None: ... + def direction_up( + self, value: Callable[[InputEvent], Any] | Callable[[], Any] + ) -> None: ... @property def direction_down(self): # -> None: """ @@ -80,7 +82,9 @@ class SenseStick: """ ... @direction_down.setter - def direction_down(self, value: Callable[[InputEvent], Any]) -> None: ... + def direction_down( + self, value: Callable[[InputEvent], Any] | Callable[[], Any] + ) -> None: ... @property def direction_left(self): # -> None: """ @@ -92,7 +96,9 @@ class SenseStick: """ ... @direction_left.setter - def direction_left(self, value: Callable[[InputEvent], Any]) -> None: ... + def direction_left( + self, value: Callable[[InputEvent], Any] | Callable[[], Any] + ) -> None: ... @property def direction_right(self): # -> None: """ @@ -104,7 +110,9 @@ class SenseStick: """ ... @direction_right.setter - def direction_right(self, value: Callable[[InputEvent], Any]) -> None: ... + def direction_right( + self, value: Callable[[InputEvent], Any] | Callable[[], Any] + ) -> None: ... @property def direction_middle(self): # -> None: """ @@ -116,7 +124,9 @@ class SenseStick: """ ... @direction_middle.setter - def direction_middle(self, value: Callable[[InputEvent], Any]) -> None: ... + def direction_middle( + self, value: Callable[[InputEvent], Any] | Callable[[], Any] + ) -> None: ... @property def direction_any(self): # -> None: """ @@ -129,4 +139,6 @@ class SenseStick: """ ... @direction_any.setter - def direction_any(self, value: Callable[[InputEvent], Any]) -> None: ... + def direction_any( + self, value: Callable[[InputEvent], Any] | Callable[[], Any] + ) -> None: ...