From fe0987f8fa9cf2d47b8b150eb8ba1ec9526d422c Mon Sep 17 00:00:00 2001 From: media Date: Sun, 14 Jun 2026 14:24:01 -0500 Subject: [PATCH] Initial Commit --- config/m380_map.yaml | 628 +++++++++++++++++++++++++++++++++++++++++++ docker-compose.yaml | 28 ++ midi_mqtt_bridge.py | 569 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 1225 insertions(+) create mode 100644 config/m380_map.yaml create mode 100644 docker-compose.yaml create mode 100644 midi_mqtt_bridge.py diff --git a/config/m380_map.yaml b/config/m380_map.yaml new file mode 100644 index 0000000..ae091eb --- /dev/null +++ b/config/m380_map.yaml @@ -0,0 +1,628 @@ +mixer: Roland M-380 +port_in: "M-380 MIXING CONSOLE:M-380 MIXING CONSOLE MIDI 1" +port_out: "M-380 MIXING CONSOLE:M-380 MIXING CONSOLE MIDI 1" +channels: + '1': + name: Channel 1 + fader: + midi_type: cc + midi_channel: 1 + controller: 1 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 64 + on_value: 1 + off_value: 0 + '2': + name: Channel 2 + fader: + midi_type: cc + midi_channel: 1 + controller: 2 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 65 + on_value: 1 + off_value: 0 + '3': + name: Channel 3 + fader: + midi_type: cc + midi_channel: 1 + controller: 3 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 66 + on_value: 1 + off_value: 0 + '4': + name: Channel 4 + fader: + midi_type: cc + midi_channel: 1 + controller: 4 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 67 + on_value: 1 + off_value: 0 + '5': + name: Channel 5 + fader: + midi_type: cc + midi_channel: 1 + controller: 5 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 68 + on_value: 1 + off_value: 0 + '6': + name: Channel 6 + fader: + midi_type: cc + midi_channel: 1 + controller: 6 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 69 + on_value: 1 + off_value: 0 + '7': + name: Channel 7 + fader: + midi_type: cc + midi_channel: 1 + controller: 7 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 70 + on_value: 1 + off_value: 0 + '8': + name: Channel 8 + fader: + midi_type: cc + midi_channel: 1 + controller: 8 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 71 + on_value: 1 + off_value: 0 + '9': + name: Channel 9 + fader: + midi_type: cc + midi_channel: 1 + controller: 9 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 72 + on_value: 1 + off_value: 0 + '10': + name: Channel 10 + fader: + midi_type: cc + midi_channel: 1 + controller: 10 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 73 + on_value: 1 + off_value: 0 + '11': + name: Channel 11 + fader: + midi_type: cc + midi_channel: 1 + controller: 11 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 74 + on_value: 1 + off_value: 0 + '12': + name: Channel 12 + fader: + midi_type: cc + midi_channel: 1 + controller: 12 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 75 + on_value: 1 + off_value: 0 + '13': + name: Channel 13 + fader: + midi_type: cc + midi_channel: 1 + controller: 13 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 76 + on_value: 1 + off_value: 0 + '14': + name: Channel 14 + fader: + midi_type: cc + midi_channel: 1 + controller: 14 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 77 + on_value: 1 + off_value: 0 + '15': + name: Channel 15 + fader: + midi_type: cc + midi_channel: 1 + controller: 15 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 78 + on_value: 1 + off_value: 0 + '16': + name: Channel 16 + fader: + midi_type: cc + midi_channel: 1 + controller: 16 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 79 + on_value: 1 + off_value: 0 + '17': + name: Channel 17 + fader: + midi_type: cc + midi_channel: 1 + controller: 17 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 80 + on_value: 1 + off_value: 0 + '18': + name: Channel 18 + fader: + midi_type: cc + midi_channel: 1 + controller: 18 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 81 + on_value: 1 + off_value: 0 + '19': + name: Channel 19 + fader: + midi_type: cc + midi_channel: 1 + controller: 19 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 82 + on_value: 1 + off_value: 0 + '20': + name: Channel 20 + fader: + midi_type: cc + midi_channel: 1 + controller: 20 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 83 + on_value: 1 + off_value: 0 + '21': + name: Channel 21 + fader: + midi_type: cc + midi_channel: 1 + controller: 21 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 84 + on_value: 1 + off_value: 0 + '22': + name: Channel 22 + fader: + midi_type: cc + midi_channel: 1 + controller: 22 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 85 + on_value: 1 + off_value: 0 + '23': + name: Channel 23 + fader: + midi_type: cc + midi_channel: 1 + controller: 23 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 86 + on_value: 1 + off_value: 0 + '24': + name: Channel 24 + fader: + midi_type: cc + midi_channel: 1 + controller: 24 + value: 0-127 + mute: + midi_type: cc + midi_channel: 1 + controller: 87 + on_value: 1 + off_value: 0 + '25': + name: Channel 25 + fader: + midi_type: cc + midi_channel: 2 + controller: 1 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 64 + on_value: 1 + off_value: 0 + '26': + name: Channel 26 + fader: + midi_type: cc + midi_channel: 2 + controller: 2 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 65 + on_value: 1 + off_value: 0 + '27': + name: Channel 27 + fader: + midi_type: cc + midi_channel: 2 + controller: 3 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 66 + on_value: 1 + off_value: 0 + '28': + name: Channel 28 + fader: + midi_type: cc + midi_channel: 2 + controller: 4 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 67 + on_value: 1 + off_value: 0 + '29': + name: Channel 29 + fader: + midi_type: cc + midi_channel: 2 + controller: 5 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 68 + on_value: 1 + off_value: 0 + '30': + name: Channel 30 + fader: + midi_type: cc + midi_channel: 2 + controller: 6 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 69 + on_value: 1 + off_value: 0 + '31': + name: Channel 31 + fader: + midi_type: cc + midi_channel: 2 + controller: 7 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 70 + on_value: 1 + off_value: 0 + '32': + name: Channel 32 + fader: + midi_type: cc + midi_channel: 2 + controller: 8 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 71 + on_value: 1 + off_value: 0 + '33': + name: Channel 33 + fader: + midi_type: cc + midi_channel: 2 + controller: 9 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 72 + on_value: 1 + off_value: 0 + '34': + name: Channel 34 + fader: + midi_type: cc + midi_channel: 2 + controller: 10 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 73 + on_value: 1 + off_value: 0 + '35': + name: Channel 35 + fader: + midi_type: cc + midi_channel: 2 + controller: 11 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 74 + on_value: 1 + off_value: 0 + '36': + name: Channel 36 + fader: + midi_type: cc + midi_channel: 2 + controller: 12 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 75 + on_value: 1 + off_value: 0 + '37': + name: Channel 37 + fader: + midi_type: cc + midi_channel: 2 + controller: 13 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 76 + on_value: 1 + off_value: 0 + '38': + name: Channel 38 + fader: + midi_type: cc + midi_channel: 2 + controller: 14 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 77 + on_value: 1 + off_value: 0 + '39': + name: Channel 39 + fader: + midi_type: cc + midi_channel: 2 + controller: 15 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 78 + on_value: 1 + off_value: 0 + '40': + name: Channel 40 + fader: + midi_type: cc + midi_channel: 2 + controller: 16 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 79 + on_value: 1 + off_value: 0 + '41': + name: Channel 41 + fader: + midi_type: cc + midi_channel: 2 + controller: 17 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 80 + on_value: 1 + off_value: 0 + '42': + name: Channel 42 + fader: + midi_type: cc + midi_channel: 2 + controller: 18 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 81 + on_value: 1 + off_value: 0 + '43': + name: Channel 43 + fader: + midi_type: cc + midi_channel: 2 + controller: 19 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 82 + on_value: 1 + off_value: 0 + '44': + name: Channel 44 + fader: + midi_type: cc + midi_channel: 2 + controller: 20 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 83 + on_value: 1 + off_value: 0 + '45': + name: Channel 45 + fader: + midi_type: cc + midi_channel: 2 + controller: 21 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 84 + on_value: 1 + off_value: 0 + '46': + name: Channel 46 + fader: + midi_type: cc + midi_channel: 2 + controller: 22 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 85 + on_value: 1 + off_value: 0 + '47': + name: Channel 47 + fader: + midi_type: cc + midi_channel: 2 + controller: 23 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 86 + on_value: 1 + off_value: 0 + '48': + name: Channel 48 + fader: + midi_type: cc + midi_channel: 2 + controller: 24 + value: 0-127 + mute: + midi_type: cc + midi_channel: 2 + controller: 87 + on_value: 1 + off_value: 0 diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..c6b6b75 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,28 @@ +services: + midi-mqtt-bridge: + image: python:3.11-slim + network_mode: host + restart: unless-stopped + environment: + MQTT_HOST: 10.40.0.2 + MQTT_PORT: "1883" + MIXER_BASE_TOPIC: "mixer" + HA_DISCOVERY_PREFIX: "homeassistant" + MIXER_DEVICE_NAME: "Roland M-380" + MIXER_MAP_PATH: "/config/m380_map.yaml" + DEBUG: "1" + PYTHONUNBUFFERED: "1" + MIDO_BACKEND: "mido.backends.rtmidi" # optional but nice + volumes: + - ./midi_mqtt_bridge.py:/app/bridge.py:ro + - ./config:/config:ro + devices: + - /dev/snd:/dev/snd + group_add: + - "audio" + working_dir: /app + command: ["sh","-c", + "apt-get update && apt-get install -y --no-install-recommends libasound2 \ + && pip install --no-cache-dir mido python-rtmidi paho-mqtt pyyaml \ + && python /app/bridge.py" + ] diff --git a/midi_mqtt_bridge.py b/midi_mqtt_bridge.py new file mode 100644 index 0000000..1949e5b --- /dev/null +++ b/midi_mqtt_bridge.py @@ -0,0 +1,569 @@ +#!/usr/bin/env python3 + +import os, json, time, threading, re +from pathlib import Path +from queue import Queue, Empty +from threading import Event + +import yaml +import mido +from paho.mqtt.client import Client as MQTT + +# ---------- Config via env ---------- +MQTT_HOST = os.environ.get("MQTT_HOST", "localhost") +MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883")) +MQTT_USER = os.environ.get("MQTT_USER") or None +MQTT_PASS = os.environ.get("MQTT_PASS") or None +BASE_TOPIC = os.environ.get("MIXER_BASE_TOPIC", "mixer").rstrip("/") +DISC_PREFIX = os.environ.get("HA_DISCOVERY_PREFIX", "homeassistant").rstrip("/") +CLIENT_ID = os.environ.get("MQTT_CLIENT_ID", "midi-mqtt-bridge") +DEVICE_NAME = os.environ.get("MIXER_DEVICE_NAME", "Roland M-380") +MAP_PATH = Path(os.environ.get("MIXER_MAP_PATH", "/config/m380_map.yaml")) +DEBUG = bool(int(os.environ.get("DEBUG", "0"))) +FADE_STEP = int(os.environ.get("FADE_STEP", "1")) # % per step +FADE_INTERVAL = float(os.environ.get("FADE_INTERVAL", "0.10")) # seconds between steps + + +# publish throttles +PUBLISH_MIN_PERIOD = 0.05 # seconds per topic + +# ---------- Globals ---------- +_map = {} +_rev_index = {} # (midi_ch, cc) -> ("fader"/"mute", ch_key) +_in_port = None +msg_q: Queue = Queue() +mqtt = None +_last_pub_ts = {} # topic -> last time +_state_cache = {} # topic -> last payload (string) for resync +_chan_state = {} # ch_key -> {"brightness": int 0..100, "muted": bool} +_out_port = None +_out_lock = threading.Lock() + + +_fade_jobs = {} # ch_key -> {"stop": Event, "thread": Thread} + +def stop_active_fade(ch_key: str, wait=False): + job = _fade_jobs.get(ch_key) + if not job: + return + job["stop"].set() + t = job.get("thread") + if wait and t and t.is_alive(): + try: + t.join(timeout=0.5) + except Exception: + pass + _fade_jobs.pop(ch_key, None) + +def _run_fade(ch_key: str, cfg: dict, target: int): + """ + Linear fade: step FADE_STEP every FADE_INTERVAL until we hit target (inclusive). + Publishes sensor state each step and sends MIDI CC. + Aborts if stop event is set. + """ + if DEBUG: + print(f"[fade] start ch={ch_key} target={target} step={FADE_STEP} interval={FADE_INTERVAL}") + stop = _fade_jobs.get(ch_key, {}).get("stop") or Event() + _fade_jobs[ch_key] = {"stop": stop} # ensure present + + target = max(0, min(100, int(target))) + f = (cfg.get("fader") or {}) + port = _map.get("port_out") + if not (f.get("midi_type") == "cc" and port): + return + + while not stop.is_set(): + st = _current_ch_state(ch_key) + cur = int(st["level"]) + if cur == target: + publish_channel_sensors(ch_key, cfg) # ensure final state seen + break + + # step toward target + delta = target - cur + step = FADE_STEP if delta > 0 else -FADE_STEP + if abs(delta) < abs(step): + step = delta # final small step + + new_level = cur + step + new_level = max(0, min(100, new_level)) + + # send CC + publish + ensure_unmuted(ch_key, cfg) + midi_send_cc(port, f["midi_channel"], f["controller"], pct_to_cc(new_level)) + publish_channel_sensors(ch_key, cfg, level=new_level) + + # sleep per tick + time.sleep(FADE_INTERVAL) + +def start_fade(ch_key: str, cfg: dict, target: int): + # cancel existing fade on this channel + stop_active_fade(ch_key, wait=False) + + ev = Event() + _fade_jobs[ch_key] = {"stop": ev} + + t = threading.Thread(target=_run_fade, args=(ch_key, cfg, target), daemon=True) + _fade_jobs[ch_key]["thread"] = t + t.start() + +# ---------- Helpers ---------- +def slugify(s: str) -> str: + s = (s or "").strip().lower() + return re.sub(r"[^a-z0-9]+", "_", s).strip("_") or "unnamed" + +def chan_slug(ch_key: str, cfg: dict) -> str: + nm = str((cfg or {}).get("name") or "").strip() + return slugify(nm) if nm else f"ch_{ch_key}" + +def pct_to_cc(p: float) -> int: + p = max(0.0, min(100.0, float(p))) + return int(round(127 * (p / 100.0))) + +def cc_to_pct(v: int) -> int: + v = max(0, min(127, int(v))) + return int(round((v / 127.0) * 100.0)) + +def midi_send_cc(port_name: str, midi_ch_1_16: int, controller: int, value: int): + global _out_port + ch0 = max(1, min(16, int(midi_ch_1_16))) - 1 + msg = mido.Message("control_change", channel=ch0, control=int(controller), value=int(value)) + with _out_lock: + for _ in (1, 2): # try once, then reopen-and-try + try: + if _out_port is None: + open_midi_out() + if DEBUG: + print(f"[MIDI TX] ch={midi_ch_1_16} cc={controller} val={value}") + _out_port.send(msg) + return + except Exception as e: + print(f"[MIDI TX] reopen due to: {e}") + time.sleep(0.05) + _out_port = None + +def publish(topic: str, payload: str, retain=True, force=False): + now = time.monotonic() + last_t = _last_pub_ts.get(topic, 0.0) + if not force and (now - last_t) < PUBLISH_MIN_PERIOD: + return + _last_pub_ts[topic] = now + _state_cache[topic] = payload + if DEBUG: + print(f"[MQTT] {topic} = {payload} (retain={retain})") + mqtt.publish(topic, payload=payload, qos=1, retain=retain) + +def adjust_level_step(ch_key: str, cfg: dict, delta: int): + """Adjust channel level by delta (usually ±1), clamp 0..100, send CC, publish.""" + st = _current_ch_state(ch_key) + new_level = max(0, min(100, st["level"] + int(delta))) + if new_level == st["level"]: + # Already at boundary; still publish current so external UIs stay in sync if they asked. + publish_channel_sensors(ch_key, cfg) + return + f = (cfg.get("fader") or {}) + port = _map.get("port_out") + if f.get("midi_type") == "cc" and port: + midi_send_cc(port, f["midi_channel"], f["controller"], pct_to_cc(new_level)) + publish_channel_sensors(ch_key, cfg, level=new_level) + +AUTO_UNMUTE_ON_LEVEL = bool(int(os.environ.get("AUTO_UNMUTE_ON_LEVEL", "0"))) + +def ensure_unmuted(ch_key: str, cfg: dict): + if not AUTO_UNMUTE_ON_LEVEL: + return + st = _current_ch_state(ch_key) + if not st["muted"]: + return + mu = (cfg.get("mute") or {}) + port = _map.get("port_out") + if mu.get("midi_type") == "cc" and port: + on_v, off_v = int(mu.get("on_value", 1)), int(mu.get("off_value", 0)) + midi_send_cc(port, mu["midi_channel"], mu["controller"], off_v) + publish_channel_sensors(ch_key, cfg, muted=False) + +def open_midi_out(): + global _out_port + name = _map.get("port_out") + if not name: + raise RuntimeError("port_out missing in YAML") + try: + if _out_port: + _out_port.close() + except Exception: + pass + _out_port = mido.open_output(name) + print(f"[midi] output on '{name}'") + + +# ---------- Map / reverse index ---------- +def load_map(): + global _map, _rev_index, _chan_state + _map = yaml.safe_load(MAP_PATH.read_text()) or {} + _rev_index.clear() + chans = _map.get("channels") or {} + for ch_key, cfg in chans.items(): + f = (cfg or {}).get("fader") or {} + m = (cfg or {}).get("mute") or {} + if f.get("midi_type") == "cc": + _rev_index[(int(f["midi_channel"]), int(f["controller"]))] = ("fader", ch_key) + if m.get("midi_type") == "cc": + _rev_index[(int(m["midi_channel"]), int(m["controller"]))] = ("mute", ch_key) + _chan_state.setdefault(ch_key, {"level": 0, "muted": False}) + print(f"[bridge] map loaded: {len(chans)} channels | in='{_map.get('port_in')}' out='{_map.get('port_out')}'") + + +# ---------- Discovery ---------- +def publish_discovery(): + dev = { + "identifiers": [f"midi_mqtt_bridge:{DEVICE_NAME}"], + "name": DEVICE_NAME, + "manufacturer": "Roland", + "model": DEVICE_NAME, + } + chans = _map.get("channels") or {} + for ch_key, cfg in chans.items(): + disp = cfg.get("name") or f"CH {ch_key}" + slug = chan_slug(ch_key, cfg) + uid_base = f"mixer_{ch_key}_{slug}" + t, _ = topics_for_channel(ch_key, cfg) + + lvl_cfg = { + "name": f"{disp} Level", + "uniq_id": f"{uid_base}_level", + "stat_t": t["level_state"], + "unit_of_meas": "%", + "stat_cla": "measurement", + "avty_t": f"{BASE_TOPIC}/availability", + "pl_avail": "online", + "pl_not_avail": "offline", + "device": dev, + } + mqtt.publish(f"{DISC_PREFIX}/sensor/{uid_base}_level/config", json.dumps(lvl_cfg), qos=1, retain=True) + + mute_cfg = { + "name": f"{disp} Mute", + "uniq_id": f"{uid_base}_mute", + "stat_t": t["mute_state"], + "payload_on": "ON", # ON = muted + "payload_off": "OFF", # OFF = unmuted + "avty_t": f"{BASE_TOPIC}/availability", + "pl_avail": "online", + "pl_not_avail": "offline", + "device": dev, + } + mqtt.publish(f"{DISC_PREFIX}/binary_sensor/{uid_base}_mute/config", json.dumps(mute_cfg), qos=1, retain=True) + + publish(f"{BASE_TOPIC}/availability", "online", retain=True, force=True) + +# ---------- MIDI input ---------- +def midi_callback(msg): + try: + msg_q.put_nowait(msg) + except Exception: + pass + +def open_midi_in(): + global _in_port + try: + if _in_port is not None: + _in_port.close() + except Exception: + pass + name = _map.get("port_in") + if not name: + raise RuntimeError("port_in missing in YAML") + _in_port = mido.open_input(name, callback=midi_callback) + print(f"[midi] listening on '{name}'") + +def topics_for_channel(ch_key: str, cfg: dict): + slug = chan_slug(ch_key, cfg) if cfg.get("name") else None + t_num = { + "level_state": f"{BASE_TOPIC}/{ch_key}/level", + "mute_state": f"{BASE_TOPIC}/{ch_key}/mute", + "level_cmd": f"{BASE_TOPIC}/{ch_key}/fader/set", + "mute_cmd": f"{BASE_TOPIC}/{ch_key}/mute/set", + } + t_name = None + if slug: + t_name = { + "level_state": f"{BASE_TOPIC}/by-name/{slug}/level", + "mute_state": f"{BASE_TOPIC}/by-name/{slug}/mute", + "level_cmd": f"{BASE_TOPIC}/by-name/{slug}/fader/set", + "mute_cmd": f"{BASE_TOPIC}/by-name/{slug}/mute/set", + } + return t_num, t_name + +def _current_ch_state(ch_key: str): + st = _chan_state.setdefault(ch_key, {"level": 0, "muted": False}) + st["level"] = int(max(0, min(100, int(st.get("level", 0))))) + st["muted"] = bool(st.get("muted", False)) + return st + +def publish_channel_sensors(ch_key: str, cfg: dict, level: int | None = None, muted: bool | None = None, force=False): + st = _current_ch_state(ch_key) + if level is not None: + st["level"] = int(max(0, min(100, level))) + if muted is not None: + st["muted"] = bool(muted) + + t_num, t_name = topics_for_channel(ch_key, cfg) + publish(t_num["level_state"], str(int(st["level"])), retain=True, force=force) + publish(t_num["mute_state"], "ON" if st["muted"] else "OFF", retain=True, force=force) + if t_name: + publish(t_name["level_state"], str(int(st["level"])), retain=True, force=force) + publish(t_name["mute_state"], "ON" if st["muted"] else "OFF", retain=True, force=force) + +def resync_all_states(force=False): + chans = _map.get("channels") or {} + for ch_key, cfg in chans.items(): + publish_channel_sensors(ch_key, cfg, force=force) + +# ---------- MQTT handlers ---------- +def on_connect(client, userdata, flags, rc, properties=None): + print(f"[mqtt] connected rc={rc}") + + # New command topics + mqtt.subscribe(f"{BASE_TOPIC}/+/fader/set") + mqtt.subscribe(f"{BASE_TOPIC}/+/mute/set") + mqtt.subscribe(f"{BASE_TOPIC}/by-name/+/fader/set") + mqtt.subscribe(f"{BASE_TOPIC}/by-name/+/mute/set") + # Nudge topics (no payload) + mqtt.subscribe(f"{BASE_TOPIC}/+/fader/up") + mqtt.subscribe(f"{BASE_TOPIC}/+/fader/down") + mqtt.subscribe(f"{BASE_TOPIC}/by-name/+/fader/up") + mqtt.subscribe(f"{BASE_TOPIC}/by-name/+/fader/down") + # Fade topics + mqtt.subscribe(f"{BASE_TOPIC}/+/fader/fade") + mqtt.subscribe(f"{BASE_TOPIC}/by-name/+/fader/fade") + + + + # Admin + mqtt.subscribe(f"{BASE_TOPIC}/cmd/#") + + publish_discovery() + publish(f"{BASE_TOPIC}/availability", "online", retain=True, force=True) + +def parse_target(parts): + """ + Accepts: + number-based: + [BASE, , 'fader', 'set' ] -> 'fader' + [BASE, , 'fader', 'up' ] -> 'fader_up' + [BASE, , 'fader', 'down'] -> 'fader_down' + [BASE, , 'fader', 'fade'] -> 'fade' + [BASE, , 'mute', 'set' ] -> 'mute' + by-name: + [BASE, 'by-name', , 'fader', 'set' ] -> 'fader' + [BASE, 'by-name', , 'fader', 'up' ] -> 'fader_up' + [BASE, 'by-name', , 'fader', 'down'] -> 'fader_down' + [BASE, 'by-name', , 'fader', 'fade'] -> 'fade' + [BASE, 'by-name', , 'mute', 'set' ] -> 'mute' + Returns (kind, ch_key, cfg) or (None, None, None) + """ + chans = _map.get("channels") or {} + + # number-based + if len(parts) == 4 and parts[0] == BASE_TOPIC: + ch_key, mid, tail = parts[1], parts[2], parts[3] + if ch_key in chans: + if mid == "fader" and tail in ("set", "up", "down", "fade"): + return ("fader" if tail == "set" else f"fader_{tail}", ch_key, chans[ch_key]) + if mid == "mute" and tail == "set": + return "mute", ch_key, chans[ch_key] + + # by-name + if len(parts) == 5 and parts[0] == BASE_TOPIC and parts[1] == "by-name": + slug, mid, tail = parts[2], parts[3], parts[4] + for ck, cfg in chans.items(): + if cfg.get("name") and slugify(cfg["name"]) == slug: + if mid == "fader" and tail in ("set", "up", "down", "fade"): + return ("fader" if tail == "set" else f"fader_{tail}", ck, cfg) + if mid == "mute" and tail == "set": + return "mute", ck, cfg + + return None, None, None + +def on_message(client, userdata, msg): + try: + topic = msg.topic + if topic.startswith(f"{BASE_TOPIC}/cmd/"): + handle_cmd(topic.split("/", 2)[2]) + return + + parts = topic.split("/") + kind, ch_key, cfg = parse_target(parts) + if not cfg: + if DEBUG: + print(f"[mqtt] ignoring topic (no cfg): {topic}") + return + + port = _map.get("port_out") + if not port: + if DEBUG: + print("[mqtt] no port_out configured; dropping command") + return + + raw = (msg.payload or b"").decode().strip() + if DEBUG: + print(f"[mqtt] got {topic} = {raw!r} -> kind={kind} ch={ch_key}") + + # Any direct command cancels an in-flight fade on this channel + if kind in ("fader", "fader_up", "fader_down", "mute"): + stop_active_fade(ch_key, wait=False) + + if kind == "fader": + # expects numeric 0..100 + try: + pct = int(float(raw)) + except Exception: + if DEBUG: + print(f"[mqtt] bad fader payload for {topic}: {raw!r}") + return + pct = max(0, min(100, pct)) + f = (cfg.get("fader") or {}) + if f.get("midi_type") == "cc": + ensure_unmuted(ch_key, cfg) + midi_send_cc(port, f["midi_channel"], f["controller"], pct_to_cc(pct)) + publish_channel_sensors(ch_key, cfg, level=pct) + + elif kind in ("fader_up", "fader_down"): + delta = 1 if kind == "fader_up" else -1 + st = _current_ch_state(ch_key) + new_level = max(0, min(100, int(st["level"]) + delta)) + f = (cfg.get("fader") or {}) + if f.get("midi_type") == "cc": + ensure_unmuted(ch_key, cfg) + midi_send_cc(port, f["midi_channel"], f["controller"], pct_to_cc(new_level)) + publish_channel_sensors(ch_key, cfg, level=new_level) + + elif kind in ("fade", "fader_fade"): # <— new fade handler + # payload = target 0..100 + try: + target = int(float(raw)) + except Exception: + if DEBUG: + print(f"[mqtt] bad fade payload for {topic}: {raw!r}") + return + target = max(0, min(100, target)) + start_fade(ch_key, cfg, target) + + + elif kind == "fade": + # payload = target 0..100 + try: + target = int(float(raw)) + except Exception: + if DEBUG: + print(f"[mqtt] bad fade payload for {topic}: {raw!r}") + return + target = max(0, min(100, target)) + start_fade(ch_key, cfg, target) + + elif kind == "mute": + # accepts ON/OFF or 1/0 + v = raw.upper() + if v in ("ON", "OFF"): + want_muted = (v == "ON") + else: + try: + want_muted = bool(int(raw)) + except Exception: + if DEBUG: + print(f"[mqtt] bad mute payload for {topic}: {raw!r}") + return + mu = (cfg.get("mute") or {}) + if mu.get("midi_type") == "cc": + on_v, off_v = int(mu.get("on_value", 1)), int(mu.get("off_value", 0)) + midi_send_cc(port, mu["midi_channel"], mu["controller"], on_v if want_muted else off_v) + publish_channel_sensors(ch_key, cfg, muted=want_muted) + + except Exception as e: + print(f"[mqtt] msg error: {e}") + + +def handle_cmd(tail: str): + if tail == "reload_map": + load_map() + open_midi_in() + publish_discovery() + resync_all_states(force=True) + elif tail == "republish_discovery": + publish_discovery() + elif tail == "resync_states": + resync_all_states(force=True) + + +# ---------- MIDI -> MQTT worker ---------- +def worker_midi_to_mqtt(): + while True: + try: + for _ in range(64): + msg = msg_q.get_nowait() + if getattr(msg, "type", "") != "control_change": + continue + ch1 = msg.channel + 1 + key = (ch1, msg.control) + mapping = _rev_index.get(key) + if not mapping: + continue + kind, ch_key = mapping + cfg = _map["channels"][ch_key] + if kind == "fader": + pct = cc_to_pct(msg.value) + publish_channel_sensors(ch_key, cfg, level=pct) + else: + mu = cfg.get("mute") or {} + on_v, off_v = int(mu.get("on_value", 1)), int(mu.get("off_value", 0)) + v = int(msg.value) + muted = None + if v == on_v or (on_v in (1,127) and v in (1,127) and v != off_v): + muted = True + elif v == off_v or (off_v in (0,127) and v in (0,127) and v != on_v): + muted = False + if muted is not None: + publish_channel_sensors(ch_key, cfg, muted=muted) + except Empty: + pass + except Exception as e: + print(f"[worker] {e}") + time.sleep(0.04) + +# ---------- Main ---------- +def main(): + global mqtt + load_map() + open_midi_in() + open_midi_out() + + mqtt = MQTT(client_id=CLIENT_ID, clean_session=True) + if MQTT_USER: + mqtt.username_pw_set(MQTT_USER, MQTT_PASS) + mqtt.will_set(f"{BASE_TOPIC}/availability", "offline", qos=1, retain=True) + mqtt.on_connect = on_connect + mqtt.on_message = on_message + mqtt.connect(MQTT_HOST, MQTT_PORT, keepalive=30) + mqtt.loop_start() + + t = threading.Thread(target=worker_midi_to_mqtt, daemon=True) + t.start() + + print("[bridge] running; CTRL+C to stop") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + pass + finally: + try: mqtt.publish(f"{BASE_TOPIC}/availability", "offline", qos=1, retain=True) + except Exception: pass + try: mqtt.loop_stop() + except Exception: pass + try: + if _in_port: + _in_port.close() + except Exception: pass + +if __name__ == "__main__": + main()