Init
This commit is contained in:
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.
@@ -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)
|
||||
Reference in new issue
Block a user