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 = {}
class ButtonHandler(threading.Thread): def _subscribe(self):
def __init__(self, pin, func, edge='both', bouncetime=100): for area in self.areas:
super().__init__(daemon=True) self.client.subscribe(f"foobar/{area}/foodoor/status", 1)
self.pin = pin def connect(self, host="mqtt.chaospott.de"):
self.func = func
self.edge = edge
self.bouncetime = bouncetime / 1000
self.lastpinval = gpio.input(self.pin)
self.lock = threading.Lock()
def __call__(self, *args):
if not self.lock.acquire(blocking=False):
return
t = threading.Timer(self.bouncetime, self.read, args=args)
t.start()
def read(self, *args):
pinval = gpio.input(self.pin)
if ((pinval == 0 and self.lastpinval == 1 and self.edge in ['falling', 'both'])
or (pinval == 1 and self.lastpinval == 0 and self.edge in ['rising', 'both'])):
self.func(*args)
self.lastpinval = pinval
self.lock.release()
class ButtonSensor:
def __init__(self, pin, callback):
self.pin = pin
gpio.setup(pin, gpio.IN, pull_up_down=gpio.PUD_UP)
self.handler = ButtonHandler(pin, callback, edge='falling', bouncetime=100)
self.handler.start()
gpio.add_event_detect(pin, gpio.BOTH, callback=self.handler)
class ButtonPress(Exception):
def __init__(self, pin):
super().__init__()
self.pin = pin
class MultiSensor:
def __init__(self, pins):
self.pressed = None
self.buttons = [ButtonSensor(pin, self.press) for pin in pins]
def press(self, pin):
self.pressed = pin
def sleepSecond(self):
for i in range(100):
if self.pressed is not None:
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: try:
self.loadData() self.client.connect(host)
self.client.loop_start()
with self._connect_lock:
self._connect_lock.wait()
except Exception as e: except Exception as e:
print("Unable to load API data:", e) print(f"Verbindungsfehler zu MQTT-Server: {e}")
time.sleep(self.delay) def disconnect(self):
self.client.loop_stop()
def loadData(self): def on_connect(self, client, userdata, flags, rc):
# Load api data self._subscribe()
print("Loading API data...") with self._connect_lock:
response = urllib.request.urlopen("https://status-v2.chaospott.de/status.json").read() self._connect_lock.notify()
data = json.loads(response.decode("utf-8"))
self.data = {door["location"]: not door["value"] for door in data["sensors"]["door_locked"]} 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 StateMenu:
OFF = 0
SHOW_STATE = 1
CHANGE = 2
PROGRESS = 3
RAINBOW = 4
def __init__(self):
self.state = StateMenu.OFF
self.change_target = None
self.target_state = None
self.anim = []
self.timeout = None
self.next_state = None
self.reset_state()
self.mqtt = FoodoorMQTT(["oben", "unten"])
self.mqtt.message_cbs.append(lambda *_: self.draw())
self.mqtt.connect()
self.strip = neopixel.NeoPixel(board.D18, 36, brightness=0.5, pixel_order=neopixel.GRB, auto_write=False)
self.strip_cellar = SubStrip(self.strip, pixel=list(range(12)))
self.strip_center = SubStrip(self.strip, pixel=list(range(12, 24)))
self.strip_aerie = SubStrip(self.strip, pixel=list(range(24, 36)))
self.doors = {
"cellar": Door("10.42.1.20", "open", "close"),
"aerie": Door("10.42.1.28", "open", "close"),
}
def reset_state(self):
self.state = StateMenu.SHOW_STATE
self.change_target = None
self.target_state = None
self.anim.clear()
self.timeout = None
self.next_state = None
def button_pressed(self, btn):
if self.state == StateMenu.OFF:
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"
if self.change_target is not None:
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()
elif self.state == StateMenu.PROGRESS:
pass
self.draw()
def set_state(self, state, timeout=None):
self.state = state
self.timeout = None
if timeout is not None:
self.timeout = time.monotonic() + timeout
def draw(self):
self.anim.clear()
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)]
def tick(self):
if self.timeout and self.timeout < time.monotonic() and not (self.change_target and self.doors[self.change_target].inprogress):
if self.next_state is not None:
self.set_state(self.next_state, 10)
self.next_state = None
else:
self.reset_state()
self.draw()
for a in self.anim:
a.animate(show=False)
self.strip.show()
def deinit(self):
self.strip.deinit()
self.mqtt.disconnect()
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"),
"cellar": Door("10.42.1.20", "open", "close"),
}
# Initialize API interface
loader = APILoader(4)
loader.start()
print("[*] Starting...")
while True: while True:
for led in leds: if not btn_cellar.value:
leds[led].draw((0, 0, 0)) if not btn_locked:
menu.button_pressed("cellar")
data = loader.getData() btn_locked = True
for door in data: elif not btn_center.value:
if door in leds: if not btn_locked:
leds[door].draw((0, 255, 0) if data[door] else (255, 0, 0)) menu.button_pressed("center")
btn_locked = True
strip.show() elif not btn_aerie.value:
if not btn_locked:
# Wait for a button press menu.button_pressed("aerie")
try: btn_locked = True
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,8 +238,10 @@ 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 = None
if timeout is not None:
self.timeout = time.monotonic() + timeout self.timeout = time.monotonic() + timeout
def draw(self): def draw(self):
@@ -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,