diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..70c4c21 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog — Raspberry Pi Audio Streamer + +Format zgodny z [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +Wersjonowanie zgodne z `0.xxx` (pre‑alpha). + +--- + +## [0.011a] - 2026-02-11 +### Dodano +- EQ +- Web server na porcie 8080 + +### +- nadal problemy z Enkoderem i EQ + +## [0.010a] — 2026‑02‑10 +### Dodano +- Utworzono pełną strukturę projektu (`audio/`, `ui/`, `hardware/`, `utils/`, `scripts/`). +- Dodano instalator `install.sh` z wyborem trybu (instalacja/aktualizacja). +- Dodano logger z przełącznikiem `ENABLE_LOGGER`. +- Dodano inteligentne wykrywanie sprzętu (DAC, OLED, BT, Wi‑Fi). +- Dodano bezpieczne restartowanie usług (tylko jeśli istnieją). +- Dodano komplet skryptów instalacyjnych w `scripts/`. + +### Zmieniono +- kompletnie przebudowano projekt od nowa +- Uproszczono logikę instalatora — jedno pytanie na start. +- Ujednolicono komunikaty instalatora (OK / WARN / ERROR). + +### Znane problemy +- Brak pełnej konfiguracji CamillaDSP (placeholder). +- Brak usługi OLED (zostanie dodana po implementacji UI). + +--- + +## [0.000] — 2026‑02‑01 +### Start projektu +- Utworzenie repozytorium. +- Wstępne założenia funkcjonalne. diff --git a/Docs/Pinout.md b/Docs/Pinout.md index 906ab34..b1d7e40 100644 --- a/Docs/Pinout.md +++ b/Docs/Pinout.md @@ -1,4 +1,4 @@ -# Pinout – Streamer v0.07a1 (wersja minimalna) +# Pinout – Streamer v0.010a1 (wersja minimalna) ## Raspberry Pi – sygnały krytyczne @@ -8,19 +8,33 @@ | I2C SCL (OLED) | 3 | 5 | I2C | jw. | | GND | — | 6 | Masa | Wspólna masa | | 1-Wire (DS18B20) | 4 | 7 | 1-Wire | Czujniki temperatury | -| Enkoder A | 17 | 11 | GPIO | Enkoder 1 – sygnał A | -| Enkoder B | 27 | 13 | GPIO | Enkoder 1 – sygnał B | -| Enkoder SW | 22 | 15 | GPIO | Klik enkodera | +| Enkoder A | 24 | 18 | GPIO | Enkoder 1 – sygnał A | +| Enkoder B | 23 | 16 | GPIO | Enkoder 1 – sygnał B | +| Enkoder SW | 13 | 33 | GPIO | Klik enkodera | | IR odbiornik | 25 | 22 | GPIO | TSOP / VS1838B | | GND | — | 9/14/20/25 | Masa | Użyj kilku dla stabilności | +Pin GPIO Funkcja +29 GPIO5 BTN_POWER +31 GPIO6 BTN_STOP +32 GPIO12 BTN_PLAY/PAUSE +35 GPIO19 BTN_NEXT +37 GPIO26 BTN_PREV + + +Pin GPIO Kolor +36 GPIO16 LED_R +38 GPIO20 LED_G +40 GPIO21 LED_B +Każdy kolor przez rezystor 150–330 Ω. + ## OLED - Magistrala I2C (SDA/SCL) - Adres: 0x3C ## MPD - Standardowa konfiguracja `/etc/mpd.conf` -- Upewnić się, że `/etc/default/mpd` zawiera: +- Upewnić się, że `/etc/default/mpd` zawiera: `MPDCONF=/etc/mpd.conf` ## Rezerwa diff --git a/Docs/Roadmap.md b/Docs/Roadmap.md index 4ca19c9..8a79e29 100644 --- a/Docs/Roadmap.md +++ b/Docs/Roadmap.md @@ -2,7 +2,7 @@ Ten dokument opisuje plan rozwoju projektu w kolejnych etapach wersjonowania. Każda główna gałąź wersji ma jasno określony cel, zakres oraz oczekiwany poziom stabilności. -v0.07a1 – aktualny etap +v0.010a1 – aktualny etap Pierwsza faza implementacji funkcjonalności wejściowych. W tej wersji pojawia się obsługa enkodera (A/B/SW), integracja z MPD oraz stabilizacja OLED. @@ -164,4 +164,4 @@ finalized enclosure, complete documentation. -This is the first public release of the project. \ No newline at end of file +This is the first public release of the project. diff --git a/README.md b/README.md index 44372bb..ee5d3be 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -<<<<<<< HEAD # Streamer Audio – Raspberry Pi I2S DAC + OLED Streamer Audio to otwarto‑źródłowy projekt odtwarzacza audio opartego na Raspberry Pi, z obsługą: @@ -62,22 +61,33 @@ Projekt jest rozwijany z naciskiem na: ### Instalacja przez `curl` ```bash -curl -s https://gitlab.com/aloisy/streamer/-/raw/master/start_install.sh -o install.sh +curl -s https://raw.githubusercontent.com/xtreamx2/streamer/Second/install.sh | bash | tee install.log chmod +x install.sh ./install.sh -streamer/ - ├── config/ - │ └── gpio.json - ├── logs/ - │ └── install.log - ├── media/ - │ └── test.wav - ├── installer/ - │ └── start_install.sh - ├── change_log - └── README.md +/streamer +│ +├── main.py # główny loop +├── config.py # ustawienia +│ +├── audio/ +│ ├── player.py # MPD/Spotify/BT/Radio +│ ├── dsp.py # EQ, loudness, filtry (CamillaDSP/ALSA) +│ └── volume.py # głośność (PCM5122 + soft) +│ +├── ui/ +│ ├── display.py # OLED +│ ├── menu.py # logika menu +│ └── encoder.py # enkoder + przyciski +│ +├── hardware/ +│ ├── relays.py # przekaźniki/tyrystory +│ ├── rtc.py # DS3231 (później) +│ └── power.py # standby, mute, itp. +│ +└── utils/ +└── logger.py # logi Projekt składa się z trzech warstw licencyjnych: @@ -119,7 +129,7 @@ Already a pro? Just edit this README.md and make it your own. Want to make it ea ``` cd existing_repo -git remote add origin https://gitlab.com/aloisy/streamer.git +git remote add origin https://raw.githubusercontent.com/xtreamx2/streamer/Second/install.sh git branch -M main git push -uf origin main ``` diff --git a/audio/dsp.py b/audio/dsp.py new file mode 100644 index 0000000..4169fd7 --- /dev/null +++ b/audio/dsp.py @@ -0,0 +1,90 @@ +# streamer/audio/dsp.py + +import json +import time +import subprocess +from pathlib import Path + +BASE = Path(__file__).resolve().parents[1] +EQ_CONFIG = BASE / "config" / "config-eq.json" +CAMILLA_CONFIG = Path("/etc/camilladsp/streamer.yml") + + +def load_cfg(): + return json.loads(EQ_CONFIG.read_text()) + + +def write_camilla_yaml(text): + CAMILLA_CONFIG.write_text(text) + + +def reload_camilla(): + subprocess.run(["systemctl", "reload", "camilladsp"], check=False) + + +def render_yaml(cfg): + mode = cfg["mode"] + presets = cfg["presets"] + c2 = cfg["custom2_profiles"] + c5 = cfg["custom5_profiles"] + loud = cfg["loudness"] + + if mode == "preset": + gains = presets[cfg["selected_preset"]] + + elif mode.startswith("custom2"): + key = mode[-1] + gains = { + "60": c2[key]["bass"], + "230": c2[key]["bass"], + "910": 0, + "3600": c2[key]["treble"], + "14000": c2[key]["treble"] + } + + elif mode.startswith("custom5"): + key = mode[-1] + gains = c5[key] + + else: + gains = presets["FLAT"] + + # YAML – uproszczony, pipeline dopasujesz później + out = ["filters:"] + for f, g in gains.items(): + out.append(f" peq_{f}:") + out.append(" type: Peq") + out.append(f" freq: {float(f)}") + out.append(" q: 1.0") + out.append(f" gain: {g}") + + if loud["enabled"]: + out.append(" loud_low:") + out.append(" type: Lowshelf") + out.append(" freq: 80") + out.append(" q: 0.7") + out.append(f" gain: {loud['strength'] / 10}") + + out.append(" loud_high:") + out.append(" type: Highshelf") + out.append(" freq: 8000") + out.append(" q: 0.7") + out.append(f" gain: {loud['strength'] / 15}") + + return "\n".join(out) + "\n" + + +def main(): + last = None + while True: + cfg = load_cfg() + if cfg != last: + yaml = render_yaml(cfg) + write_camilla_yaml(yaml) + reload_camilla() + last = cfg + time.sleep(1) + + +if __name__ == "__main__": + main() diff --git a/audio/player.py b/audio/player.py new file mode 100644 index 0000000..0b3e537 --- /dev/null +++ b/audio/player.py @@ -0,0 +1,14 @@ +from mpd import MPDClient + +class Player: + def __init__(self): + self.client = MPDClient() + self.client.connect("localhost", 6600) + + def play_radio(self, url): + self.client.clear() + self.client.add(url) + self.client.play() + + def stop(self): + self.client.stop() diff --git a/audio/volume.py b/audio/volume.py new file mode 100644 index 0000000..ce53e5d --- /dev/null +++ b/audio/volume.py @@ -0,0 +1,9 @@ +from mpd import MPDClient + +class Volume: + def __init__(self): + self.client = MPDClient() + self.client.connect("localhost", 6600) + + def set(self, value): + self.client.setvol(value) diff --git a/config.py b/config.py new file mode 100644 index 0000000..7e8ee8d --- /dev/null +++ b/config.py @@ -0,0 +1,16 @@ +RADIO_STREAM = "http://stream.rcs.revma.com/ypqt40u0x1zuv" +OLED_I2C_ADDR = 0x3C + +GPIO_ENCODER_A = 24 +GPIO_ENCODER_B = 23 +GPIO_ENCODER_SW = 13 + +GPIO_BTN_POWER = 5 +GPIO_BTN_STOP = 6 +GPIO_BTN_PLAY = 12 +GPIO_BTN_NEXT = 19 +GPIO_BTN_PREV = 26 + +GPIO_LED_R = 16 +GPIO_LED_G = 20 +GPIO_LED_B = 21 diff --git a/config/config-dsp.json b/config/config-dsp.json new file mode 100644 index 0000000..b4bf52f --- /dev/null +++ b/config/config-dsp.json @@ -0,0 +1,5 @@ +{ + "eq_5band": [0, 0, 0, 0, 0], + "eq_2band": [0, 0], + "active_eq": "5band" +} diff --git a/config/config-eq.json b/config/config-eq.json new file mode 100644 index 0000000..e4447c9 --- /dev/null +++ b/config/config-eq.json @@ -0,0 +1,29 @@ +{ + "mode": "preset", + "selected_preset": "ROCK", + + "custom2_profiles": { + "A": { "bass": 0, "treble": 0 }, + "B": { "bass": 3, "treble": -2 } + }, + + "custom5_profiles": { + "A": { "60": 0, "230": 0, "910": 0, "3600": 0, "14000": 0 }, + "B": { "60": 4, "230": 2, "910": -1, "3600": 3, "14000": 5 } + }, + + "presets": { + "ROCK": { "60": 4, "230": 2, "910": -1, "3600": 3, "14000": 5 }, + "JAZZ": { "60": -2, "230": 1, "910": 3, "3600": 2, "14000": 1 }, + "DANCE": { "60": 6, "230": 4, "910": 0, "3600": 2, "14000": 3 }, + "POP": { "60": 3, "230": 1, "910": 0, "3600": 2, "14000": 4 }, + "VOCAL": { "60": -1, "230": 0, "910": 2, "3600": 4, "14000": 3 }, + "CLASSIC":{ "60": -3, "230": -1, "910": 1, "3600": 2, "14000": 1 }, + "FLAT": { "60": 0, "230": 0, "910": 0, "3600": 0, "14000": 0 } + }, + + "loudness": { + "enabled": false, + "strength": 50 + } +} diff --git a/config/config-oled.json b/config/config-oled.json new file mode 100644 index 0000000..7a88c3c --- /dev/null +++ b/config/config-oled.json @@ -0,0 +1,7 @@ +{ + "eq_mode": "5band", + "screensaver_dim_after": 10, + "screensaver_dim_level": 10, + "screensaver_off_after": 30, + "brightness_default": 100 +} diff --git a/config/config-radio.json b/config/config-radio.json new file mode 100644 index 0000000..19dad37 --- /dev/null +++ b/config/config-radio.json @@ -0,0 +1,36 @@ +{ + "stations": [ + { + "name": "FIP \u00c9lectro", + "url": "https://icecast.radiofrance.fr/fipelectro-midfi.mp3", + "favorite": true, + "tags": [ + "electronic", + "aac" + ] + }, + { + "name": "FIP Groove", + "url": "https://icecast.radiofrance.fr/fipgroove-midfi.mp3", + "favorite": false, + "tags": [ + "groove", + "aac" + ] + }, + { + "name": "Dance Classics Radio", + "url": "http://vriezenet.nl:11051/dcflac", + "favorite": false, + "tags": [ + "Classic Dance" + ] + }, + { + "name": "TNM Radio", + "url": "http://stream.tnm-radio.eu/ultra", + "favorite": false, + "tags": [] + } + ] +} diff --git a/config/config-system.json b/config/config-system.json new file mode 100644 index 0000000..fd6e877 --- /dev/null +++ b/config/config-system.json @@ -0,0 +1,5 @@ +{ + "hostname": "streamer v0.010a", + "i2s_card": "hifiberry-dac", + "bluetooth_enabled": false +} diff --git a/config/gpio.json b/config/gpio.json deleted file mode 100644 index 7636e37..0000000 --- a/config/gpio.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "Version": "0.7a3", - - "oled": { - "type": "ssd1306", - "width": 128, - "height": 64, - "address": "0x3C", - "rotation": "0" - }, - - "i2s": { - "enabled": true, - "dac_overlay": "hifiberry-dac", - "bck": 18, - "lrck": 19, - "din": 21, - "mclk": 5, - "mute": 6 - }, - - "encoder": { - "enabled": true, - "pin_a": 23, - "pin_b": 24, - "pin_sw": 13 - } - - "buttons": { - "enabled": false, - "pins": [] - } -} diff --git a/hardware/buttons.py b/hardware/buttons.py deleted file mode 100644 index 8195690..0000000 --- a/hardware/buttons.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -import RPi.GPIO as GPIO -import os -import json - -BASE_DIR = os.path.join(os.path.expanduser("~"), "streamer") -GPIO_MAP_PATH = os.path.join(BASE_DIR, "config", "gpio.json") - - -class Buttons: - """ - Moduł obsługi wejść: - - enkoder (A/B/SW) - - przyciski GPIO (w przyszłości) - """ - - def __init__(self, on_rotate=None, on_click=None): - self.on_rotate = on_rotate - self.on_click = on_click - - # Wczytaj mapę GPIO - with open(GPIO_MAP_PATH, "r") as f: - gpio = json.load(f) - - enc = gpio.get("encoder", {}) - self.enabled = enc.get("enabled", False) - - self.pin_a = enc.get("pin_a") - self.pin_b = enc.get("pin_b") - self.pin_sw = enc.get("pin_sw") - - GPIO.setmode(GPIO.BCM) - GPIO.setwarnings(False) - - if self.enabled: - self._init_encoder() - - # ----------------------------------------- - # ENCODER - # ----------------------------------------- - - def _init_encoder(self): - """Inicjalizacja enkodera z bezpiecznym fallbackiem.""" - - # Jeśli któryś pin nie jest ustawiony → wyłącz enkoder - if None in (self.pin_a, self.pin_b, self.pin_sw): - print("[buttons] Brak pinów enkodera w gpio.json — wyłączam.") - self.enabled = False - return - - GPIO.setup(self.pin_a, GPIO.IN, pull_up_down=GPIO.PUD_UP) - GPIO.setup(self.pin_b, GPIO.IN, pull_up_down=GPIO.PUD_UP) - GPIO.setup(self.pin_sw, GPIO.IN, pull_up_down=GPIO.PUD_UP) - - self.last_state = GPIO.input(self.pin_a) - - # --- bezpieczne dodawanie przerwań dla A --- - try: - GPIO.add_event_detect( - self.pin_a, - GPIO.BOTH, - callback=self._rotary_callback, - bouncetime=2 - ) - except RuntimeError as e: - print("[buttons] Nie można dodać event_detect dla pin_a:", e) - print("[buttons] Wyłączam obsługę enkodera.") - self.enabled = False - return - - # --- bezpieczne dodawanie przerwań dla SW --- - try: - GPIO.add_event_detect( - self.pin_sw, - GPIO.FALLING, - callback=self._button_callback, - bouncetime=200 - ) - except RuntimeError as e: - print("[buttons] Nie można dodać event_detect dla pin_sw:", e) - print("[buttons] Wyłączam obsługę przycisku.") - - def _rotary_callback(self, channel): - """Obsługa obrotu enkodera.""" - if not self.enabled: - return - - a = GPIO.input(self.pin_a) - b = GPIO.input(self.pin_b) - - if a != self.last_state: - direction = +1 if b != a else -1 - if self.on_rotate: - self.on_rotate(direction) - self.last_state = a - - def _button_callback(self, channel): - """Obsługa kliknięcia.""" - if self.on_click: - self.on_click() - - # ----------------------------------------- - # CLEANUP - # ----------------------------------------- - - def cleanup(self): - GPIO.cleanup() diff --git a/input/input_daemon.py b/input/input_daemon.py deleted file mode 100644 index 45ec14c..0000000 --- a/input/input_daemon.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -import time -import os -import sys - -BASE_DIR = os.path.join(os.path.expanduser("~"), "streamer") -sys.path.append(os.path.join(BASE_DIR, "hardware")) - -from buttons import Buttons - - -def main(): - print("[input_daemon] start") - - # Callbacki — na razie tylko logi - def on_rotate(direction): - print(f"[input_daemon] rotate: {direction}") - - def on_click(): - print("[input_daemon] click") - - # Inicjalizacja hardware - btn = Buttons( - on_rotate=on_rotate, - on_click=on_click - ) - - try: - while True: - time.sleep(0.1) - except KeyboardInterrupt: - pass - finally: - btn.cleanup() - print("[input_daemon] stop") - - -if __name__ == "__main__": - main() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..4f4c0c5 --- /dev/null +++ b/install.sh @@ -0,0 +1,306 @@ +#!/usr/bin/env bash + +# streamer installer / updater (branch Second) + +set -euo pipefail + +# ---------- konfiguracja logów ---------- + +DEBUG_LOG=1 # 1 = zapisuj debug do install.log, 0 = tylko streamer_install.log + +LOG_FILE="$HOME/streamer_install.log" +DEBUG_FILE="$HOME/install.log" + +touch "$LOG_FILE" +if [[ "$DEBUG_LOG" == "1" ]]; then + touch "$DEBUG_FILE" +fi + +# ---------- kolory ---------- + +GREEN="\e[32m" +RED="\e[31m" +YELLOW="\e[33m" +BLUE="\e[34m" +RESET="\e[0m" + +log() { + local msg="$1" + local type="${2:-INFO}" + + case "$type" in + OK) prefix="${GREEN}[OK]${RESET}" ;; + ERR) prefix="${RED}[ERROR]${RESET}" ;; + WARN) prefix="${YELLOW}[-]${RESET}" ;; + *) prefix="${BLUE}[..]${RESET}" ;; + esac + + echo -e "$prefix $msg" + echo "$(date '+%Y-%m-%d %H:%M:%S') | $prefix $msg" >> "$LOG_FILE" + + if [[ "$DEBUG_LOG" == "1" ]]; then + echo "$(date '+%Y-%m-%d %H:%M:%S') | $msg" >> "$DEBUG_FILE" + fi +} + +run_safe() { + local cmd="$*" + log "$cmd" "INFO" + if ! bash -c "$cmd"; then + log "Błąd: $cmd" "ERR" + return 1 + fi + return 0 +} + +restart_service_if_exists() { + local svc="$1" + + if ! systemctl list-unit-files --type=service | awk '{print $1}' | grep -qx "$svc"; then + log "[$svc] brak jednostki, pomijam." "WARN" + return 0 + fi + + if systemctl is-active --quiet "$svc"; then + log "[$svc] restartuję..." "INFO" + if ! sudo systemctl restart "$svc"; then + log "[$svc] nie udało się zrestartować" "ERR" + else + log "[$svc] zrestartowano" "OK" + fi + else + log "[$svc] włączam i uruchamiam..." "INFO" + if ! sudo systemctl enable --now "$svc"; then + log "[$svc] nie udało się włączyć/uruchomić" "ERR" + else + log "[$svc] włączona i uruchomiona" "OK" + fi + fi +} + +ensure_git_clone() { + local repo_url="$1" + local branch="$2" + local dest="$3" + + if [ -d "$dest/.git" ]; then + log "[clone_repo] sprawdzam repozytorium w $dest..." "INFO" + if ! (cd "$dest" && git fsck --full >/dev/null 2>&1); then + log "[clone_repo] repozytorium uszkodzone, usuwam i klonuję ponownie" "WARN" + rm -rf "$dest" + else + log "[clone_repo] repozytorium OK, aktualizuję..." "INFO" + if ! (cd "$dest" && git fetch --all --prune && git reset --hard "origin/$branch"); then + log "[clone_repo] aktualizacja nie powiodła się, usuwam i klonuję ponownie" "WARN" + rm -rf "$dest" + fi + fi + fi + + if [ ! -d "$dest" ]; then + log "[clone_repo] klonuję $repo_url (branch: $branch) do $dest..." "INFO" + git clone --depth 1 --branch "$branch" "$repo_url" "$dest" + log "[clone_repo] repozytorium pobrane" "OK" + else + log "[clone_repo] repozytorium istnieje i zostało zaktualizowane" "OK" + fi +} + +install_python_deps() { + log "[install_python] instaluję biblioteki Python..." "INFO" + + sudo apt update + sudo apt install -y python3-pip python3-venv build-essential libjpeg-dev zlib1g-dev + + sudo -H python3 -m pip install --break-system-packages --upgrade pip setuptools wheel + + sudo -H python3 -m pip install --break-system-packages \ + RPi.GPIO \ + smbus2 \ + pillow \ + python-mpd2 \ + luma.oled + + log "[install_python] biblioteki Python zainstalowane" "OK" +} + +install_oled_service() { + local user_name="$1" + local repo_dir="$2" + + local oled_script="$repo_dir/oled/oled.py" + + if [ ! -f "$oled_script" ]; then + log "[oled] brak skryptu ($oled_script) — pomijam usługę" "WARN" + return 0 + fi + + log "[oled] tworzę usługę systemd..." "INFO" + + sudo tee /etc/systemd/system/oled.service >/dev/null </dev/null </dev/null <= HOLD_TIME: + self.hold_fired = True + if self.on_hold: + self.on_hold() + + # przycisk puszczony + if state == 1 and self.last_button_state == 0: + if not self.hold_fired: + if self.on_click: + self.on_click() + self.button_down_time = None + time.sleep(DEBOUNCE_CLICK) + + self.last_button_state = state + + # ========================== + # ZATRZYMANIE + # ========================== + + def stop(self): + self.running = False + time.sleep(0.05) + GPIO.cleanup() diff --git a/oled/graphics/anim/frame01.pbm b/oled/graphics/anim/frame01.pbm deleted file mode 100644 index c74aa85..0000000 --- a/oled/graphics/anim/frame01.pbm +++ /dev/null @@ -1,66 +0,0 @@ -P1 -128 64 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000111100000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000111111110000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000001111111111100000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000011111000011111000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000111100000000111100000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000001111000000000001111000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000001110000000000000111000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000100000000000000001000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/oled/graphics/anim/frame02.pbm b/oled/graphics/anim/frame02.pbm deleted file mode 100644 index 3bc997c..0000000 --- a/oled/graphics/anim/frame02.pbm +++ /dev/null @@ -1,18 +0,0 @@ -P1 -128 64 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 -0 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0 -0 1 0 1 1 0 1 1 1 1 0 1 1 0 1 0 -0 1 0 1 0 0 1 0 0 1 0 0 1 0 1 0 -0 1 0 1 1 0 1 1 1 1 0 1 1 0 1 0 -0 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0 -0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 diff --git a/oled/graphics/anim/frame03.pbm b/oled/graphics/anim/frame03.pbm deleted file mode 100644 index 497e297..0000000 --- a/oled/graphics/anim/frame03.pbm +++ /dev/null @@ -1,18 +0,0 @@ -P1 -128 64 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 -0 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0 -0 1 0 1 1 0 1 1 1 1 0 1 1 0 1 0 -0 1 0 1 0 0 1 0 0 1 0 0 1 0 1 0 -0 1 0 1 1 0 1 1 1 1 0 1 1 0 1 0 -0 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0 -0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 diff --git a/oled/graphics/bits.pbm b/oled/graphics/bits.pbm deleted file mode 100644 index e69de29..0000000 diff --git a/oled/graphics/khz.pbm b/oled/graphics/khz.pbm deleted file mode 100644 index e69de29..0000000 diff --git a/oled/graphics/logo.pbm b/oled/graphics/logo.pbm deleted file mode 100644 index a7d07b7..0000000 --- a/oled/graphics/logo.pbm +++ /dev/null @@ -1,18 +0,0 @@ -P1 -16 16 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 -0 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0 -0 1 0 1 1 0 1 1 1 1 0 1 1 0 1 0 -0 1 0 1 0 0 1 0 0 1 0 0 1 0 1 0 -0 1 0 1 1 0 1 1 1 1 0 1 1 0 1 0 -0 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0 -0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 diff --git a/oled/graphics/pause.pbm b/oled/graphics/pause.pbm deleted file mode 100644 index e69de29..0000000 diff --git a/oled/graphics/play.pbm b/oled/graphics/play.pbm deleted file mode 100644 index 6a31d75..0000000 --- a/oled/graphics/play.pbm +++ /dev/null @@ -1,18 +0,0 @@ -P1 -16 16 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 -0 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 -0 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 -0 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 -0 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 -0 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 -0 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 -0 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 -0 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 -0 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 -0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 diff --git a/oled/graphics/radio.pbm b/oled/graphics/radio.pbm deleted file mode 100644 index e69de29..0000000 diff --git a/oled/graphics/time.pbm b/oled/graphics/time.pbm deleted file mode 100644 index e69de29..0000000 diff --git a/oled/graphics/volume.pbm b/oled/graphics/volume.pbm deleted file mode 100644 index 79eceae..0000000 --- a/oled/graphics/volume.pbm +++ /dev/null @@ -1,18 +0,0 @@ -P1 -16 16 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 -0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 -0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 -0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 -0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 -0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 -0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 -0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 -0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 -0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 -0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 -0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 -0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 -0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 -0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 -1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 diff --git a/oled/oled.py b/oled/oled.py new file mode 100755 index 0000000..7a9d63e --- /dev/null +++ b/oled/oled.py @@ -0,0 +1,634 @@ +#!/usr/bin/env python3 +import time +import json +import signal +import sys +from dataclasses import dataclass, field +from pathlib import Path + +from luma.core.interface.serial import i2c +from luma.core.render import canvas +from luma.oled.device import ssd1306 + +from PIL import ImageFont +from mpd import MPDClient + +from encoder import Encoder + + +# ================== ŚCIEŻKI ================== + +BASE_DIR = Path(__file__).resolve().parent +CONFIG_DIR = BASE_DIR.parent / "config" +CONFIG_DIR.mkdir(exist_ok=True) + +CONFIG_OLED = CONFIG_DIR / "config-oled.json" +CONFIG_RADIO = CONFIG_DIR / "config-radio.json" + +FONT_PATH = Path("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf") +FONT_SMALL = ImageFont.truetype(str(FONT_PATH), 10) +FONT_NORMAL = ImageFont.truetype(str(FONT_PATH), 12) + + +# ================== KONFIG DOMYŚLNY ================== + +DEFAULT_OLED_CONFIG = { + "eq_mode": "5band", + "screensaver_dim_after": 10, + "screensaver_dim_level": 10, + "screensaver_off_after": 60, + "brightness_default": 50 +} + +DEFAULT_RADIO_CONFIG = { + "stations": [ + { + "name": "Radio Paradise (FLAC)", + "url": "http://stream.radioparadise.com/flac", + "favorite": True, + "tags": ["hires", "flac"] + } + ] +} + + +# ================== DANE / STANY ================== + +@dataclass +class NowPlaying: + source: str = "radio" + artist: str = "" + title: str = "" + bitrate_kbps: int = 0 + bit_depth: int = 16 + sample_rate: int = 44100 + volume: int = 42 + playing: bool = False + + +@dataclass +class ScreenState: + screen_off: bool = False + mode: str = "main" + menu_path: list = field(default_factory=list) + last_input_time: float = field(default_factory=time.time) + + scroll_offset_line1: int = 0 + scroll_offset_line2: int = 0 + last_scroll_time: float = field(default_factory=time.time) + + selected_index: int = 0 + scroll_offset: int = 0 + + +@dataclass +class Settings: + eq_mode: str = "5band" + screensaver_dim_after: int = 10 + screensaver_dim_level: int = 10 + screensaver_off_after: int = 60 + brightness_default: int = 50 + + +# ================== PARAMETRY OLED ================== + +I2C_PORT = 1 +I2C_ADDRESS = 0x3C + +FPS = 20 +SCROLL_SPEED = 0.3 +MENU_TIMEOUT = 10 + + +# ================== KONFIG / RADIO ================== + +def load_json(path: Path, default: dict) -> dict: + if not path.exists(): + path.write_text(json.dumps(default, indent=2), encoding="utf-8") + return default.copy() + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return default.copy() + + +def save_json(path: Path, data: dict): + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def load_oled_config() -> Settings: + cfg = load_json(CONFIG_OLED, DEFAULT_OLED_CONFIG) + return Settings( + eq_mode=cfg.get("eq_mode", "5band"), + screensaver_dim_after=int(cfg.get("screensaver_dim_after", 10)), + screensaver_dim_level=int(cfg.get("screensaver_dim_level", 10)), + screensaver_off_after=int(cfg.get("screensaver_off_after", 60)), + brightness_default=int(cfg.get("brightness_default", 50)), + ) + + +def load_radio_stations(): + data = load_json(CONFIG_RADIO, DEFAULT_RADIO_CONFIG) + return data.get("stations", []) + + +def get_favorite_stations(): + stations = load_radio_stations() + return [s for s in stations if s.get("favorite")] + + +# ================== OLED INIT ================== + +def init_device(): + serial = i2c(port=I2C_PORT, address=I2C_ADDRESS) + device = ssd1306(serial, rotate=0) + return device + + +# ================== TEKST / FORMAT ================== + +def normalize(text: str) -> str: + return text or "" + + +def get_source_icon(np: NowPlaying) -> str: + return "▶" if np.playing else "⏸" + + +def format_bitrate(np: NowPlaying) -> str: + return f"{np.bitrate_kbps}k" if np.bitrate_kbps else "" + + +def format_bitdepth(np: NowPlaying) -> str: + sr_khz = int(np.sample_rate / 1000) + return f"{np.bit_depth}/{sr_khz}" + + +def is_hq(np: NowPlaying) -> bool: + if not np.playing: + return False + return np.bit_depth >= 16 and np.sample_rate >= 44100 + + +def is_hires(np: NowPlaying) -> bool: + if not np.playing: + return False + return np.bit_depth >= 24 or np.sample_rate > 48000 + + +# ================== RYSOWANIE ================== + +def draw_startup_animation(device): + w, h = device.width, device.height + steps = 10 + + logo_lines = [ + " _____ _ ", + " / ____| | ", + "| (___ | |_ _ __ ___ _ __ ___ _ _ ", + " \\___ \\| __| '__/ _ \\| '_ ` _ \\| | | |", + " ____) | |_| | | (_) | | | | | | |_| |", + "|_____/ \\__|_| \\___/|_| |_| |_|\\__,_|", + " STREAMER ", + ] + + for i in range(steps): + with canvas(device) as draw: + draw.rectangle((0, 0, w, h), outline=0, fill=0) + max_line = int(len(logo_lines) * (i + 1) / steps) + y = 0 + for line in logo_lines[:max_line]: + draw.text((0, y), line[:21], font=FONT_SMALL, fill=255) + y += 8 + time.sleep(0.1) + +def draw_edit_screen(device, state: ScreenState): + with canvas(device) as draw: + draw.text((0, 0), f"Ustaw: {state.edit_key}", font=FONT_NORMAL, fill=255) + draw.text((0, 20), f"{state.edit_value}", font=FONT_NORMAL, fill=255) + draw.text((0, 40), "Klik = Zapis", font=FONT_SMALL, fill=255) + +def draw_volume_icon(draw, x, y, height, width, volume): + draw.line((x, y, x, y + height), fill=255) + mid = y + height // 2 + draw.line((x, mid, x + width, mid), fill=255) + + fill_width = int(width * (volume / 100.0)) + if fill_width > 0: + draw.line((x, mid, x + fill_width, mid), fill=255) + +def scroll_text(text: str, width_chars: int, offset: int) -> str: + text = normalize(text) + if len(text) <= width_chars: + return text.ljust(width_chars) + padded = text + " " + start = offset % len(padded) + return (padded + padded)[start:start + width_chars] + + +def draw_main_screen(device, np: NowPlaying, state: ScreenState): + w, h = device.width, device.height + chars_per_line = 16 + + icon = get_source_icon(np) + hq = is_hq(np) + hires = is_hires(np) + + # RADIO + if np.source == "radio": + if np.artist: + line1_text = np.artist + line2_text = np.title + else: + line1_text = np.title + line2_text = "" + else: + line1_text = np.title or "" + line2_text = f"{format_bitrate(np)} {format_bitdepth(np)}".strip() + + # przewijanie + now = time.time() + if now - state.last_scroll_time > SCROLL_SPEED: + state.scroll_offset_line1 += 1 + state.scroll_offset_line2 += 1 + state.last_scroll_time = now + + line1 = scroll_text(line1_text, chars_per_line - 2, state.scroll_offset_line1) + line2 = scroll_text(line2_text, chars_per_line, state.scroll_offset_line2) + + bitinfo = f"{np.bit_depth}bit / {np.sample_rate//1000}kHz" + + with canvas(device) as draw: + draw.text((0, 0), icon, font=FONT_NORMAL, fill=255) + draw.text((14, 0), line1, font=FONT_NORMAL, fill=255) + + draw.text((0, 14), line2, font=FONT_NORMAL, fill=255) + + draw.text((0, 26), bitinfo, font=FONT_SMALL, fill=255) + + # HQ / HiRes + if hires: + draw.text((w - 26, 14), "HiRes", font=FONT_SMALL, fill=255) + elif hq: + draw.text((w - 20, 14), "HQ", font=FONT_SMALL, fill=255) + + # ikona głośności + draw_volume_icon(draw, 0, h - 12, 10, w - 40, np.volume) + draw.text((w - 30, h - 12), f"{np.volume}%", font=FONT_SMALL, fill=255) + + +# ================== MENU ================== + +def build_menu_structure(): + fav_names = [s["name"] for s in get_favorite_stations()] + if not fav_names: + fav_names = ["(brak ulubionych)"] + return { + "root": ["Ustawienia", "Ulubione stacje", "Stacje radiowe", "Źródło", "ESC"], + "Ustawienia": ["Filtry EQ", "Wygaszacz", "Ekran", "ESC"], + "Filtry EQ": ["EQ 5-pasmowy", "EQ 2-pasmowy", "ESC"], + "Wygaszacz": ["Czas do przyciemnienia", "Jasność po przyciemnieniu", "Czas do wygaszenia", "ESC"], + "Ekran": ["Jasność domyślna", "ESC"], + "Stacje radiowe": [s["name"] for s in load_radio_stations()] + ["ESC"], + "Ulubione stacje": fav_names + ["ESC"], + "Źródło": ["Radio", "Pliki", "Bluetooth (niedostępne)", "ESC"], + } + + +MENU_STRUCTURE = build_menu_structure() + + +def current_menu_items(state: ScreenState): + if not state.menu_path: + return MENU_STRUCTURE["root"] + return MENU_STRUCTURE.get(state.menu_path[-1], ["ESC"]) + + +def draw_menu(device, state: ScreenState): + items = current_menu_items(state) + title = state.menu_path[-1] if state.menu_path else "MENU" + + visible = 2 + + if state.selected_index < state.scroll_offset: + state.scroll_offset = state.selected_index + elif state.selected_index >= state.scroll_offset + visible: + state.scroll_offset = state.selected_index - visible + 1 + + with canvas(device) as draw: + draw.text((0, 0), title[:16], font=FONT_NORMAL, fill=255) + + for i in range(visible): + idx = state.scroll_offset + i + if idx >= len(items): + break + prefix = "> " if idx == state.selected_index else " " + draw.text((0, 14 + i * 12), prefix + items[idx][:14], font=FONT_NORMAL, fill=255) + + +# ================== MPD ================== + +def init_mpd(): + client = MPDClient() + client.timeout = 2 + client.idletimeout = None + try: + client.connect("localhost", 6600) + except Exception: + client = None + return client + + +def update_now_playing_from_mpd(client: MPDClient | None, np: NowPlaying): + if client is None: + return + + try: + status = client.status() + song = client.currentsong() + + np.playing = (status.get("state") == "play") + + # głośność z MPD + if "volume" in status: + try: + np.volume = int(status["volume"]) + except ValueError: + pass + + # audio format + if "audio" in status: + parts = status["audio"].split(":") + if len(parts) >= 2: + try: + np.sample_rate = int(parts[0]) + np.bit_depth = int(parts[1]) + except ValueError: + pass + + # bitrate + if "bitrate" in status: + try: + np.bitrate_kbps = int(status["bitrate"]) + except ValueError: + pass + + file_path = song.get("file", "") + + # źródło + if file_path.startswith("http"): + np.source = "radio" + elif file_path.startswith("bluetooth:"): + np.source = "bt" + else: + np.source = "file" + + # RADIO — NAZWA STACJI + if np.source == "radio": + stations = load_radio_stations() + for s in stations: + if s["url"] in file_path: + np.title = s["name"] + break + else: + np.title = song.get("title", file_path) + + # METADANE STREAMU: "Artist - Title" + stream_title = song.get("title", "") + if " - " in stream_title: + artist, title = stream_title.split(" - ", 1) + np.artist = artist + np.title = title + else: + np.artist = "" + return + + # PLIKI + np.title = song.get("title", file_path) + np.artist = song.get("artist", "") + + except Exception: + pass + +def play_station_by_name(client: MPDClient | None, name: str): + if client is None: + return + stations = load_radio_stations() + for s in stations: + if s["name"] == name: + try: + client.stop() + client.clear() + client.add(s["url"]) + client.play() + except Exception: + pass + break + +# ================== ENKODER ================== + +def on_encoder_rotate(direction: int, np: NowPlaying, state: ScreenState, client: MPDClient | None): + state.last_input_time = time.time() + + if state.mode == "edit": + state.edit_value = max(0, min(255, state.edit_value + direction)) + return + if state.mode == "main": + new_vol = max(0, min(100, np.volume + direction)) + if client: + try: + client.setvol(new_vol) + except: + pass + np.volume = new_vol + elif state.mode == "menu": + items = current_menu_items(state) + state.selected_index = max(0, min(len(items) - 1, state.selected_index + direction)) + +def handle_menu_action(choice: str, np: NowPlaying, state: ScreenState, settings: Settings, client: MPDClient | None): + if choice == "Radio": + np.source = "radio" + elif choice == "Pliki": + np.source = "file" + elif choice == "Bluetooth (niedostępne)": + pass + elif choice == "EQ 5-pasmowy": + settings.eq_mode = "5band" + save_json(CONFIG_OLED, settings.__dict__) + elif choice == "EQ 2-pasmowy": + settings.eq_mode = "2band" + save_json(CONFIG_OLED, settings.__dict__) + elif choice in [s["name"] for s in get_favorite_stations()]: + play_station_by_name(client, choice) + elif choice in [s["name"] for s in load_radio_stations()]: + play_station_by_name(client, choice) + elif choice == "Czas do przyciemnienia": + state.mode = "edit" + state.edit_key = "screensaver_dim_after" + state.edit_value = settings.screensaver_dim_after + return + + elif choice == "Jasność po przyciemnieniu": + state.mode = "edit" + state.edit_key = "screensaver_dim_level" + state.edit_value = settings.screensaver_dim_level + return + + elif choice == "Czas do wygaszenia": + state.mode = "edit" + state.edit_key = "screensaver_off_after" + state.edit_value = settings.screensaver_off_after + return + + elif choice == "Jasność domyślna": + state.mode = "edit" + state.edit_key = "brightness_default" + state.edit_value = settings.brightness_default + return + +def on_encoder_click(np: NowPlaying, state: ScreenState, settings: Settings, client: MPDClient | None): + + # jeśli ekran jest wygaszony → klik tylko wybudza + if state.screen_off: + state.last_input_time = time.time() + state.screen_off = False + return + + state.last_input_time = time.time() + + if state.mode == "edit": + setattr(settings, state.edit_key, state.edit_value) + save_json(CONFIG_OLED, settings.__dict__) + state.mode = "menu" + return + if state.mode == "main": + if client: + if np.playing: + client.pause(1) + else: + client.pause(0) + np.playing = not np.playing + return + + items = current_menu_items(state) + if not items: + return + + choice = items[state.selected_index] + + if choice == "ESC": + if state.menu_path: + state.menu_path.pop() + else: + state.mode = "main" + state.selected_index = 0 + state.scroll_offset = 0 + return + + if choice in MENU_STRUCTURE: + state.menu_path.append(choice) + state.selected_index = 0 + state.scroll_offset = 0 + return + + handle_menu_action(choice, np, state, settings, client) + + +def on_encoder_hold(np: NowPlaying, state: ScreenState): + state.last_input_time = time.time() + + if state.mode == "main": + state.mode = "menu" + state.menu_path = [] + state.selected_index = 0 + state.scroll_offset = 0 + + elif state.mode == "menu": + if state.menu_path: + state.menu_path.pop() + else: + state.mode = "main" + state.selected_index = 0 + state.scroll_offset = 0 + + +# ================== PĘTLA GŁÓWNA ================== + +running = True + + +def signal_handler(sig, frame): + global running + running = False + + +def main(): + global running + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + device = init_device() + settings = load_oled_config() + np = NowPlaying() + state = ScreenState() + client = init_mpd() + + draw_startup_animation(device) + + enc = Encoder( + on_rotate=lambda d: on_encoder_rotate(d, np, state, client), + on_click=lambda: on_encoder_click(np, state, settings, client), + on_hold=lambda: on_encoder_hold(np, state) + ) + + while running: + now = time.time() + inactive = now - state.last_input_time + + update_now_playing_from_mpd(client, np) + + + # soft-dimming + if inactive > settings.screensaver_off_after: + state.screen_off = True + with canvas(device) as draw: + draw.rectangle((0, 0, device.width, device.height), outline=0, fill=0) + time.sleep(0.1) + continue + else: + state.screen_off = False + + if inactive > settings.screensaver_dim_after: + # soft dimming (10%) + try: + device.contrast(int(255 * (settings.screensaver_dim_level / 100.0))) + except Exception: + pass + else: + # jasność domyślna + try: + device.contrast(int(255 * (settings.brightness_default / 100.0))) + except Exception: + pass + + # timeout menu + if state.mode == "menu" and inactive > MENU_TIMEOUT: + state.mode = "main" + + # rysowanie ekranu + if state.mode == "main": + draw_main_screen(device, np, state) + elif state.mode == "menu": + draw_menu(device, state) + elif state.mode == "edit": + draw_edit_screen(device, state) + + time.sleep(1.0 / FPS) + + enc.stop() + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/oled/oled_daemon.py b/oled/oled_daemon.py deleted file mode 100644 index 2c4769c..0000000 --- a/oled/oled_daemon.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 -import os -import time -import json -import board, busio -from mpd import MPDClient -from adafruit_ssd1306 import SSD1306_I2C -from PIL import Image, ImageDraw, ImageFont - -BASE_DIR = os.path.join(os.path.expanduser("~"), "streamer") -CONFIG_PATH = os.path.join(BASE_DIR, "oled", "config.json") -GPIO_MAP_PATH = os.path.join(BASE_DIR, "config", "gpio.json") -GRAPHICS_DIR = os.path.join(BASE_DIR, "oled", "graphics") - -# ------------------------------- -# CONFIG LOADERS -# ------------------------------- - -def load_runtime_config(): - defaults = { - "oled": { - "brightness_normal": 80, - "brightness_dim": 1, - "dim_timeout": 30, - "off_timeout": 120 - }, - "mpd": { - "host": "localhost", - "port": 6600, - "timeout": 2 - } - } - try: - with open(CONFIG_PATH, "r") as f: - data = json.load(f) - return {**defaults, **data} - except Exception: - return defaults - - -def load_gpio_map(): - with open(GPIO_MAP_PATH, "r") as f: - return json.load(f) - -# ------------------------------- -# OLED -# ------------------------------- - -def init_display(oled_cfg): - i2c = busio.I2C(board.SCL, board.SDA) - disp = SSD1306_I2C( - oled_cfg["width"], - oled_cfg["height"], - i2c, - addr=int(oled_cfg["address"], 16) - ) - disp.fill(0) - disp.show() - return disp - - -def load_pbm(name): - path = os.path.join(GRAPHICS_DIR, name) - return Image.open(path).convert("1") - - -def show_logo(display): - try: - frames = [] - anim_dir = os.path.join(GRAPHICS_DIR, "anim") - - if os.path.isdir(anim_dir): - for fname in sorted(os.listdir(anim_dir)): - if fname.lower().endswith(".pbm"): - frames.append(load_pbm(os.path.join("anim", fname))) - - if not frames: - frames = [load_pbm("logo.pbm")] - - for _ in range(2): - for frame in frames: - display.image(frame) - display.show() - time.sleep(0.1) - - display.fill(0) - display.show() - time.sleep(1) - - except Exception as e: - print("Logo/anim error:", e) - -# ------------------------------- -# MPD -# ------------------------------- - -def connect_mpd(host, port): - client = MPDClient() - client.timeout = 2 - client.idletimeout = None - try: - client.connect(host, port) - return client - except Exception: - return None - - -def get_mpd_status(client): - try: - status = client.status() - song = client.currentsong() - - station = ( - song.get("name") - or song.get("title") - or song.get("file") - or "Brak danych" - ) - - return { - "station": station, - "volume": status.get("volume", "0"), - "state": status.get("state", "stop"), - "bitrate": status.get("bitrate", ""), - "samplerate": song.get("samplerate", ""), - "bits": song.get("bitdepth", "") - } - except Exception: - return { - "station": "Brak połączenia", - "volume": "0", - "state": "stop", - "bitrate": "", - "samplerate": "", - "bits": "" - } - -# ------------------------------- -# OLED STATUS SCREEN -# ------------------------------- - -def draw_status(display, info): - image = Image.new("1", (128, 64)) - draw = ImageDraw.Draw(image) - font = ImageFont.load_default() - - line1 = info["station"][:20] - line2 = f"Vol:{info['volume']}% {'▶' if info['state']=='play' else '❚❚' if info['state']=='pause' else '■'}" - - draw.text((0, 0), line1, font=font, fill=255) - draw.text((0, 16), line2, font=font, fill=255) - - display.image(image) - display.show() - -# ------------------------------- -# MAIN LOOP -# ------------------------------- - -def main(): - cfg = load_runtime_config() - gpio = load_gpio_map() - - oled_cfg = gpio["oled"] - mpd_cfg = cfg["mpd"] - oled_rt = cfg["oled"] - - display = init_display(oled_cfg) - show_logo(display) - - client = connect_mpd(mpd_cfg["host"], mpd_cfg["port"]) - last_activity = time.time() - - try: - while True: - if client is None: - client = connect_mpd(mpd_cfg["host"], mpd_cfg["port"]) - - info = get_mpd_status(client) - draw_status(display, info) - - idle = time.time() - last_activity - - if idle > oled_rt["off_timeout"]: - display.fill(0) - display.show() - elif idle > oled_rt["dim_timeout"]: - display.contrast(oled_rt["brightness_dim"]) - else: - display.contrast(oled_rt["brightness_normal"]) - - time.sleep(0.5) - - finally: - pass - - -if __name__ == "__main__": - main() diff --git a/scripts/clone_repo.sh b/scripts/clone_repo.sh new file mode 100644 index 0000000..623c3f0 --- /dev/null +++ b/scripts/clone_repo.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +echo "[clone_repo] Pobieram projekt z GitHub..." + +REPO_URL="https://github.com/xtreamx2/streamer.git" +TARGET_DIR="/home/$USER/streamer" +BRANCH="Second" + +# Jeśli repo istnieje → aktualizacja +if [ -d "$TARGET_DIR/.git" ]; then + echo "[clone_repo] Repozytorium już istnieje — aktualizuję." + cd "$TARGET_DIR" + git fetch + git checkout "$BRANCH" + git pull +else + echo "[clone_repo] Klonuję repozytorium (gałąź: $BRANCH)..." + git clone -b "$BRANCH" "$REPO_URL" "$TARGET_DIR" +fi diff --git a/scripts/configure_audio.sh b/scripts/configure_audio.sh new file mode 100644 index 0000000..bd68f86 --- /dev/null +++ b/scripts/configure_audio.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +echo "[configure_audio] Konfiguruję I2S + rpi-dac..." + +CONFIG="/boot/firmware/config.txt" + +# Czyścimy stare wpisy DAC +sudo sed -i '/dtoverlay=hifiberry-dacplus/d' "$CONFIG" +sudo sed -i '/dtoverlay=rpi-dac/d' "$CONFIG" +sudo sed -i '/dtparam=i2s=on/d' "$CONFIG" + +# Dodajemy DAC PO I2C (I2C ustawia configure_i2c.sh) +echo "dtoverlay=rpi-dac" | sudo tee -a "$CONFIG" >/dev/null +echo "dtparam=i2s=on" | sudo tee -a "$CONFIG" >/dev/null + +echo "[configure_audio] Ustawiono:" +echo " dtoverlay=rpi-dac" +echo " dtparam=i2s=on" +echo "[configure_audio] Restart wymagany, aby DAC został wykryty." diff --git a/scripts/configure_bt.sh b/scripts/configure_bt.sh new file mode 100644 index 0000000..3039a90 --- /dev/null +++ b/scripts/configure_bt.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -e + +echo "[configure_bt] Konfiguruję Bluetooth (BlueALSA)..." + +#sudo systemctl enable bluetooth +#sudo systemctl enable bluealsa diff --git a/scripts/configure_camilladsp.sh b/scripts/configure_camilladsp.sh new file mode 100644 index 0000000..53854ba --- /dev/null +++ b/scripts/configure_camilladsp.sh @@ -0,0 +1,86 @@ +#!/bin/bash +set -e + +echo "[configure_camilladsp] Instaluję i konfiguruję CamillaDSP..." + +# ================================ +# 0. Sprawdzenie czy już jest +# ================================ +if command -v camilladsp >/dev/null 2>&1; then + echo "[configure_camilladsp] CamillaDSP już jest zainstalowany — pomijam instalację." +else + echo "[configure_camilladsp] CamillaDSP nie znaleziony — instaluję najnowszą wersję." + + # ================================ + # 1. Instalacja rustup (Rust 1.82+) + # ================================ + if ! command -v rustup >/dev/null 2>&1; then + echo "[configure_camilladsp] Instaluję rustup (Rust 1.82+)..." + curl https://sh.rustup.rs -sSf | sh -s -- -y + source $HOME/.cargo/env + else + echo "[configure_camilladsp] rustup już zainstalowany." + source $HOME/.cargo/env + fi + + rustup update stable + + # ================================ + # 2. Pobranie źródeł (tylko raz) + # ================================ + if [ ! -d "/home/$USER/camilladsp" ]; then + echo "[configure_camilladsp] Pobieram repozytorium..." + git clone https://github.com/HEnquist/camilladsp /home/$USER/camilladsp + else + echo "[configure_camilladsp] Repozytorium już istnieje — aktualizuję." + cd /home/$USER/camilladsp + git pull + fi + + # ================================ + # 3. Kompilacja + # ================================ + cd /home/$USER/camilladsp + echo "[configure_camilladsp] Kompiluję CamillaDSP..." + cargo build --release + + # ================================ + # 4. Instalacja binarki + # ================================ + sudo cp target/release/camilladsp /usr/bin/ +fi + +# ================================ +# 5. Katalog konfiguracyjny +# ================================ +sudo mkdir -p /etc/camilladsp + +cat </dev/null +echo "dtoverlay=i2c1,pins_2_3" | sudo tee -a "$CONFIG" >/dev/null + +echo "[configure_i2c] Ustawiono:" +echo " dtparam=i2c_arm=on" +echo " dtoverlay=i2c1,pins_2_3" +echo "[configure_i2c] Restart wymagany, aby I2C zostało aktywowane." + +# Załaduj moduły I2C teraz +sudo modprobe i2c-bcm2835 || true +sudo modprobe i2c-dev || true + +# Upewnij się, że ładują się przy starcie +echo "i2c-bcm2835" | sudo tee /etc/modules-load.d/i2c.conf >/dev/null +echo "i2c-dev" | sudo tee -a /etc/modules-load.d/i2c.conf >/dev/null diff --git a/scripts/configure_mpd.sh b/scripts/configure_mpd.sh new file mode 100644 index 0000000..07d90cd --- /dev/null +++ b/scripts/configure_mpd.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +echo "[configure_mpd] Konfiguruję MPD..." + +sudo systemctl stop mpd + +sudo sed -i 's/^audio_output.*/audio_output {\ + type "alsa"\ + name "PCM5122"\ + device "hw:0,0"\ +}/' /etc/mpd.conf + +sudo systemctl enable mpd diff --git a/scripts/configure_oled.sh b/scripts/configure_oled.sh new file mode 100644 index 0000000..ebe8359 --- /dev/null +++ b/scripts/configure_oled.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -e + +echo "[configure_oled] Konfiguruję OLED (I2C)..." + +CONFIG="/boot/firmware/config.txt" + +# ================================ +# 1. Włącz I2C +# ================================ +sudo sed -i '/dtparam=i2c_arm=on/d' "$CONFIG" +echo "dtparam=i2c_arm=on" | sudo tee -a "$CONFIG" >/dev/null + +# ================================ +# 2. Instalacja zależności +# ================================ +sudo apt install -y i2c-tools python3-smbus python3-pil + +# ================================ +# 3. Sprawdzenie magistrali +# ================================ +if [ ! -e /dev/i2c-1 ]; then + echo "[configure_oled] I2C nieaktywne — wymagany restart." + exit 0 +fi + +# ================================ +# 4. Wykrywanie OLED +# ================================ +ADDR=$(i2cdetect -y 1 | grep -oE '3c|3d' | head -n 1) + +if [ -z "$ADDR" ]; then + echo "[configure_oled] [-] OLED nie wykryty — pomijam konfigurację." + exit 0 +fi + +echo "[configure_oled] [OK] Wykryto OLED na adresie 0x$ADDR." + +# ================================ +# 5. Zapis konfiguracji OLED +# ================================ +sudo mkdir -p /etc/streamer +echo "oled_address=0x$ADDR" | sudo tee /etc/streamer/oled.conf >/dev/null + +echo "[configure_oled] OLED gotowy." diff --git a/scripts/install_packages.sh b/scripts/install_packages.sh new file mode 100644 index 0000000..3715195 --- /dev/null +++ b/scripts/install_packages.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +echo "[install_packages] Instaluję pakiety systemowe..." + +sudo apt install -y \ + mpd mpc \ + python3 python3-pip python3-venv \ + git i2c-tools alsa-utils \ + curl wget unzip + +# pakiety usunięte +# bluez bluealsa bluealsa-aplay +# camilladsp diff --git a/scripts/install_python.sh b/scripts/install_python.sh new file mode 100644 index 0000000..360c343 --- /dev/null +++ b/scripts/install_python.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +echo "[install_python] Instaluję biblioteki Python..." + +sudo apt install -y python3-pip python3-venv build-essential libjpeg-dev zlib1g-dev + +sudo -H python3 -m pip install --break-system-packages --upgrade pip setuptools wheel + +sudo -H python3 -m pip install --break-system-packages \ + RPi.GPIO \ + smbus2 \ + pillow \ + python-mpd2 \ + luma.oled + +sudo apt install -y python3-flask diff --git a/start_install.sh b/start_install.sh deleted file mode 100755 index b8f468c..0000000 --- a/start_install.sh +++ /dev/null @@ -1,296 +0,0 @@ -#!/bin/bash -clear - -SOFT_VERSION="0.07a5" - -RED="\e[31m" -GREEN="\e[32m" -YELLOW="\e[33m" -BLUE="\e[34m" -RESET="\e[0m" - -STREAMER_DIR="$HOME/streamer" -LOG_DIR="$STREAMER_DIR/logs" -CONFIG_DIR="$STREAMER_DIR/config" -INSTALLER_DIR="$STREAMER_DIR/installer" -CHANGELOG_DIR="$STREAMER_DIR/changelog" -MEDIA_DIR="$STREAMER_DIR/media" -OLED_DIR="$STREAMER_DIR/oled" - -LOGFILE="$LOG_DIR/install.log" - -if [ -f /boot/firmware/config.txt ]; then - CONFIG_TXT="/boot/firmware/config.txt" -else - CONFIG_TXT="/boot/config.txt" -fi - -REPO_GIT="https://gitlab.com/aloisy/streamer.git" - -log() { - echo -e "$(date '+%Y-%m-%d %H:%M:%S') $1" | tee -a "$LOGFILE" -} - -pause_step() { - echo "" - read -p "ENTER = kontynuuj, q = przerwij: " choice - if [ "$choice" = "q" ]; then - log "Instalacja przerwana przez użytkownika." - exit 1 - fi -} - -spinner() { - local pid=$1 - local delay=0.15 - local spin='|/-\' - while kill -0 $pid 2>/dev/null; do - for i in $(seq 0 3); do - printf "\r${BLUE}Przetwarzanie...${RESET} ${spin:$i:1}" - sleep $delay - done - done - printf "\r${GREEN}Zakończono.${RESET}\n" -} - -ensure_line() { - local line="$1" - local file="$2" - if ! grep -Fxq "$line" "$file"; then - echo "$line" | sudo tee -a "$file" >/dev/null - log "Dodano do $(basename "$file"): $line" - return 0 - fi - return 1 -} - -echo -e "${BLUE}==============================================" -echo -e " STREAMER AUDIO – Instalator v$SOFT_VERSION" -echo -e "==============================================${RESET}" -pause_step - -mkdir -p "$LOG_DIR" "$CONFIG_DIR" "$INSTALLER_DIR" "$CHANGELOG_DIR" "$MEDIA_DIR" \ - "$OLED_DIR/graphics/anim" -touch "$LOGFILE" -log "Struktura katalogów gotowa." - -echo -e "${BLUE}Krok 1: Aktualizacja systemu${RESET}" -(sudo apt update && sudo apt upgrade -y) & -spinner $! -log "System zaktualizowany." -pause_step - -echo -e "${BLUE}Krok 2: Instalacja pakietów${RESET}" -(sudo apt install -y git python3 python3-pip python3-venv \ - mpd mpc alsa-utils i2c-tools jq curl wget unzip sox) & -spinner $! -log "Pakiety zainstalowane." -pause_step - -echo -e "${BLUE}Krok 3: Instalacja bibliotek Python${RESET}" -(pip3 install --break-system-packages \ - python-mpd2 RPi.GPIO pillow adafruit-circuitpython-ssd1306 requests) & -spinner $! -log "Biblioteki Python zainstalowane." -pause_step - -echo -e "${BLUE}Krok 4: Synchronizacja config.txt${RESET}" - -CHANGES=0 -ensure_line "dtoverlay=hifiberry-dac" "$CONFIG_TXT" && CHANGES=1 -ensure_line "dtparam=i2c_arm=on" "$CONFIG_TXT" && CHANGES=1 -ensure_line "dtparam=i2s=on" "$CONFIG_TXT" && CHANGES=1 - -if grep -q "^dtparam=audio=on" "$CONFIG_TXT"; then - sudo sed -i 's/^dtparam=audio=on/#dtparam=audio=on/' "$CONFIG_TXT" - log "Wyłączono wbudowane audio." - CHANGES=1 -fi - -log "Synchronizacja config.txt zakończona." -pause_step - -echo -e "${BLUE}Krok 5: Restart MPD${RESET}" -sudo systemctl restart mpd -mpc stop >/dev/null 2>&1 -log "MPD uruchomiony i zatrzymany." -pause_step - -echo -e "${BLUE}Krok 6: Autodetekcja DAC${RESET}" -if aplay -l | grep -qi "sndrpihifiberry"; then - log "Wykryto DAC: PCM5102A" -else - log "Nie wykryto DAC!" -fi -pause_step - -echo -e "${BLUE}Krok 7: Autodetekcja OLED${RESET}" -if sudo i2cdetect -y 1 | grep -q "3c"; then - log "OLED wykryty." - OLED_PRESENT=1 -else - log "OLED nie wykryty." - OLED_PRESENT=0 -fi -pause_step - -echo -e "${BLUE}Krok 8: Dodanie stacji radiowej${RESET}" - -RADIO_URL="http://stream.rcs.revma.com/ye5kghkgcm0uv" - -if mpc playlist | grep -q "$RADIO_URL"; then - RADIO_NEW=0 - log "Stacja radiowa już istnieje." -else - mpc clear - mpc add "$RADIO_URL" - - if ! mpc lsplaylists | grep -q "^radio$"; then - mpc save radio - fi - - mpc volume 30 - mpc play - RADIO_NEW=1 - log "Dodano i uruchomiono Radio 357." -fi - -pause_step - -echo -e "${BLUE}Krok 9: Test DAC${RESET}" - -TEST_WAV="$MEDIA_DIR/test.wav" - -log "Generuję test.wav (400 Hz, stereo, 0.5 s)." -sox -n -r 48000 -b 16 -c 2 "$TEST_WAV" synth 0.5 sine 400 - -mpc stop >/dev/null 2>&1 -sudo systemctl stop mpd - -aplay "$TEST_WAV" -D plughw:0,0 -log "Test DAC zakończony." -pause_step - -echo -e "${BLUE}Krok 10: Test OLED${RESET}" - -if [ "$OLED_PRESENT" -eq 1 ]; then -python3 << 'EOF' -import time -import board, busio -from adafruit_ssd1306 import SSD1306_I2C -from PIL import Image, ImageDraw, ImageFont - -try: - i2c = busio.I2C(board.SCL, board.SDA) - display = SSD1306_I2C(128, 64, i2c, addr=0x3C) -except Exception as e: - print("OLED not detected:", e) - exit(0) - -display.contrast(1) - -image = Image.new("1", (128, 64)) -draw = ImageDraw.Draw(image) -font = ImageFont.load_default() - -text = "STREAMER" -bbox = draw.textbbox((0, 0), text, font=font) -w = bbox[2] - bbox[0] -h = bbox[3] - bbox[1] - -draw.text(((128 - w) // 2, (64 - h) // 2), text, font=font, fill=255) - -display.image(image) -display.show() - -time.sleep(2) - -display.fill(0) -display.show() -EOF - log "OLED test zakończony (niska jasność + wygaszenie)." -else - log "OLED pominięty – brak urządzenia." -fi - -pause_step - -echo -e "${BLUE}Krok 11: Pobieranie i aktualizacja projektu STREAMER${RESET}" - -TMP_DIR=$(mktemp -d) -git clone --depth=1 "$REPO_GIT" "$TMP_DIR" >/dev/null 2>&1 - -if [ $? -ne 0 ]; then - log "Błąd: nie udało się pobrać repozytorium!" - exit 1 -fi - -rsync -av \ - --exclude=installer \ - --exclude=logs \ - --exclude=.git \ - --exclude=.gitignore \ - "$TMP_DIR/" "$STREAMER_DIR/" - -log "Repozytorium zsynchronizowane." -pause_step - -echo -e "${BLUE}Krok 12: Aktualizacja changelog${RESET}" - -if [ -f "$STREAMER_DIR/changelog/change_log" ]; then - cp "$STREAMER_DIR/changelog/change_log" "$CHANGELOG_DIR/latest.txt" - log "Changelog zaktualizowany z repozytorium." -else - log "Brak pliku change_log w repozytorium." -fi - -pause_step - -echo -e "${BLUE}Krok 13: Instalacja usług systemd${RESET}" - -CURRENT_USER="$(whoami)" - -if [ -f "$STREAMER_DIR/systemd/oled.service" ]; then - sudo cp "$STREAMER_DIR/systemd/oled.service" /etc/systemd/system/oled.service - sudo sed -i "s/%i/$CURRENT_USER/g" /etc/systemd/system/oled.service - sudo systemctl enable oled.service - sudo systemctl restart oled.service - log "Usługa OLED zainstalowana i uruchomiona." -else - log "Brak pliku systemd/oled.service w repozytorium." -fi - -if [ -f "$STREAMER_DIR/systemd/input.service" ]; then - sudo cp "$STREAMER_DIR/systemd/input.service" /etc/systemd/system/input.service - sudo sed -i "s/%i/$CURRENT_USER/g" /etc/systemd/system/input.service - sudo systemctl enable input.service - sudo systemctl restart input.service - log "Usługa INPUT zainstalowana i uruchomiona." -else - log "Brak pliku systemd/input.service w repozytorium (pomijam)." -fi - -sudo systemctl daemon-reload -pause_step - -echo -e "${BLUE}Krok 14: Przenoszenie instalatora${RESET}" - -SCRIPT_NAME="$(basename "$0")" -TARGET_PATH="$INSTALLER_DIR/$SCRIPT_NAME" - -if [ "$(realpath "$0")" != "$TARGET_PATH" ]; then - cp "$0" "$TARGET_PATH" - rm "$0" - log "Instalator przeniesiony do $TARGET_PATH" -fi - -pause_step - -mpc stop >/dev/null 2>&1 - -echo -e "${GREEN}==============================================" -echo -e " INSTALACJA ZAKOŃCZONA SUKCESEM" -echo -e " Log: $LOGFILE" -echo -e "${GREEN}==============================================${RESET}" - -log "=== Instalacja zakończona pomyślnie ===" diff --git a/systemd/oled.service b/systemd/oled.service deleted file mode 100644 index af7faa7..0000000 --- a/systemd/oled.service +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Streamer OLED Display Daemon -After=network.target mpd.service - -[Service] -ExecStart=/usr/bin/python3 /home/%i/streamer/oled/oled_daemon.py -User=%i -Restart=always - -[Install] -WantedBy=multi-user.target diff --git a/ui/display.py b/ui/display.py new file mode 100644 index 0000000..a6cbc00 --- /dev/null +++ b/ui/display.py @@ -0,0 +1,12 @@ +from luma.core.interface.serial import i2c +from luma.oled.device import ssd1306 + +class Display: + def __init__(self): + serial = i2c(port=1, address=0x3C) + self.device = ssd1306(serial) + + def text(self, msg, line=0): + from luma.core.render import canvas + with canvas(self.device) as draw: + draw.text((0, line * 12), msg, fill=255) diff --git a/ui/encoder.py b/ui/encoder.py new file mode 100644 index 0000000..5c8ae9e --- /dev/null +++ b/ui/encoder.py @@ -0,0 +1,26 @@ +import RPi.GPIO as GPIO + +class Encoder: + def __init__(self, pin_a, pin_b, pin_sw, callback_rotate, callback_press): + GPIO.setmode(GPIO.BCM) + GPIO.setup(pin_a, GPIO.IN, pull_up_down=GPIO.PUD_UP) + GPIO.setup(pin_b, GPIO.IN, pull_up_down=GPIO.PUD_UP) + GPIO.setup(pin_sw, GPIO.IN, pull_up_down=GPIO.PUD_UP) + + self.pin_a = pin_a + self.pin_b = pin_b + self.pin_sw = pin_sw + self.callback_rotate = callback_rotate + self.callback_press = callback_press + + GPIO.add_event_detect(pin_a, GPIO.FALLING, callback=self._rotary) + GPIO.add_event_detect(pin_sw, GPIO.FALLING, callback=self._press, bouncetime=300) + + def _rotary(self, channel): + if GPIO.input(self.pin_b) == 0: + self.callback_rotate(+1) + else: + self.callback_rotate(-1) + + def _press(self, channel): + self.callback_press() diff --git a/ui/eq.py b/ui/eq.py new file mode 100644 index 0000000..3352031 --- /dev/null +++ b/ui/eq.py @@ -0,0 +1,137 @@ +# streamer/ui/eq.py + +import json +from pathlib import Path + +EQ_CONFIG_PATH = Path(__file__).resolve().parents[1] / "config" / "config-eq.json" + + +def load_eq_config(): + if not EQ_CONFIG_PATH.exists(): + return {} + return json.loads(EQ_CONFIG_PATH.read_text()) + + +def save_eq_config(cfg): + EQ_CONFIG_PATH.write_text(json.dumps(cfg, indent=2)) + + +class EqMenu: + def __init__(self, ui_state): + self.state = ui_state + self.cfg = load_eq_config() + self.items = [ + "Tryb EQ", + "Presety", + "Custom 2‑band", + "Custom 5‑band", + "Loudness", + ] + self.index = 0 + self.submode = None # np. "mode", "presets", "custom2", "custom5", "loudness" + self.edit_band = None # np. "bass", "60", "230" itd. + + # wywoływane przy wejściu do menu EQ + def enter(self): + self.cfg = load_eq_config() + self.index = 0 + self.submode = None + self.edit_band = None + + # rysowanie na OLED – dopasuj do swojego display.py + def render(self, draw): + # uproszczone – tylko nazwy pozycji + # w praktyce użyjesz swojego systemu layoutów + title = "EQ" + current = self.items[self.index] + # narysuj title + current + np. aktualny preset / tryb + # ... + + # obrót enkodera + def on_rotate(self, direction): + if self.submode is None: + # poruszanie się po głównym menu EQ + self.index = (self.index + direction) % len(self.items) + return + + # w trybach edycji – zmiana wartości + if self.submode.startswith("custom2"): + profile_key = self.submode[-1] # "A" lub "B" + band = self.edit_band + step = 1 * direction + val = self.cfg["custom2_profiles"][profile_key][band] + val = max(-12, min(12, val + step)) + self.cfg["custom2_profiles"][profile_key][band] = val + save_eq_config(self.cfg) + return + + if self.submode.startswith("custom5"): + profile_key = self.submode[-1] # "A" lub "B" + band = self.edit_band + step = 1 * direction + val = self.cfg["custom5_profiles"][profile_key][band] + val = max(-12, min(12, val + step)) + self.cfg["custom5_profiles"][profile_key][band] = val + save_eq_config(self.cfg) + return + + if self.submode == "preset_select": + presets = list(self.cfg["presets"].keys()) + idx = presets.index(self.cfg["selected_preset"]) + idx = (idx + direction) % len(presets) + self.cfg["selected_preset"] = presets[idx] + save_eq_config(self.cfg) + return + + if self.submode == "loudness": + step = 5 * direction + val = self.cfg["loudness"]["strength"] + val = max(0, min(100, val + step)) + self.cfg["loudness"]["strength"] = val + save_eq_config(self.cfg) + return + + # klik enkodera + def on_click(self): + # poziom główny + if self.submode is None: + current = self.items[self.index] + + if current == "Tryb EQ": + # przełączanie trybu: preset / custom2A / custom2B / custom5A / custom5B + modes = ["preset", "custom2A", "custom2B", "custom5A", "custom5B"] + idx = modes.index(self.cfg["mode"]) + idx = (idx + 1) % len(modes) + self.cfg["mode"] = modes[idx] + save_eq_config(self.cfg) + return + + if current == "Presety": + self.submode = "preset_select" + return + + if current == "Custom 2‑band": + # wejście w edycję – najpierw wybór profilu A/B, potem Bass/Treble + # dla uproszczenia: od razu edytujemy Bass profilu A + self.submode = "custom2A" + self.edit_band = "bass" + return + + if current == "Custom 5‑band": + self.submode = "custom5A" + self.edit_band = "60" + return + + if current == "Loudness": + # klik = toggle on/off, obrót = zmiana siły + self.cfg["loudness"]["enabled"] = not self.cfg["loudness"]["enabled"] + save_eq_config(self.cfg) + # drugi klik może wejść w edycję siły + if self.cfg["loudness"]["enabled"]: + self.submode = "loudness" + return + + # jeśli jesteśmy w submode – klik = wyjście poziom wyżej + self.submode = None + self.edit_band = None + save_eq_config(self.cfg) diff --git a/ui/menu.py b/ui/menu.py new file mode 100644 index 0000000..86774bf --- /dev/null +++ b/ui/menu.py @@ -0,0 +1,125 @@ +# streamer/ui/menu.py + +from ui.eq import EqMenu +from pathlib import Path +import json + + +EQ_CONFIG = Path(__file__).resolve().parents[1] / "config" / "config-eq.json" + + +def load_eq(): + if EQ_CONFIG.exists(): + return json.loads(EQ_CONFIG.read_text()) + return {} + + +class Menu: + """ + Główne menu streamera: + - obrót: głośność + - klik: play/pause lub wejście do EQ + - długie przytrzymanie: wejście do EQ + """ + + def __init__(self, display, player, volume): + self.display = display + self.player = player + self.volume = volume + + self.volume_level = 50 + self.eq_menu = EqMenu(display) + + self.active_menu = None # None = ekran główny + + # wyświetl od razu preset EQ + self.show_main_screen() + + # ------------------------------- + # EKRAN GŁÓWNY + # ------------------------------- + + def show_main_screen(self): + eq = load_eq() + preset = eq.get("selected_preset", "FLAT") + mode = eq.get("mode", "preset") + + if mode.startswith("custom"): + preset_text = f"EQ: {mode.upper()}" + else: + preset_text = f"EQ: {preset}" + + self.display.text(f"Vol {self.volume_level} | {preset_text}") + + # ------------------------------- + # OBSŁUGA OBROTU + # ------------------------------- + + def rotate(self, direction): + # jeśli jesteśmy w EQ → przekazujemy dalej + if self.active_menu is self.eq_menu: + self.eq_menu.rotate(direction) + return + + # normalnie: regulacja głośności + self.volume_level = max(0, min(100, self.volume_level + direction)) + self.volume.set(self.volume_level) + + # integracja loudness z głośnością + self.apply_loudness_dynamic() + + self.show_main_screen() + + # ------------------------------- + # OBSŁUGA KLIKNIĘCIA + # ------------------------------- + + def press(self): + # jeśli jesteśmy w EQ → przekazujemy dalej + if self.active_menu is self.eq_menu: + self.eq_menu.press() + # jeśli EQ wyszedł do góry + if self.eq_menu.sub is None: + self.active_menu = None + self.show_main_screen() + return + + # klik na ekranie głównym = PLAY + self.player.play_radio("http://stream.rcs.revma.com/ypqt40u0x1zuv") + self.display.text("Playing radio") + + # ------------------------------- + # DŁUGIE PRZYTRZYMANIE = EQ + # ------------------------------- + + def long_press(self): + self.active_menu = self.eq_menu + self.eq_menu.enter() + self.display.text("EQ menu") + + # ------------------------------- + # LOUDNESS DYNAMICZNY + # ------------------------------- + + def apply_loudness_dynamic(self): + """ + Loudness zależny od głośności: + - przy 0–30% → mocny + - przy 30–60% → średni + - przy 60–100% → minimalny + """ + eq = load_eq() + if not eq.get("loudness", {}).get("enabled", False): + return + + vol = self.volume_level + + if vol < 30: + strength = 80 + elif vol < 60: + strength = 40 + else: + strength = 10 + + eq["loudness"]["strength"] = strength + EQ_CONFIG.write_text(json.dumps(eq, indent=2)) diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..2c374b6 --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,12 @@ +import logging + +def setup_logger(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.FileHandler("/var/log/streamer.log"), + logging.StreamHandler() + ] + ) + return logging.getLogger("streamer") diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..4a01720 --- /dev/null +++ b/web/app.py @@ -0,0 +1,138 @@ +from flask import Flask, render_template, request, redirect +from mpd import MPDClient +import json +import requests +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +CONFIG_DIR = BASE_DIR.parent / "config" +CONFIG_RADIO = CONFIG_DIR / "config-radio.json" + +app = Flask(__name__) + +# ------------------------------ +# Helpers +# ------------------------------ + +def load_stations(): + if not CONFIG_RADIO.exists(): + return {"stations": []} + return json.loads(CONFIG_RADIO.read_text()) + +def save_stations(data): + CONFIG_RADIO.write_text(json.dumps(data, indent=2)) + +def mpd_client(): + c = MPDClient() + try: + c.connect("localhost", 6600) + return c + except: + return None + +# ------------------------------ +# M3U resolver +# ------------------------------ + +def resolve_m3u(url): + if not url.lower().endswith(".m3u"): + return url + try: + r = requests.get(url, timeout=5) + for line in r.text.splitlines(): + line = line.strip() + if line and not line.startswith("#"): + return line + except: + pass + return url + +# ------------------------------ +# Routes +# ------------------------------ + +@app.route("/") +def index(): + data = load_stations() + client = mpd_client() + status = {} + + if client: + try: + status = client.status() + song = client.currentsong() + status["title"] = song.get("title", "") + except: + pass + + return render_template("index.html", stations=data["stations"], status=status) + +@app.route("/play/") +def play(name): + data = load_stations() + client = mpd_client() + + if client: + for s in data["stations"]: + if s["name"] == name: + try: + client.clear() + client.add(s["url"]) + client.play() + except: + pass + break + + return redirect("/") + +@app.route("/stop") +def stop(): + client = mpd_client() + if client: + try: + client.stop() + except: + pass + return redirect("/") + +@app.route("/edit/", methods=["GET", "POST"]) +def edit(name): + data = load_stations() + station = next((s for s in data["stations"] if s["name"] == name), None) + + if request.method == "POST": + station["name"] = request.form["name"] + station["url"] = resolve_m3u(request.form["url"]) + station["favorite"] = ("favorite" in request.form) + save_stations(data) + return redirect("/") + + return render_template("edit.html", station=station) + +@app.route("/delete/") +def delete(name): + data = load_stations() + data["stations"] = [s for s in data["stations"] if s["name"] != name] + save_stations(data) + return redirect("/") + +@app.route("/add", methods=["GET", "POST"]) +def add(): + if request.method == "POST": + data = load_stations() + data["stations"].append({ + "name": request.form["name"], + "url": resolve_m3u(request.form["url"]), + "favorite": ("favorite" in request.form), + "tags": [] + }) + save_stations(data) + return redirect("/") + return render_template("edit.html", station=None) + +# ------------------------------ +# Run +# ------------------------------ + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8080) diff --git a/web/templates/edit.html b/web/templates/edit.html new file mode 100644 index 0000000..82b9983 --- /dev/null +++ b/web/templates/edit.html @@ -0,0 +1,35 @@ + + + + + Edytuj stację + + + + +

{{ station and "Edytuj stację" or "Dodaj stację" }}

+ +
+

Nazwa:
+

+ +

URL:
+

+ +

+ +

+ + +
+ +

← Powrót

+ + + diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..69acfee --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,37 @@ + + + + + Streamer – Stacje radiowe + + + + +

Stacje radiowe

+ +{% for s in stations %} +
+ {{ s.name }}
+ ▶ Play | + ✎ Edytuj | + 🗑 Usuń + {% if s.favorite %} ⭐{% endif %} +
+{% endfor %} + +

➕ Dodaj stację

+ +

Odtwarzacz

+ +

Status: {{ status.state }}

+

Aktualnie: {{ status.title }}

+

Głośność: {{ status.volume }}%

+ +⏹ Stop + + +