Rewrite OG Buttons

This commit is contained in:
T
2025-09-11 15:36:04 +02:00
parent 4bbb323f30
commit fe3d3967a8
4 changed files with 257 additions and 167 deletions

View File

@@ -1,20 +1,37 @@
import json
import subprocess import subprocess
import threading import threading
import time import time
import urllib.request
import RPi.GPIO as gpio
import board import board
import digitalio
import neopixel import neopixel
import paho.mqtt.client as mqtt
from adafruit_led_animation import color
from adafruit_led_animation.animation.chase import Chase
from adafruit_led_animation.animation.rainbowcomet import RainbowComet
def ssh(host, name): class SubStrip:
def ssh_(host_, name_): def __init__(self, strip, pixel: list[int]):
print("[ssh]", f"{name_}@{host_}") self.strip: neopixel.NeoPixel = strip
subprocess.check_call(["ssh", f"{name_}@{host_}"], stdin=subprocess.DEVNULL) self._pixel_map = pixel
threading.Thread(target=ssh_, args=(host, name)).start() def __len__(self):
return len(self._pixel_map)
def __setitem__(self, key, value):
if isinstance(key, slice):
for i, v in zip(range(*key.indices(len(self._pixel_map))), value):
self.strip[self._pixel_map[i]] = v
else:
self.strip[self._pixel_map[key]] = value
def show(self):
self.strip.show()
def fill(self, color):
for p in self._pixel_map:
self.strip[p] = color
class Door: class Door:
@@ -22,194 +39,244 @@ class Door:
self.host = host self.host = host
self.user_open = user_open self.user_open = user_open
self.user_close = user_close self.user_close = user_close
self.inprogress = False
def _ssh(self, host, name):
def _ssh_(host_, name_):
self.inprogress = True
try:
print("[ssh]", f"{name_}@{host_}")
subprocess.check_call(["ssh", f"{name_}@{host_}"], stdin=subprocess.DEVNULL)
except subprocess.CalledProcessError as ex:
print("[ssh]", ex)
self.inprogress = False
threading.Thread(target=_ssh_, args=(host, name)).start()
def open(self): def open(self):
ssh(self.host, self.user_open) self._ssh(self.host, self.user_open)
def close(self): def close(self):
ssh(self.host, self.user_close) self._ssh(self.host, self.user_close)
class ButtonStrip: class FoodoorMQTT:
def __init__(self, strip, leds): def __init__(self, areas):
self.leds = leds self.areas = areas
self.strip = strip self.message_cbs = []
def draw(self, color): self.client = mqtt.Client()
for led in self.leds: self.client.on_connect = self.on_connect
self.strip[led] = color self.client.on_message = self.on_message
self._connect_lock = threading.Condition()
self._data = {}
def _subscribe(self):
for area in self.areas:
self.client.subscribe(f"foobar/{area}/foodoor/status", 1)
def connect(self, host="mqtt.chaospott.de"):
try:
self.client.connect(host)
self.client.loop_start()
with self._connect_lock:
self._connect_lock.wait()
except Exception as e:
print(f"Verbindungsfehler zu MQTT-Server: {e}")
def disconnect(self):
self.client.loop_stop()
def on_connect(self, client, userdata, flags, rc):
self._subscribe()
with self._connect_lock:
self._connect_lock.notify()
def on_message(self, client, userdata, msg):
print(f"MQTT-Server Message: {msg.topic} {msg.payload.decode()}")
self._data[msg.topic] = msg.payload.decode()
for cb in self.message_cbs:
cb(msg.topic)
def get_door(self, area):
return self._data.get(f"foobar/{area}/foodoor/status")
def is_open(self, area):
return self.get_door(area) == "open"
class ButtonHandler(threading.Thread): class StateMenu:
def __init__(self, pin, func, edge='both', bouncetime=100): OFF = 0
super().__init__(daemon=True) SHOW_STATE = 1
CHANGE = 2
PROGRESS = 3
RAINBOW = 4
self.pin = pin def __init__(self):
self.func = func self.state = StateMenu.OFF
self.edge = edge self.change_target = None
self.bouncetime = bouncetime / 1000 self.target_state = None
self.anim = []
self.timeout = None
self.next_state = None
self.reset_state()
self.lastpinval = gpio.input(self.pin) self.mqtt = FoodoorMQTT(["oben", "unten"])
self.lock = threading.Lock() self.mqtt.message_cbs.append(lambda *_: self.draw())
self.mqtt.connect()
def __call__(self, *args): self.strip = neopixel.NeoPixel(board.D18, 36, brightness=0.5, pixel_order=neopixel.GRB, auto_write=False)
if not self.lock.acquire(blocking=False): self.strip_cellar = SubStrip(self.strip, pixel=list(range(12)))
return self.strip_center = SubStrip(self.strip, pixel=list(range(12, 24)))
self.strip_aerie = SubStrip(self.strip, pixel=list(range(24, 36)))
t = threading.Timer(self.bouncetime, self.read, args=args) self.doors = {
t.start() "cellar": Door("10.42.1.20", "open", "close"),
"aerie": Door("10.42.1.28", "open", "close"),
}
def read(self, *args): def reset_state(self):
pinval = gpio.input(self.pin) self.state = StateMenu.SHOW_STATE
self.change_target = None
self.target_state = None
self.anim.clear()
self.timeout = None
self.next_state = None
if ((pinval == 0 and self.lastpinval == 1 and self.edge in ['falling', 'both']) def button_pressed(self, btn):
or (pinval == 1 and self.lastpinval == 0 and self.edge in ['rising', 'both'])): if self.state == StateMenu.OFF:
self.func(*args) self.set_state(StateMenu.SHOW_STATE)
elif self.state == StateMenu.RAINBOW and btn == "center":
self.reset_state()
elif self.state == StateMenu.SHOW_STATE or self.state == StateMenu.RAINBOW:
if btn == "center":
self.set_state(StateMenu.RAINBOW, 10)
elif btn in ("aerie", "cellar"):
self.set_state(StateMenu.CHANGE, 10)
self.change_target = btn
elif self.state == StateMenu.CHANGE:
if btn == self.change_target:
self.reset_state()
elif self.change_target == "aerie":
if btn == "center":
self.target_state = "open"
elif btn == "cellar":
self.target_state = "close"
elif self.change_target == "cellar":
if btn == "aerie":
self.target_state = "open"
elif btn == "center":
self.target_state = "close"
self.lastpinval = pinval if self.change_target is not None:
self.lock.release() self.set_state(StateMenu.PROGRESS, 5)
self.next_state = StateMenu.SHOW_STATE
print(self.change_target, self.target_state)
if self.target_state == "open":
self.doors[self.change_target].open()
elif self.target_state == "close":
self.doors[self.change_target].close()
class ButtonSensor: elif self.state == StateMenu.PROGRESS:
def __init__(self, pin, callback): pass
self.pin = pin
gpio.setup(pin, gpio.IN, pull_up_down=gpio.PUD_UP) self.draw()
self.handler = ButtonHandler(pin, callback, edge='falling', bouncetime=100)
self.handler.start()
gpio.add_event_detect(pin, gpio.BOTH, callback=self.handler)
def set_state(self, state, timeout=None):
self.state = state
self.timeout = None
if timeout is not None:
self.timeout = time.monotonic() + timeout
class ButtonPress(Exception): def draw(self):
def __init__(self, pin): self.anim.clear()
super().__init__()
self.pin = pin
if self.state == StateMenu.OFF:
self.strip.fill(color.BLACK)
elif self.state == StateMenu.SHOW_STATE:
self.strip_aerie.fill(color.GREEN if self.mqtt.is_open("oben") else color.RED)
self.strip_cellar.fill(color.GREEN if self.mqtt.is_open("unten") else color.RED)
self.strip_center.fill(color.BLACK)
elif self.state == StateMenu.CHANGE:
if self.change_target == "aerie":
self.anim = [
Chase(self.strip_center, 0.5, color=color.GREEN, size=1, spacing=11),
Chase(self.strip_cellar, 0.5, color=color.RED, size=1, spacing=11),
]
elif self.change_target == "cellar":
self.anim = [
Chase(self.strip_aerie, 0.5, color=color.GREEN, size=1, spacing=11),
Chase(self.strip_center, 0.5, color=color.RED, size=1, spacing=11),
]
elif self.state == StateMenu.PROGRESS:
self.strip_center.fill(color.BLACK)
if self.change_target == "aerie":
self.strip_cellar.fill(color.GREEN if self.mqtt.is_open("unten") else color.RED)
self.anim = [Chase(self.strip_aerie, 0.05, size=1,
color=color.GREEN if self.target_state == "open" else color.RED)]
elif self.change_target == "cellar":
self.strip_aerie.fill(color.GREEN if self.mqtt.is_open("oben") else color.RED)
self.anim = [Chase(self.strip_cellar, 0.05, size=1,
color=color.GREEN if self.target_state == "open" else color.RED)]
elif self.state == StateMenu.RAINBOW:
self.strip_aerie.fill(color.GREEN if self.mqtt.is_open("oben") else color.RED)
self.strip_cellar.fill(color.GREEN if self.mqtt.is_open("unten") else color.RED)
self.anim = [RainbowComet(self.strip_center, 0.05, ring=True)]
class MultiSensor: def tick(self):
def __init__(self, pins): if self.timeout and self.timeout < time.monotonic() and not (self.change_target and self.doors[self.change_target].inprogress):
self.pressed = None if self.next_state is not None:
self.buttons = [ButtonSensor(pin, self.press) for pin in pins] self.set_state(self.next_state, 10)
self.next_state = None
else:
self.reset_state()
self.draw()
def press(self, pin): for a in self.anim:
self.pressed = pin a.animate(show=False)
self.strip.show()
def sleepSecond(self): def deinit(self):
for i in range(100): self.strip.deinit()
if self.pressed is not None: self.mqtt.disconnect()
pressed_pin = self.pressed
self.pressed = None
raise ButtonPress(pressed_pin)
time.sleep(0.01)
def sleep(self, delay):
for i in range(delay):
self.sleepSecond()
class APILoader(threading.Thread):
def __init__(self, delay):
super().__init__()
self.delay = delay
self.data = None
self.loadData()
def getData(self):
if not self.is_alive():
print("API thread is dead. Trying to restart...")
self.start()
return self.data
def run(self):
while True:
try:
self.loadData()
except Exception as e:
print("Unable to load API data:", e)
time.sleep(self.delay)
def loadData(self):
# Load api data
print("Loading API data...")
response = urllib.request.urlopen("https://status-v2.chaospott.de/status.json").read()
data = json.loads(response.decode("utf-8"))
self.data = {door["location"]: not door["value"] for door in data["sensors"]["door_locked"]}
def main(): def main():
print("[*] Initializing...") btn_cellar = digitalio.DigitalInOut(board.D22)
# Initialize GPIO board btn_cellar.switch_to_input(digitalio.Pull.DOWN)
gpio.setmode(gpio.BCM)
# Create LED-Strip handle btn_center = digitalio.DigitalInOut(board.D24)
gpio.setup(4, gpio.IN, pull_up_down=gpio.PUD_UP) btn_center.switch_to_input(digitalio.Pull.DOWN)
strip = neopixel.NeoPixel(board.D18, 36, brightness=0.5, pixel_order=neopixel.GRB, auto_write=False)
# Create sub-handle for each button handle btn_aerie = digitalio.DigitalInOut(board.D23)
leds = { btn_aerie.switch_to_input(digitalio.Pull.DOWN)
"cellar": ButtonStrip(strip, list(range(1, 12))),
"center": ButtonStrip(strip, list(range(12, 24))),
"aerie": ButtonStrip(strip, list(range(24, 36))),
}
# Create button press sensor menu = StateMenu()
buttons = { btn_locked = False # For waiting while a button is still pressed
22: "cellar",
23: "aerie",
24: "center",
}
sensor = MultiSensor(buttons.keys())
doors = { try:
"aerie": Door("10.42.1.28", "open", "close"), while True:
"cellar": Door("10.42.1.20", "open", "close"), if not btn_cellar.value:
} if not btn_locked:
menu.button_pressed("cellar")
# Initialize API interface btn_locked = True
loader = APILoader(4) elif not btn_center.value:
loader.start() if not btn_locked:
menu.button_pressed("center")
print("[*] Starting...") btn_locked = True
while True: elif not btn_aerie.value:
for led in leds: if not btn_locked:
leds[led].draw((0, 0, 0)) menu.button_pressed("aerie")
btn_locked = True
data = loader.getData()
for door in data:
if door in leds:
leds[door].draw((0, 255, 0) if data[door] else (255, 0, 0))
strip.show()
# Wait for a button press
try:
sensor.sleep(2)
except ButtonPress as press:
selection = buttons[press.pin]
if selection == "aerie":
leds["cellar"].draw((255, 0, 0))
elif selection == "cellar":
leds["aerie"].draw((255, 0, 0))
else: else:
continue btn_locked = False
leds["center"].draw((0, 255, 0)) menu.tick()
leds[selection].draw((0, 0, 0)) time.sleep(.01)
except KeyboardInterrupt:
strip.show() menu.deinit()
try:
sensor.sleep(5)
except ButtonPress as press:
action = buttons[press.pin]
if action == "center":
doors[selection].open()
elif action != selection:
doors[selection].close()
if __name__ == '__main__': if __name__ == '__main__':

