# =============================================================================
# STREAM DECK DIY — Raspberry Pi Pico (MicroPython)
# =============================================================================
#
# CÂBLAGE MATÉRIEL :
# Boutons : BTN1=GP2 BTN2=GP3 BTN3=GP4 BTN4=GP5
# BTN5=GP6 BTN6=GP7 BTN7=GP8 BTN8=GP9
# Encodeur : CLK=GP10 DT=GP11 SW=GP12
# OLED I2C : SDA=GP0 SCL=GP1 (adresse 0x3C)
#
# INSTALLATION DES DÉPENDANCES (dans Thonny → Outils → REPL) :
# import mip
# mip.install("ssd1306")
# mip.install("github:micropython/micropython-lib/micropython/usb/usb-device-keyboard")
#
# VERSION MICROPYTHON REQUISE : >= 1.22.0
# REMARQUE : Sans la lib USB HID, le code démarre en mode SIMULATION
# (les actions sont affichées dans la console).
# =============================================================================
import machine
from machine import Pin, I2C
import utime
import ssd1306
# =============================================================================
# SECTION 1 — KEYCODES & MODIFICATEURS HID
# =============================================================================
class KC:
"""
Codes de touches HID (USB HID Usage Tables 1.12 — Keyboard/Keypad Page).
Référence : https://www.usb.org/sites/default/files/hut1_3_0.pdf
"""
# ── Lettres ──────────────────────────────────────────────
A=0x04; B=0x05; C=0x06; D=0x07; E=0x08; F=0x09; G=0x0A
H=0x0B; I=0x0C; J=0x0D; K=0x0E; L=0x0F; M=0x10; N=0x11
O=0x12; P=0x13; Q=0x14; R=0x15; S=0x16; T=0x17; U=0x18
V=0x19; W=0x1A; X=0x1B; Y=0x1C; Z=0x1D
# ── Chiffres ─────────────────────────────────────────────
N1=0x1E; N2=0x1F; N3=0x20; N4=0x21; N5=0x22
N6=0x23; N7=0x24; N8=0x25; N9=0x26; N0=0x27
# ── Touches spéciales ────────────────────────────────────
ENTER = 0x28
ESC = 0x29
BACKSPACE= 0x2A
TAB = 0x2B
SPACE = 0x2C
MINUS = 0x2D
EQUAL = 0x2E
# ── Touches de fonction ──────────────────────────────────
F1 =0x3A; F2 =0x3B; F3 =0x3C; F4 =0x3D
F5 =0x3E; F6 =0x3F; F7 =0x40; F8 =0x41
F9 =0x42; F10=0x43; F11=0x44; F12=0x45
# ── Navigation ───────────────────────────────────────────
HOME=0x4A; END=0x4D; PAGE_UP=0x4B; PAGE_DOWN=0x4E
LEFT=0x50; RIGHT=0x4F; UP=0x52; DOWN=0x51
DELETE=0x4C; INSERT=0x49
# ── Pavé numérique ───────────────────────────────────────
NUM_LOCK=0x53; KP_SLASH=0x54; KP_STAR=0x55
KP_MINUS=0x56; KP_PLUS=0x57; KP_ENTER=0x58
class MOD:
"""Codes de modificateurs HID (bitmask)."""
NONE = 0x00
CTRL = 0x01 # Left Control
SHIFT = 0x02 # Left Shift
ALT = 0x04 # Left Alt
GUI = 0x08 # Left GUI (Windows/Cmd)
RCTRL = 0x10 # Right Control
RSHIFT = 0x20 # Right Shift
RALT = 0x40 # Right Alt (AltGr)
RGUI = 0x80 # Right GUI
CTRL_SHIFT = 0x03
CTRL_ALT = 0x05
SHIFT_ALT = 0x06
CTRL_SHIFT_ALT = 0x07
# =============================================================================
# SECTION 2 — COUCHE USB HID (avec fallback simulation)
# =============================================================================
def _make_fake_keyboard():
"""Crée un clavier factice qui imprime les actions dans la console."""
MOD_NAMES = {
0x00: "", 0x01: "CTRL+", 0x02: "SHIFT+",
0x03: "CTRL+SHIFT+", 0x04: "ALT+", 0x05: "CTRL+ALT+",
0x06: "SHIFT+ALT+", 0x08: "GUI+",
}
class FakeKeyboard:
def send_key(self, modifier=0x00, keycode=0x00):
mod = MOD_NAMES.get(modifier, f"MOD({modifier:#04x})+")
print(f" [HID-SIM] {mod}KEY({keycode:#04x})")
return FakeKeyboard()
# ── Tentative d'initialisation USB HID réelle ────────────────
_hid_keyboard = None
HID_AVAILABLE = False
try:
import usb.device
from usb.device.keyboard import KeyboardInterface
_kbd_iface = KeyboardInterface()
usb.device.get().init(_kbd_iface, builtin_driver=True)
utime.sleep_ms(500) # Attente énumération USB
class _RealKeyboard:
def send_key(self, modifier=0x00, keycode=0x00):
_kbd_iface.send_keys([keycode], modifier)
utime.sleep_ms(30) # Durée d'appui simulée
_kbd_iface.send_keys([]) # Relâchement
_hid_keyboard = _RealKeyboard()
HID_AVAILABLE = True
print("[HID] ✓ USB Keyboard initialisé")
except Exception as e:
_hid_keyboard = _make_fake_keyboard()
HID_AVAILABLE = False
print(f"[HID] Non disponible ({e})")
print("[HID] → Mode SIMULATION actif (sorties console uniquement)")
def send_key(modifier: int, keycode: int):
"""
Envoie une frappe clavier (appui + relâchement).
Fonctionne en mode HID réel ou en simulation console.
Args:
modifier : Combinaison de bits MOD.* (ex: MOD.CTRL_SHIFT)
keycode : Code de touche KC.* (ex: KC.S)
"""
_hid_keyboard.send_key(modifier, keycode)
# =============================================================================
# SECTION 3 — MAPPINGS DES MODES
# =============================================================================
#
# FORMAT PAR BOUTON : (étiquette_oled, modificateur, keycode)
#
# ┌─────────────────────────────────────────────────────────────┐
# │ MODIFIER ICI pour changer les raccourcis clavier ! │
# │ Chaque entrée = un bouton (BTN1 à BTN8). │
# └─────────────────────────────────────────────────────────────┘
MODES = {
# ── Mode 1 : Communication (Discord / Teams) ─────────────
"LAUNCHER": [
# Étiquette Modificateur Touche # Description
("CHROME", MOD.CTRL_SHIFT, KC.M), # Discord : Mute/Unmute micro
("SPEECY", MOD.CTRL_SHIFT, KC.D), # Discord : Sourd/Entendre
("UTORRENT", MOD.CTRL_SHIFT, KC.C), # Discord/Teams : Caméra on/off
("DOSSIER", MOD.CTRL_SHIFT, KC.S), # Discord/Teams : Partage d'écran
("PARAMETRE", MOD.CTRL_SHIFT, KC.J), # Discord : Rejoindre un salon vocal
("BLUETOOTH", MOD.CTRL_SHIFT, KC.Q), # Discord : Quitter le salon vocal
("WIFI", MOD.CTRL_SHIFT, KC.N), # Discord : Activer/Couper notifs
("ALT + F4", MOD.CTRL_SHIFT, KC.E), # Discord/Teams : Ouvrir sélecteur emoji
],
# ── Mode 2 : Jeux vidéo ──────────────────────────────────
"CREATIVE": [
# Étiquette Modificateur Touche # Description
("VISUAL CODE", MOD.NONE, KC.Z), # Avancer (ZQSD)
("PHOTOSHOP", MOD.NONE, KC.Q), # Aller à gauche
("ILLUSTRATOR", MOD.NONE, KC.S), # Reculer
("DOSSIER", MOD.NONE, KC.D), # Aller à droite
("CHROME", MOD.NONE, KC.SPACE), # Sauter
("RPG MAKER", MOD.NONE, KC.E), # Interagir
("FILEZILLA", MOD.SHIFT, KC.Z), # Sprint (Shift+Z)
("ALT + F4", MOD.NONE, KC.M), # Afficher la carte
],
# ── Mode 3 : Contrôle Média ──────────────────────────────
"GAMING": [
# Étiquette Modificateur Touche # Description
("STEAM", MOD.NONE, KC.F8), # Play / Pause
("EPIC GAME", MOD.NONE, KC.F7), # Piste précédente
("ORIGIN", MOD.NONE, KC.F9), # Piste suivante
("MINECRAFT", MOD.NONE, KC.F6), # Arrêt lecture
("UBISOFT", MOD.NONE, KC.F10), # Mute / Unmute
("", MOD.NONE, KC.F1), # Navigateur
("", MOD.NONE, KC.F2), # Client mail
("ALT + F4", MOD.NONE, KC.F3), # Calculatrice
],
}
# Liste ordonnée des noms de modes (pour la rotation)
# ┌──────────────────────────────────────────────────────────────┐
# │ MODIFIER ICI pour changer l'ordre ou ajouter un mode. │
# │ Le premier élément est le mode au démarrage. │
# └──────────────────────────────────────────────────────────────┘
MODE_LIST = ["LAUNCHER", "CREATIVE", "GAMING"]
# =============================================================================
# SECTION 4 — CLASSE BOUTON (anti-rebond + appui long/court)
# =============================================================================
DEBOUNCE_MS = 50 # Délai anti-rebond en millisecondes
LONG_PRESS_MS = 700 # Durée minimale pour un "appui long"
class Button:
"""
Gestion complète d'un bouton poussoir avec :
- Anti-rebond logiciel
- Détection appui court (< LONG_PRESS_MS)
- Détection appui long (>= LONG_PRESS_MS)
"""
def __init__(self, pin_num: int):
self._pin = Pin(pin_num, Pin.IN, Pin.PULL_UP)
self._prev_state = 1 # 1 = relâché (PULL_UP actif)
self._press_time = 0 # Timestamp du début d'appui
self._last_edge = 0 # Timestamp du dernier front
def update(self):
"""
Lit l'état du bouton.
Returns:
'short' si appui court vient d'être relâché
'long' si appui long vient d'être relâché
None si aucun événement
"""
now = utime.ticks_ms()
state = self._pin.value()
# Ignorer les changements trop rapides (anti-rebond)
if utime.ticks_diff(now, self._last_edge) < DEBOUNCE_MS:
return None
event = None
if state == 0 and self._prev_state == 1:
# ↓ Front descendant → début d'appui
self._press_time = now
self._last_edge = now
self._prev_state = 0
elif state == 1 and self._prev_state == 0:
# ↑ Front montant → relâchement
duration = utime.ticks_diff(now, self._press_time)
event = 'long' if duration >= LONG_PRESS_MS else 'short'
self._last_edge = now
self._prev_state = 1
return event
# =============================================================================
# SECTION 5 — CLASSE ENCODEUR ROTATIF KY-040
# =============================================================================
class RotaryEncoder:
"""
Gestion de l'encodeur rotatif KY-040.
- CLK + DT → détection rotation (sens + vitesse)
- SW → appui court / long (via classe Button)
"""
ENC_DEBOUNCE_MS = 4 # Debounce spécifique à l'encodeur (très court)
def __init__(self, clk_pin: int, dt_pin: int, sw_pin: int):
self._clk = Pin(clk_pin, Pin.IN, Pin.PULL_UP)
self._dt = Pin(dt_pin, Pin.IN, Pin.PULL_UP)
self._sw = Button(sw_pin)
self._last_clk = self._clk.value()
self._last_enc = 0 # Timestamp dernier top encodeur
def update(self) -> dict:
"""
Lit l'encodeur.
Returns:
dict {
'rotation': +1 (horaire) | -1 (anti-horaire) | 0,
'click' : 'short' | 'long' | None
}
"""
result = {'rotation': 0, 'click': None}
now = utime.ticks_ms()
# ── Rotation ─────────────────────────────────────────
clk = self._clk.value()
if clk != self._last_clk:
if utime.ticks_diff(now, self._last_enc) > self.ENC_DEBOUNCE_MS:
dt = self._dt.value()
result['rotation'] = 1 if (dt != clk) else -1
self._last_enc = now
self._last_clk = clk
# ── Clic ─────────────────────────────────────────────
result['click'] = self._sw.update()
return result
# =============================================================================
# SECTION 6 — CLASSE AFFICHAGE OLED SSD1306 (128×64)
# =============================================================================
class OLEDDisplay:
"""
Gestion de l'affichage OLED SSD1306 128×64 px.
Layout de l'écran :
┌────────────────────────────────┐ y=0
│ GAMING [1/3] │ En-tête mode
├────────────────────────────────┤ y=10 (hline)
│ 1:AVANC 5:SAUT │ y=13
│ 2:GAUCH 6:ACTIO │ y=25
│ 3:RECUL 7:SPRIN │ y=37
│ 4:DROIT 8:CARTE │ y=49
│ [HID] │ y=56
└────────────────────────────────┘
"""
W = 128; H = 64
CW = 8; CH = 8 # Taille d'un caractère (police par défaut)
def __init__(self, sda_pin=0, scl_pin=1, i2c_addr=0x3C):
i2c = I2C(0, sda=Pin(sda_pin), scl=Pin(scl_pin), freq=400_000)
self._oled = ssd1306.SSD1306_I2C(self.W, self.H, i2c, addr=i2c_addr)
self._oled.fill(0)
self._oled.show()
@staticmethod
def _pad(text: str, n: int) -> str:
text = text[:n]
while len(text) < n:
text += " "
return text
def render(self, mode_name: str, mode_idx: int, total_modes: int,
btn_labels: list, hid_ok: bool):
"""
Rafraîchit l'écran entier.
Args:
mode_name : Nom du mode courant (ex: "GAMING")
mode_idx : Index courant (0-based)
total_modes : Nombre total de modes
btn_labels : Liste de 8 chaînes (étiquettes boutons)
hid_ok : True si USB HID actif
"""
o = self._oled
o.fill(0)
# ── Ligne 1 : Nom du mode ─────────────────────────────
badge = f"[{mode_idx + 1}/{total_modes}]"
text = mode_name
text_width = len(text) * self.CW
x = (self.W - text_width) // 2
o.text(text, x, 0, 1)
# ── Séparateur ───────────────────────────────────────
o.hline(0, 10, self.W, 1)
# ── Grille boutons 2×4 ───────────────────────────────
# Colonne gauche : BTN1-4 (x=0), Colonne droite : BTN5-8 (x=64)
for i in range(8):
col = i // 4 # 0 = gauche, 1 = droite
row = i % 4 # 0..3
x = col * 64
y = 13 + row * 13
lbl = btn_labels[i] if i < len(btn_labels) else "---"
cell = f"{i+1}:{self._pad(lbl, 5)}" # Ex: "1:AVANC"
o.text(cell, x, y, 1)
o.show()
def show_message(self, line1: str, line2: str = ""):
"""Affiche un message temporaire centré (utile au démarrage)."""
o = self._oled
o.fill(0)
o.text(line1[:16], max(0, (16 - len(line1)) * self.CW // 2), 24, 1)
if line2:
o.text(line2[:16], max(0, (16 - len(line2)) * self.CW // 2), 36, 1)
o.show()
# =============================================================================
# SECTION 7 — GESTIONNAIRE DE MODES
# =============================================================================
class ModeManager:
"""Gère la navigation entre les modes et l'exécution des actions."""
def __init__(self):
self._idx = 0 # Index du mode actif
self._dirty = True # True = écran doit être rafraîchi
# ── Propriétés ───────────────────────────────────────────
@property
def name(self) -> str:
return MODE_LIST[self._idx]
@property
def index(self) -> int:
return self._idx
@property
def total(self) -> int:
return len(MODE_LIST)
@property
def needs_refresh(self) -> bool:
return self._dirty
@property
def btn_labels(self) -> list:
return [entry[0] for entry in MODES[self.name]]
# ── Navigation ───────────────────────────────────────────
def next_mode(self):
"""Passe au mode suivant (cyclique)."""
self._idx = (self._idx + 1) % len(MODE_LIST)
self._dirty = True
print(f"[MODE] → {self.name} ({self._idx + 1}/{self.total})")
def go_to_mode(self, idx: int):
"""Saute directement à un mode par son index."""
self._idx = idx % len(MODE_LIST)
self._dirty = True
print(f"[MODE] Saut → {self.name}")
def mark_refreshed(self):
"""Signale que l'écran a été mis à jour."""
self._dirty = False
# ── Exécution des actions ────────────────────────────────
def execute_button(self, btn_idx: int, press_type: str):
"""
Exécute l'action associée à un bouton dans le mode courant.
Args:
btn_idx : Index du bouton (0-7)
press_type : 'short' ou 'long'
"""
mapping = MODES[self.name]
if btn_idx >= len(mapping):
return
label, modifier, keycode = mapping[btn_idx]
if press_type == 'short':
print(f" [BTN{btn_idx + 1}] {label:10s} → {self.name}")
send_key(modifier, keycode)
elif press_type == 'long':
# Appui long par défaut : même touche + modificateur Shift ajouté
# → Personnalisable selon vos besoins
long_mod = modifier | MOD.SHIFT
print(f" [BTN{btn_idx + 1}] {label:10s} (LONG) → SHIFT+action")
send_key(long_mod, keycode)
# =============================================================================
# SECTION 8 — CONTRÔLEUR DE VOLUME
# =============================================================================
class VolumeController:
"""
Envoie des commandes de volume via des touches clavier.
(F11 = Volume−, F12 = Volume+ sur la plupart des systèmes)
"""
REPEAT_DELAY_MS = 80 # Délai minimum entre deux pas de volume
def __init__(self):
self._last_tick = 0
def _can_send(self) -> bool:
now = utime.ticks_ms()
if utime.ticks_diff(now, self._last_tick) >= self.REPEAT_DELAY_MS:
self._last_tick = now
return True
return False
def up(self):
if self._can_send():
send_key(MOD.NONE, KC.F12)
print(" [VOL] ▲ +")
def down(self):
if self._can_send():
send_key(MOD.NONE, KC.F11)
print(" [VOL] ▼ −")
# =============================================================================
# SECTION 9 — INITIALISATION DU MATÉRIEL
# =============================================================================
def init_hardware():
"""
Instancie et retourne tous les composants.
Returns:
tuple (buttons, encoder, display, mode_mgr, volume)
"""
# 8 boutons sur GP2..GP9
button_pins = [2, 3, 4, 5, 6, 7, 8, 9]
buttons = [Button(p) for p in button_pins]
encoder = RotaryEncoder(clk_pin=10, dt_pin=11, sw_pin=12)
display = OLEDDisplay(sda_pin=0, scl_pin=1)
mode_mgr = ModeManager()
volume = VolumeController()
print("[INIT] Matériel initialisé avec succès")
return buttons, encoder, display, mode_mgr, volume
# =============================================================================
# SECTION 10 — BOUCLE PRINCIPALE
# =============================================================================
def main():
print("=" * 48)
print(" STREAM DECK DIY — Raspberry Pi Pico")
print("=" * 48)
print(f" USB HID : {'ACTIF ✓' if HID_AVAILABLE else 'SIMULATION (console)'}")
print(f" Modes : {' / '.join(MODE_LIST)}")
print(f" Boutons : GP2–GP9 | Encodeur : GP10–GP12")
print(f" OLED I2C : SDA=GP0 SCL=GP1")
print("=" * 48)
# ── Initialisation ───────────────────────────────────────
buttons, encoder, display, mode_mgr, volume = init_hardware()
# Écran de bienvenue (1 seconde)
display.show_message("STREAM DECK", "Initialisation...")
utime.sleep_ms(1000)
# Premier rendu complet
display.render(
mode_mgr.name, mode_mgr.index, mode_mgr.total,
mode_mgr.btn_labels, HID_AVAILABLE
)
mode_mgr.mark_refreshed()
print("\n[MAIN] Démarrage de la boucle principale...\n")
# ── Boucle infinie ───────────────────────────────────────
while True:
# ────────────────────────────────────────────────────
# 1. LECTURE DES 8 BOUTONS
# ────────────────────────────────────────────────────
for i, btn in enumerate(buttons):
event = btn.update()
if event: # 'short' ou 'long'
mode_mgr.execute_button(i, event)
# ────────────────────────────────────────────────────
# 2. LECTURE DE L'ENCODEUR ROTATIF
# ────────────────────────────────────────────────────
enc = encoder.update()
# Rotation → Volume
if enc['rotation'] == 1:
volume.up()
elif enc['rotation'] == -1:
volume.down()
# Clic court → mode suivant
if enc['click'] == 'short':
mode_mgr.next_mode()
# Clic long → retour au mode 1 (GAMING)
elif enc['click'] == 'long':
mode_mgr.go_to_mode(0)
print("[ENC] Clic LONG → retour au mode 1")
# ────────────────────────────────────────────────────
# 3. RAFRAÎCHISSEMENT OLED (uniquement si besoin)
# ────────────────────────────────────────────────────
if mode_mgr.needs_refresh:
display.render(
mode_mgr.name, mode_mgr.index, mode_mgr.total,
mode_mgr.btn_labels, HID_AVAILABLE
)
mode_mgr.mark_refreshed()
# ────────────────────────────────────────────────────
# 4. PAUSE (évite un busy-loop à 100% CPU)
# ────────────────────────────────────────────────────
utime.sleep_ms(5)
# =============================================================================
# POINT D'ENTRÉE
# =============================================================================
if __name__ == "__main__":
main()