Initial Commit

This commit is contained in:
media
2026-06-14 14:24:01 -05:00
commit fe0987f8fa
3 changed files with 1225 additions and 0 deletions
+628
View File
@@ -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
+28
View File
@@ -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"
]
+569
View File
@@ -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, <chan>, 'fader', 'set' ] -> 'fader'
[BASE, <chan>, 'fader', 'up' ] -> 'fader_up'
[BASE, <chan>, 'fader', 'down'] -> 'fader_down'
[BASE, <chan>, 'fader', 'fade'] -> 'fade'
[BASE, <chan>, 'mute', 'set' ] -> 'mute'
by-name:
[BASE, 'by-name', <slug>, 'fader', 'set' ] -> 'fader'
[BASE, 'by-name', <slug>, 'fader', 'up' ] -> 'fader_up'
[BASE, 'by-name', <slug>, 'fader', 'down'] -> 'fader_down'
[BASE, 'by-name', <slug>, 'fader', 'fade'] -> 'fade'
[BASE, 'by-name', <slug>, '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()