This commit is contained in:
bruno committed 2026-09-21 06:06:42 +02:00
1 parent 599be5bdad
commit 5f4935f2c2
12 files changed
+816

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+816
View File
@@ -0,0 +1,816 @@
# MicroPython Stream Deck fuer Raspberry Pi Pico
# 4 Tasten + Rotary Encoder + SSD1306 OLED -> USB-HID-Tastatur/Mediakeys mit
# mehreren Profilen (Tastenkombis, Lautstaerke/Play-Stop, App-Start) +
# Bildschirmschoner nach Inaktivitaet (4 Animationen, im "Saver"-Profil
# waehlbar)
#
# Einmalig installieren (am Rechner, Pico per USB angeschlossen, KEIN
# Netzwerk auf dem Pico noetig - mpremote laedt am PC und kopiert rueber):
#
# mpremote mip install usb-device-keyboard
# mpremote mip install usb-device-hid
# mpremote mip install ssd1306
#
# Danach dieses main.py auf den Pico kopieren, z.B.:
# mpremote cp main.py :main.py
#
# Verkabelung (Standardbelegung unten anpassbar):
# Taste 1..4 : GP2, GP3, GP4, GP5 -> Taste -> GND (interner Pull-Up)
# Encoder CLK : GP6
# Encoder DT : GP7
# Encoder SW : GP8 (Druckknopf des Encoders, optional)
# OLED SSD1306 : I2C0 SDA=GP0, SCL=GP1, + 3V3 und GND (Display: 128x32)
# LED 1..4 : GP9, GP10, GP11, GP12 -> je ueber ~220-330 Ohm Vorwider-
# stand an die LED (langes Bein/Anode), kurzes Bein/Kathode
# an GND. Modus umschaltbar im "LEDs"-Profil: durchgehend
# an/aus (mit Blink-Feedback), Lauflicht im Kreis, oder
# gemeinsames sanftes Pulsieren (PWM).
#
# GND ist am Pico mehrfach vorhanden und intern ueberall verbunden - pro
# Gruppe reicht ein gemeinsamer GND-Pin (kurze Wege, physische Pin-Nummern
# am Board, nicht GPIO-Nummern):
# Display -> physischer Pin 3 (direkt neben GP0/GP1)
# Taster -> physischer Pin 8 (direkt nach GP5)
# Encoder -> physischer Pin 13 (direkt nach GP8)
# LEDs -> physischer Pin 18 (direkt nach GP12)
#
# Beim ersten main.py-Start trennt sich der Pico kurz vom USB und meldet
# sich neu an - einmal als Tastatur UND als Media-Control-Geraet. Das ist
# normal.
#
# Hinweis App-Start-Profile ("open"-Aktionen): die simulieren Win-Taste +
# Tippen des Programmnamens + Enter, wie am Rechner von Hand. Das setzt
# Windows voraus (Startmenue-Suche) und dass der Pico gerade am PC steckt,
# dessen Fokus auf dem Desktop liegt (kein Textfeld o.ae. offen), sonst
# landet der getippte Text an der falschen Stelle.
import time
import random
from array import array
from machine import Pin, I2C, PWM
import usb.device
from usb.device.keyboard import KeyboardInterface, KeyCode
from usb.device.hid import HIDInterface
import ssd1306
# ---------------------------------------------------------------------------
# USB-HID: Tastatur-Interface
# ---------------------------------------------------------------------------
kb = KeyboardInterface()
# ---------------------------------------------------------------------------
# USB-HID: eigenes Consumer-Control-Interface fuer Media-Tasten
# (Lautstaerke, Play/Pause, Stop, ...) - das kann die normale Tastatur-
# Klasse nicht, dafuer braucht es eine eigene HID-Usage-Page.
# ---------------------------------------------------------------------------
_INTERFACE_PROTOCOL_NONE = 0
# 16-bit "Consumer Control Array" Report-Descriptor - Standardmuster, das
# von Windows/Linux/Mac ohne Zusatztreiber als Media-Tasten erkannt wird.
# fmt: off
_CONSUMER_REPORT_DESC = (
b'\x05\x0C' # Usage Page (Consumer)
b'\x09\x01' # Usage (Consumer Control)
b'\xA1\x01' # Collection (Application)
b'\x15\x00' # Logical Minimum (0)
b'\x26\xFF\x03' # Logical Maximum (0x3FF)
b'\x19\x00' # Usage Minimum (0)
b'\x2A\xFF\x03' # Usage Maximum (0x3FF)
b'\x75\x10' # Report Size (16)
b'\x95\x01' # Report Count (1)
b'\x81\x00' # Input (Data, Array, Absolute)
b'\xC0' # End Collection
)
# fmt: on
class ConsumerControlInterface(HIDInterface):
def __init__(self):
super().__init__(
_CONSUMER_REPORT_DESC,
protocol=_INTERFACE_PROTOCOL_NONE,
interface_str="MicroPython Media Keys",
)
def send_code(self, code, timeout_ms=100):
report = bytearray(2)
report[0] = code & 0xFF
report[1] = (code >> 8) & 0xFF
return self.send_report(report, timeout_ms)
media = ConsumerControlInterface()
class MediaCode:
VOLUME_UP = 0x00E9
VOLUME_DOWN = 0x00EA
MUTE = 0x00E2
PLAY_PAUSE = 0x00CD
STOP = 0x00B7
NEXT_TRACK = 0x00B5
PREV_TRACK = 0x00B6
M = MediaCode
# ---------------------------------------------------------------------------
# Beide HID-Interfaces zusammen registrieren (NICHT zwei separate init()-
# Aufrufe - das wuerde die erste Schnittstelle wieder entfernen).
# ---------------------------------------------------------------------------
usb.device.get().init(kb, media, builtin_driver=True)
# Kurz warten, bis der Host (PC) die neuen HID-Schnittstellen akzeptiert hat
_t0 = time.ticks_ms()
while not (kb.is_open() and media.is_open()):
time.sleep_ms(100)
if time.ticks_diff(time.ticks_ms(), _t0) > 5000:
break # nach 5s trotzdem weitermachen, falls is_open() nie True wird
def press(keys, hold_ms=15):
"""Sendet eine Tastenkombination (Liste aus KeyCode-Werten) und laesst
sie sofort wieder los. Modifier (Strg/Alt/Shift) sind in der gleichen
Liste einfach mit dabei."""
if not keys:
return
kb.send_keys(keys)
time.sleep_ms(hold_ms)
kb.send_keys(()) # alle Tasten wieder loslassen
def press_media(code, hold_ms=20):
"""Sendet einen Media-Tastendruck (Lautstaerke, Play/Pause, ...)."""
if code is None:
return
media.send_code(code)
time.sleep_ms(hold_ms)
media.send_code(0)
# ---------------------------------------------------------------------------
# Text tippen (fuer die Windows-Suche) - einfache US/DE-QWERTZ-naheliegende
# Zuordnung fuer Buchstaben, Ziffern, Leerzeichen und ein paar Satzzeichen.
# Reicht fuer Programmnamen wie "Opera GX" oder "Visual Studio Code".
# ---------------------------------------------------------------------------
K = KeyCode # kurzer Alias
_PLAIN_SYMBOLS = {
" ": K.SPACE,
"-": K.MINUS,
"=": K.EQUAL,
".": K.DOT,
",": K.COMMA,
"/": K.SLASH,
";": K.SEMICOLON,
"'": K.QUOTE,
}
_SHIFT_SYMBOLS = {
"_": K.MINUS,
"+": K.EQUAL,
":": K.SEMICOLON,
'"': K.QUOTE,
"?": K.SLASH,
}
def _char_to_keys(ch):
if "a" <= ch <= "z":
return [getattr(K, ch.upper())]
if "A" <= ch <= "Z":
return [K.LEFT_SHIFT, getattr(K, ch)]
if "0" <= ch <= "9":
return [getattr(K, "N" + ch)]
if ch in _PLAIN_SYMBOLS:
return [_PLAIN_SYMBOLS[ch]]
if ch in _SHIFT_SYMBOLS:
return [K.LEFT_SHIFT, _SHIFT_SYMBOLS[ch]]
return None # unbekanntes Zeichen wird einfach uebersprungen
def type_text(text, char_delay_ms=12):
for ch in text:
keys = _char_to_keys(ch)
if keys is None:
continue
kb.send_keys(keys)
time.sleep_ms(char_delay_ms)
kb.send_keys(())
time.sleep_ms(char_delay_ms)
def open_app(query, delay_after_win_ms=350, delay_before_enter_ms=150):
"""Oeffnet die Windows-Suche (Win-Taste alleine) und tippt den
Suchbegriff, dann Enter. Die App muss im Startmenue auffindbar sein -
Anheften an die Taskleiste ist NICHT noetig."""
kb.send_keys([K.LEFT_UI])
time.sleep_ms(50)
kb.send_keys(())
time.sleep_ms(delay_after_win_ms)
type_text(query)
time.sleep_ms(delay_before_enter_ms)
press([K.ENTER])
def do_action(action):
"""Fuehrt eine Profil-Aktion aus. action ist ein dict mit "type":
"keys" | "media" | "open" | "led_mode" | "screensaver" | "none"."""
kind = action.get("type", "none")
if kind == "keys":
press(action["keys"])
elif kind == "media":
press_media(action["code"])
elif kind == "open":
open_app(action["query"])
elif kind == "led_mode":
set_led_mode(action["mode"])
elif kind == "screensaver":
set_screensaver_animation(action["animation"])
# "none" -> nichts tun
# ---------------------------------------------------------------------------
# Profile: Name, 4 Anzeige-Labels, 4 Aktionen
# ---------------------------------------------------------------------------
NONE_ACTION = {"type": "none"}
PROFILES = [
{
"name": "Browser",
"labels": ["NTab", "TabZu", "Zurck", "Vor"],
"actions": [
{"type": "keys", "keys": [K.LEFT_CTRL, K.T]},
{"type": "keys", "keys": [K.LEFT_CTRL, K.W]},
{"type": "keys", "keys": [K.LEFT_ALT, K.LEFT]},
{"type": "keys", "keys": [K.LEFT_ALT, K.RIGHT]},
],
},
{
"name": "Code",
"labels": ["Save", "Find", "Run", "Cmnt"],
"actions": [
{"type": "keys", "keys": [K.LEFT_CTRL, K.S]},
{"type": "keys", "keys": [K.LEFT_CTRL, K.F]},
{"type": "keys", "keys": [K.F5]},
{"type": "keys", "keys": [K.LEFT_CTRL, K.SLASH]},
],
},
{
"name": "Audio",
"labels": ["Vol+", "Vol-", "Stop", "Play"],
"actions": [
{"type": "media", "code": M.VOLUME_UP},
{"type": "media", "code": M.VOLUME_DOWN},
{"type": "media", "code": M.STOP},
{"type": "media", "code": M.PLAY_PAUSE},
],
},
{
"name": "Dev",
"labels": ["Opera", "Code", "Tbird", "Vault"],
"actions": [
{"type": "open", "query": "Opera GX"},
{"type": "open", "query": "Visual Studio Code"},
{"type": "open", "query": "Thunderbird"},
{"type": "open", "query": "Vaultwarden"},
],
},
{
"name": "Arbeit",
"labels": ["FFDev", "OVPN", "-", "-"],
"actions": [
{"type": "open", "query": "Firefox Developer Edition"},
{"type": "open", "query": "OpenVPN Connect"},
NONE_ACTION,
NONE_ACTION,
],
},
{
# Einstellungs-Profil: stellt den LED-Modus um, sendet keine
# HID-Aktionen.
"name": "LEDs",
"labels": ["An", "Aus", "Kreis", "Puls"],
"actions": [
{"type": "led_mode", "mode": "on"}, # Default an, blinkt aus bei Druck
{"type": "led_mode", "mode": "off"}, # Default aus, blinkt an bei Druck
{"type": "led_mode", "mode": "chase"}, # Lauflicht im Kreis (1->2->3->4->1->...)
{"type": "led_mode", "mode": "breathe"}, # alle 4 LEDs pulsieren gemeinsam
],
},
{
# Einstellungs-Profil: waehlt die Bildschirmschoner-Animation aus,
# die nach SCREENSAVER_TIMEOUT_MS Inaktivitaet startet.
"name": "Saver",
"labels": ["Stern", "Pacmn", "Matrx", "Bounc"],
"actions": [
{"type": "screensaver", "animation": "stars"},
{"type": "screensaver", "animation": "pacman"},
{"type": "screensaver", "animation": "matrix"},
{"type": "screensaver", "animation": "bounce"},
],
},
]
# ---------------------------------------------------------------------------
# Hardware: Taster
# ---------------------------------------------------------------------------
BUTTON_PINS = [2, 3, 4, 5]
buttons = [Pin(p, Pin.IN, Pin.PULL_UP) for p in BUTTON_PINS]
button_last_state = [1, 1, 1, 1]
button_last_change = [0, 0, 0, 0]
DEBOUNCE_MS = 30
# ---------------------------------------------------------------------------
# Hardware: LEDs (eine je Taste)
# ---------------------------------------------------------------------------
LED_PINS = [9, 10, 11, 12]
leds = [Pin(p, Pin.OUT) for p in LED_PINS]
led_pwms = [None, None, None, None] # nur im "breathe"-Modus belegt
# LED_MODE steuert das Verhalten aller LEDs - umschaltbar zur Laufzeit ueber
# das "LEDs"-Profil:
# "on" -> LEDs leuchten durchgehend, blinken beim Tastendruck kurz AUS
# "off" -> LEDs sind durchgehend aus, blinken beim Tastendruck kurz AN
# "chase" -> Lauflicht im Kreis: immer nur eine LED an, wandert reihum
# "breathe" -> alle 4 LEDs pulsieren gemeinsam sanft auf und ab (per PWM)
LED_MODE = "on"
# Geschwindigkeit der "chase"/"breathe"-Animation (Millisekunden pro Bild)
LED_ANIM_FRAME_MS = 150 # Lauflicht-Tempo (Zeit pro LED-Schritt)
LED_BREATHE_STEP_MS = 15 # Puls-Update-Rate (kleiner = weicheres Pulsieren)
LED_BREATHE_STEPS = 40 # Aufloesung einer Pulsphase (rauf ODER runter)
_led_anim_last_ms = 0
_led_chase_index = 0
_led_breathe_phase = 0
_led_breathe_dir = 1
def _led_pwm(i):
"""Erzeugt bei Bedarf ein PWM-Objekt fuer LED i (fuer den 'breathe'-
Modus) - der Pin wird dabei automatisch von digitalem Output auf PWM
umgeschaltet."""
if led_pwms[i] is None:
led_pwms[i] = PWM(Pin(LED_PINS[i]))
led_pwms[i].freq(1000)
return led_pwms[i]
def _release_led_pwms():
"""Gibt alle PWM-Objekte wieder frei und stellt die Pins zurueck auf
normalen digitalen Output - noetig beim Verlassen des 'breathe'-Modus."""
for i in range(len(LED_PINS)):
if led_pwms[i] is not None:
led_pwms[i].deinit()
led_pwms[i] = None
leds[i] = Pin(LED_PINS[i], Pin.OUT)
def apply_led_mode():
"""Setzt alle LEDs auf ihren aktuellen Default-Zustand (LED_MODE)."""
global _led_chase_index, _led_breathe_phase, _led_breathe_dir
if LED_MODE == "breathe":
for i in range(len(LED_PINS)):
_led_pwm(i).duty_u16(0)
_led_breathe_phase = 0
_led_breathe_dir = 1
return
_release_led_pwms()
if LED_MODE == "on":
for led in leds:
led.value(1)
elif LED_MODE == "off":
for led in leds:
led.value(0)
elif LED_MODE == "chase":
_led_chase_index = 0
for led in leds:
led.value(0)
def set_led_mode(mode):
"""Wechselt den LED-Modus ("on"/"off"/"chase"/"breathe") und wendet ihn
sofort an."""
global LED_MODE
if mode not in ("on", "off", "chase", "breathe"):
return
LED_MODE = mode
apply_led_mode()
apply_led_mode()
def _led_chase_frame():
"""Ein Schritt des Lauflichts - naechste LED an, Rest aus."""
global _led_chase_index
for i, led in enumerate(leds):
led.value(1 if i == _led_chase_index else 0)
_led_chase_index = (_led_chase_index + 1) % len(leds)
def _led_breathe_frame():
"""Ein Schritt der Puls-Animation - Dreieckswelle fuer sanftes Auf-
und Abblenden aller LEDs gemeinsam."""
global _led_breathe_phase, _led_breathe_dir
duty = int(65535 * _led_breathe_phase / LED_BREATHE_STEPS)
for i in range(len(LED_PINS)):
_led_pwm(i).duty_u16(duty)
_led_breathe_phase += _led_breathe_dir
if _led_breathe_phase >= LED_BREATHE_STEPS or _led_breathe_phase <= 0:
_led_breathe_dir = -_led_breathe_dir
def update_led_animation(now):
"""Von der Hauptschleife regelmaessig aufgerufen - treibt "chase" und
"breathe" nicht-blockierend an. Bei "on"/"off" passiert hier nichts."""
global _led_anim_last_ms
if LED_MODE == "chase":
interval = LED_ANIM_FRAME_MS
frame_fn = _led_chase_frame
elif LED_MODE == "breathe":
interval = LED_BREATHE_STEP_MS
frame_fn = _led_breathe_frame
else:
return
if time.ticks_diff(now, _led_anim_last_ms) >= interval:
_led_anim_last_ms = now
frame_fn()
def led_flash(i, flash_ms=120):
"""Kurzes Blink-Feedback fuer die LED der gedrueckten Taste - nur bei
"on"/"off" sinnvoll, bei "chase"/"breathe" laeuft die LED schon."""
if not (0 <= i < len(leds)):
return
if LED_MODE == "on":
leds[i].value(0)
time.sleep_ms(flash_ms)
leds[i].value(1)
elif LED_MODE == "off":
leds[i].value(1)
time.sleep_ms(flash_ms)
leds[i].value(0)
# "chase"/"breathe" -> keine zusaetzliche Blink-Rueckmeldung noetig
# ---------------------------------------------------------------------------
# Inaktivitaets-Erkennung fuer den Bildschirmschoner (Details weiter unten).
# Muss VOR dem Encoder-Interrupt initialisiert sein, da dieser beide
# Variablen bei jeder Drehung liest bzw. aktualisiert.
# ---------------------------------------------------------------------------
_last_activity_ms = time.ticks_ms()
_screensaver_active = False
# ---------------------------------------------------------------------------
# Hardware: Rotary Encoder (Quadratur per Interrupt)
# ---------------------------------------------------------------------------
encoder_clk = Pin(6, Pin.IN, Pin.PULL_UP)
encoder_dt = Pin(7, Pin.IN, Pin.PULL_UP)
encoder_sw = Pin(8, Pin.IN, Pin.PULL_UP)
# Auf True setzen, um die Drehrichtung umzukehren (falls "im Uhrzeigersinn"
# bei dir rueckwaerts durch die Profile blaettert statt vorwaerts).
ENCODER_REVERSED = True
# "Geschwindigkeit" des Encoders drosseln:
#
# ENCODER_STEPS_PER_CLICK: wie viele elektrische Encoder-Schritte noetig
# sind, bis EIN Profilwechsel ausgeloest wird. Viele billige Encoder
# liefern 2 oder 4 elektrische Impulse pro spuerbarer Raste/Klick - wenn
# der Encoder "zu schnell" durch die Profile springt (z.B. 2 Profile pro
# Klick), hier hochsetzen (2 oder 4 probieren).
#
# _ENCODER_DEBOUNCE_MS: minimaler Abstand zwischen zwei gewerteten Flanken
# in Millisekunden. Hilft gegen Kontaktprellen (mechanisches "Zittern"),
# das sonst wie zusaetzliche schnelle Drehungen wirkt. Bei Bedarf leicht
# erhoehen (z.B. 5-10), aber nicht zu hoch - sonst werden echte schnelle
# Drehungen verschluckt.
ENCODER_STEPS_PER_CLICK = 3
_ENCODER_DEBOUNCE_MS = 5
profile_index = 0
_encoder_last_change_ms = 0
_encoder_step_accum = 0
profile_dirty = True # erzwingt initiales Display-Zeichnen
def _encoder_irq(pin):
global profile_index, _encoder_last_change_ms, profile_dirty, _encoder_step_accum
global _last_activity_ms, _screensaver_active
now = time.ticks_ms()
if time.ticks_diff(now, _encoder_last_change_ms) < _ENCODER_DEBOUNCE_MS:
return
_encoder_last_change_ms = now
_last_activity_ms = now
if _screensaver_active:
# Screensaver per Drehung aufwecken - nur ein Flag setzen (kein
# I2C-Zugriff im Interrupt), das Zeichnen erledigt die Hauptschleife.
_screensaver_active = False
profile_dirty = True
forward = encoder_dt.value() != encoder_clk.value()
if ENCODER_REVERSED:
forward = not forward
_encoder_step_accum += 1 if forward else -1
if _encoder_step_accum >= ENCODER_STEPS_PER_CLICK:
profile_index = (profile_index + 1) % len(PROFILES)
_encoder_step_accum = 0
profile_dirty = True
elif _encoder_step_accum <= -ENCODER_STEPS_PER_CLICK:
profile_index = (profile_index - 1) % len(PROFILES)
_encoder_step_accum = 0
profile_dirty = True
encoder_clk.irq(trigger=Pin.IRQ_FALLING, handler=_encoder_irq)
# ---------------------------------------------------------------------------
# Encoder-Taster (Druckknopf des Encoders): frei belegbar, getrennt fuer
# kurzen und langen (gehaltenen) Druck. Verfuegbare Aktionsnamen:
#
# "home" -> springt zurueck zu Profil 0
# "next_profile" -> ein Profil weiter (wie einmal nach rechts drehen)
# "prev_profile" -> ein Profil zurueck (wie einmal nach links drehen)
# "toggle_leds" -> wechselt den LED_MODE zwischen "on" und "off"
# "toggle_screensaver" -> schaltet den Bildschirmschoner an/aus
# "mute" -> sendet Media-Mute
# "none" -> nichts tun
#
# Einfach unten die zwei Zeilen anpassen, um andere Kombinationen zu waehlen.
ENCODER_SW_SHORT_ACTION = "home"
ENCODER_SW_LONG_ACTION = "toggle_leds"
ENCODER_SW_LONG_PRESS_MS = 600 # ab wann ein Druck als "lang" zaehlt
def _do_encoder_sw_action(name):
global profile_index, profile_dirty, SCREENSAVER_ENABLED
if name == "home":
profile_index = 0
profile_dirty = True
elif name == "next_profile":
profile_index = (profile_index + 1) % len(PROFILES)
profile_dirty = True
elif name == "prev_profile":
profile_index = (profile_index - 1) % len(PROFILES)
profile_dirty = True
elif name == "toggle_leds":
set_led_mode("off" if LED_MODE == "on" else "on")
elif name == "toggle_screensaver":
SCREENSAVER_ENABLED = not SCREENSAVER_ENABLED
elif name == "mute":
press_media(M.MUTE)
# "none" -> nichts tun
sw_last_state = 1
sw_last_change = 0
_sw_press_start_ms = None
# ---------------------------------------------------------------------------
# Hardware: OLED
# ---------------------------------------------------------------------------
i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)
OLED_WIDTH = 128
OLED_HEIGHT = 32 # bei einem 128x64-Display hier auf 64 aendern
oled = ssd1306.SSD1306_I2C(OLED_WIDTH, OLED_HEIGHT, i2c)
# Layout fuer ein 128x32-Display: nur 3 Zeilen Platz (8px Zeichenhoehe).
# Zeile 0: Profilname
# Zeile 1: Taste 1 (links) / Taste 2 (rechts)
# Zeile 2: Taste 3 (links) / Taste 4 (rechts)
_COL_X = [0, 64] # linke und rechte Spalte
_ROW_Y = [10, 20] # y-Position der beiden Tastenzeilen
def draw_profile(active_button=None):
"""Zeichnet Profilname + 4 Tastenlabels. active_button (0-3) markiert
optional die gerade gedrueckte Taste mit '>'."""
p = PROFILES[profile_index]
oled.fill(0)
oled.text("P:" + p["name"][:11], 0, 0)
for i, label in enumerate(p["labels"]):
col = i % 2
row = i // 2
prefix = ">" if i == active_button else " "
# Spaltenbreite ist 64px = 8 Zeichen; "prefix+nummer+:" braucht 3,
# bleiben 5 Zeichen fuer das Label - kurz halten!
text = "{}{}:{}".format(prefix, i + 1, label[:5])
oled.text(text, _COL_X[col], _ROW_Y[row])
oled.show()
def flash_button(i):
draw_profile(active_button=i)
# ---------------------------------------------------------------------------
# Bildschirmschoner: startet nach SCREENSAVER_TIMEOUT_MS ohne Eingabe
# (Taste, Encoder drehen oder druecken). Die erste Eingabe danach weckt den
# Screen nur auf (wie beim Smartphone), erst die naechste loest die Aktion
# aus. Welche Animation laeuft, waehlst du im "Saver"-Profil.
# ---------------------------------------------------------------------------
SCREENSAVER_ENABLED = True
SCREENSAVER_TIMEOUT_MS = 30000 # 30s Inaktivitaet bis der Screensaver startet
SCREENSAVER_FRAME_MS = 80 # Bildwiederholrate der Animation
SCREENSAVER_ANIMATION = "stars" # "stars" | "pacman" | "matrix" | "bounce"
# _screensaver_active wird bereits weiter oben (vor dem Encoder-Interrupt)
# initialisiert.
_screensaver_last_frame_ms = 0
# --- Animation 1: Sternenhimmel - Sterne driften von rechts nach links ---
_NUM_STARS = 14
_stars = [
[random.randint(0, OLED_WIDTH - 1), random.randint(0, OLED_HEIGHT - 1)]
for _ in range(_NUM_STARS)
]
def _anim_stars_frame():
oled.fill(0)
for s in _stars:
s[0] -= 1
if s[0] < 0:
s[0] = OLED_WIDTH - 1
s[1] = random.randint(0, OLED_HEIGHT - 1)
oled.pixel(s[0], s[1], 1)
oled.show()
# --- Animation 2: Pac-Man laeuft quer durchs Display (mit Tunnel-Wrap) ---
_pacman_x = -10
_pacman_mouth_open = True
_PACMAN_R = 6
_PACMAN_Y = OLED_HEIGHT // 2
_pacman_supported = hasattr(oled, "ellipse") and hasattr(oled, "poly")
if _pacman_supported:
# Dreieck (Maulkeil), das rechts aus dem Kreis "herausgebissen" wird
_pacman_mouth = array("h", [0, 0, _PACMAN_R + 2, -_PACMAN_R, _PACMAN_R + 2, _PACMAN_R])
def _anim_pacman_frame():
global _pacman_x, _pacman_mouth_open
oled.fill(0)
x = _pacman_x
if _pacman_supported:
oled.ellipse(x, _PACMAN_Y, _PACMAN_R, _PACMAN_R, 1, True)
if _pacman_mouth_open:
oled.poly(x, _PACMAN_Y, _pacman_mouth, 0, True)
else:
# Fallback fuer Firmware ohne ellipse()/poly(): einfaches Quadrat
oled.fill_rect(x - _PACMAN_R, _PACMAN_Y - _PACMAN_R, _PACMAN_R * 2, _PACMAN_R * 2, 1)
oled.show()
_pacman_mouth_open = not _pacman_mouth_open
_pacman_x += 3
if _pacman_x > OLED_WIDTH + _PACMAN_R + 2:
_pacman_x = -(_PACMAN_R + 2)
# --- Animation 3: Matrix-Regen - fallende 0/1 je Zeichenspalte ---
_MATRIX_COLS = OLED_WIDTH // 8
_matrix_col_y = [random.randint(-32, 0) for _ in range(_MATRIX_COLS)]
def _anim_matrix_frame():
oled.fill(0)
for col in range(_MATRIX_COLS):
y = _matrix_col_y[col]
if 0 <= y < OLED_HEIGHT:
oled.text("1" if random.getrandbits(1) else "0", col * 8, y)
_matrix_col_y[col] += 8
if _matrix_col_y[col] > OLED_HEIGHT:
_matrix_col_y[col] = -8 - random.randint(0, 24)
oled.show()
# --- Animation 4: DVD-Bounce - "PICO" hopst von Wand zu Wand ---
_BOUNCE_TEXT = "PICO"
_bounce_w = len(_BOUNCE_TEXT) * 8
_bounce_h = 8
_bounce_x = 0
_bounce_y = 0
_bounce_dx = 2
_bounce_dy = 1
def _anim_bounce_frame():
global _bounce_x, _bounce_y, _bounce_dx, _bounce_dy
oled.fill(0)
_bounce_x += _bounce_dx
_bounce_y += _bounce_dy
if _bounce_x <= 0 or _bounce_x >= OLED_WIDTH - _bounce_w:
_bounce_dx = -_bounce_dx
_bounce_x = max(0, min(_bounce_x, OLED_WIDTH - _bounce_w))
if _bounce_y <= 0 or _bounce_y >= OLED_HEIGHT - _bounce_h:
_bounce_dy = -_bounce_dy
_bounce_y = max(0, min(_bounce_y, OLED_HEIGHT - _bounce_h))
oled.text(_BOUNCE_TEXT, _bounce_x, _bounce_y)
oled.show()
_SCREENSAVER_ANIMATIONS = {
"stars": _anim_stars_frame,
"pacman": _anim_pacman_frame,
"matrix": _anim_matrix_frame,
"bounce": _anim_bounce_frame,
}
def set_screensaver_animation(name):
"""Waehlt die aktive Screensaver-Animation ("stars"/"pacman"/"matrix"/
"bounce")."""
global SCREENSAVER_ANIMATION
if name in _SCREENSAVER_ANIMATIONS:
SCREENSAVER_ANIMATION = name
def _screensaver_frame():
fn = _SCREENSAVER_ANIMATIONS.get(SCREENSAVER_ANIMATION)
if fn:
fn()
def _wake_screensaver():
"""Beendet den Screensaver und zeichnet das normale Profil wieder."""
global _screensaver_active
_screensaver_active = False
draw_profile()
# ---------------------------------------------------------------------------
# Hauptschleife
# ---------------------------------------------------------------------------
draw_profile()
while True:
now = time.ticks_ms()
# LED-Animation ("chase"/"breathe") nicht-blockierend weiterlaufen lassen
update_led_animation(now)
# Bildschirmschoner: Inaktivitaet pruefen und ggf. naechstes Bild zeichnen
if SCREENSAVER_ENABLED and not _screensaver_active and time.ticks_diff(now, _last_activity_ms) >= SCREENSAVER_TIMEOUT_MS:
_screensaver_active = True
if _screensaver_active:
if time.ticks_diff(now, _screensaver_last_frame_ms) >= SCREENSAVER_FRAME_MS:
_screensaver_last_frame_ms = now
_screensaver_frame()
elif profile_dirty:
# Profilwechsel per Encoder anzeigen
profile_dirty = False
draw_profile()
# Tasten abfragen (entprellt)
for i, btn in enumerate(buttons):
state = btn.value()
if state != button_last_state[i] and time.ticks_diff(now, button_last_change[i]) > DEBOUNCE_MS:
button_last_change[i] = now
button_last_state[i] = state
if state == 0: # Taste gedrueckt (active low)
_last_activity_ms = now
if _screensaver_active:
# Erster Druck weckt nur auf, loest noch keine Aktion aus
_wake_screensaver()
else:
action = PROFILES[profile_index]["actions"][i]
flash_button(i)
led_flash(i)
do_action(action)
time.sleep_ms(120)
draw_profile()
# Encoder-Taster: kurzer/langer Druck loesen die konfigurierten
# Aktionen aus (siehe ENCODER_SW_SHORT_ACTION / ENCODER_SW_LONG_ACTION) -
# waehrend der Screensaver laeuft, weckt der Druck nur auf.
sw_state = encoder_sw.value()
if sw_state != sw_last_state and time.ticks_diff(now, sw_last_change) > DEBOUNCE_MS:
sw_last_change = now
sw_last_state = sw_state
if sw_state == 0:
# Taster wurde gedrueckt - Zeitpunkt merken
_last_activity_ms = now
_sw_press_start_ms = now
else:
# Taster wurde losgelassen - Druckdauer auswerten
if _sw_press_start_ms is not None:
held_ms = time.ticks_diff(now, _sw_press_start_ms)
_sw_press_start_ms = None
if _screensaver_active:
_wake_screensaver()
elif held_ms >= ENCODER_SW_LONG_PRESS_MS:
_do_encoder_sw_action(ENCODER_SW_LONG_ACTION)
else:
_do_encoder_sw_action(ENCODER_SW_SHORT_ACTION)
time.sleep_ms(5)