5
1og/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
adafruit-circuitpython-neopixel
adafruit-circuitpython-led-animation
paho-mqtt
RPi.GPIO
rpi-ws281x

View File

@@ -3,13 +3,25 @@
FILE=/etc/systemd/system/buttond.service FILE=/etc/systemd/system/buttond.service
cat > $FILE << EOF button=$(realpath button.py)
if [[ ! -f $button ]]; then
echo button.py not found
exit 1
fi
vpython=$(realpath venv/)/bin/python
if [[ ! -e $vpython ]]; then
echo venv not found
exit 1
fi
cat > "$FILE" << EOF
[Unit] [Unit]
Description = buttonctl Description = buttonctl
[Service] [Service]
Type = simple Type = simple
ExecStart = /usr/bin/python3 $PWD/button.py ExecStart = $vpython $button
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -126,6 +126,7 @@ class DoorLocal(Door):
class FoodoorMQTT: class FoodoorMQTT:
def __init__(self, areas): def __init__(self, areas):
self.areas = areas self.areas = areas
self.message_cbs = []
self.client = mqtt.Client() self.client = mqtt.Client()
self.client.on_connect = self.on_connect self.client.on_connect = self.on_connect
@@ -158,6 +159,8 @@ class FoodoorMQTT:
def on_message(self, client, userdata, msg): def on_message(self, client, userdata, msg):
print(f"MQTT-Server Message: {msg.topic} {msg.payload.decode()}") print(f"MQTT-Server Message: {msg.topic} {msg.payload.decode()}")
self._data[msg.topic] = msg.payload.decode() self._data[msg.topic] = msg.payload.decode()
for cb in self.message_cbs:
cb(msg.topic)
def get_door(self, area): def get_door(self, area):
return self._data.get(f"foobar/{area}/foodoor/status") return self._data.get(f"foobar/{area}/foodoor/status")
@@ -179,8 +182,10 @@ class StateMenu:
self.anim = [] self.anim = []
self.timeout = None self.timeout = None
self.next_state = None self.next_state = None
self.reset_state()
self.mqtt = FoodoorMQTT(["oben", "unten"]) self.mqtt = FoodoorMQTT(["oben", "unten"])
self.mqtt.message_cbs.append(lambda *_: self.draw())
if SIM: if SIM:
self.mqtt.connect("127.0.0.1") self.mqtt.connect("127.0.0.1")
else: else:
@@ -233,9 +238,11 @@ class StateMenu:
self.draw() self.draw()
def set_state(self, state, timeout): def set_state(self, state, timeout=None):
self.state = state self.state = state
self.timeout = time.monotonic() + timeout self.timeout = None
if timeout is not None:
self.timeout = time.monotonic() + timeout
def draw(self): def draw(self):
self.anim.clear() self.anim.clear()
@@ -251,7 +258,6 @@ class StateMenu:
Chase(self.strip_unten, 0.5, color=color.RED, size=1, spacing=11), Chase(self.strip_unten, 0.5, color=color.RED, size=1, spacing=11),
] ]
elif self.state == StateMenu.PROGRESS: elif self.state == StateMenu.PROGRESS:
self.strip_oben.fill(color.GREEN if self.mqtt.is_open("oben") else color.RED)
if self.change_target == "oben": if self.change_target == "oben":
self.strip_unten.fill(color.GREEN if self.mqtt.is_open("unten") else color.RED) self.strip_unten.fill(color.GREEN if self.mqtt.is_open("unten") else color.RED)
self.anim = [Chase(self.strip_oben, 0.05, size=1, self.anim = [Chase(self.strip_oben, 0.05, size=1,