Initial Commit
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user