├── .gitignore ├── LICENSE ├── README.md └── cec_auto_audio.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 John Lian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Auto-enable system audio mode when switching HDMI-CEC sources 2 | 3 | Automatically wake up your AVR and enable System Audio Mode when switching to gaming consoles or media players via HDMI-CEC. 4 | 5 | ## Why this exists 6 | 7 | When you turn on a console connected via HDMI-CEC, your TV switches inputs automatically, but the AVR often doesn't wake up or enable System Audio Mode - leaving you with TV speakers. This script monitors CEC traffic and sends the necessary command to activate your audio system. 8 | 9 | ## Features 10 | 11 | - Watches CEC bus passively without interfering 12 | - Only acts when playback devices (consoles) become active 13 | - Cancels injection if the system resolves naturally 14 | - Rate-limited to prevent command spam 15 | - Dry-run mode for testing 16 | 17 | ## Requirements 18 | 19 | - A Raspberry Pi (or some other CEC-capable device) connected to your AVR or TV with HDMI (USB-HDMI adaptors won't work) on the CEC-enabled port (usually 0) 20 | - Python 3.6+ 21 | - `cec-client` from libCEC: `sudo apt-get install cec-utils` 22 | 23 | ## Installation 24 | 25 | ```bash 26 | git clone https://github.com/jlian/cec_auto_audio.git 27 | cd cec_auto_audio 28 | chmod +x cec_auto_audio.py 29 | ``` 30 | 31 | ## Configuration 32 | 33 | Edit these variables in `cec_auto_audio.py`: 34 | 35 | ```python 36 | CONSOLE_LAS = {0x4, 0x8, 0xB} # Playback device addresses 37 | DENON_LA = 0x5 # AVR address 38 | PENDING_TIMEOUT_SEC = 0.5 # Wait time before injecting command 39 | DRY_RUN = False # Set True to test without sending commands 40 | ``` 41 | 42 | Find your device addresses: `echo 'scan' | cec-client -s -d 1` 43 | 44 | ## Usage 45 | 46 | Run the script: 47 | ```bash 48 | ./cec_auto_audio.py 49 | ``` 50 | 51 | The script monitors CEC traffic and automatically sends System Audio Mode Request (`tx 15:70:00:00`) when needed. 52 | 53 | You can temporarily set `DRY_RUN = True` while sniffing behavior; you’ll see log lines like: 54 | 55 | ```text 56 | [AUTO 00:18:19] Playback/console at logical B became Active Source (phys 36:00). 57 | [AUTO 00:18:20] [DRY RUN] Would send: tx 15:70:00:00 58 | ``` 59 | 60 | Once you’re happy, flip `DRY_RUN = False`. 61 | 62 | ### Running as a systemd service 63 | 64 | On the device, create a service file: 65 | 66 | ```bash 67 | sudo nano /etc/systemd/system/cec-auto-audio.service 68 | ``` 69 | 70 | Example unit: 71 | 72 | ```ini 73 | [Unit] 74 | Description=CEC auto audio helper (Denon + consoles) 75 | After=network.target 76 | 77 | [Service] 78 | Type=simple 79 | ExecStart=/usr/bin/python3 /opt/cec-auto-audio/cec_auto_audio.py 80 | Restart=on-failure 81 | User=pi 82 | Group=pi 83 | WorkingDirectory=/opt/cec-auto-audio 84 | StandardOutput=journal 85 | StandardError=journal 86 | 87 | [Install] 88 | WantedBy=multi-user.target 89 | ``` 90 | 91 | Then: 92 | 93 | ```bash 94 | sudo systemctl daemon-reload 95 | sudo systemctl enable cec-auto-audio.service 96 | sudo systemctl start cec-auto-audio.service 97 | 98 | # Tail logs 99 | journalctl -u cec-auto-audio.service -f 100 | ``` 101 | 102 | Because we print both our own `[INFO]` / `[AUTO]` lines and the raw `cec-client` output, the journal doubles as a trace buffer. `journald` handles rotation automatically; you don’t need to babysit log files. 103 | 104 | ## How it works 105 | 106 | 1. Monitors CEC traffic using `cec-client -d 8` 107 | 2. When a playback device sends Active Source (`0x82`), starts a timer 108 | 3. Waits briefly (0.5s) to see if AVR naturally sends Set System Audio Mode (`5f:72:01`) 109 | 4. If not, sends System Audio Mode Request (`tx 15:70:00:00`) 110 | 5. Rate-limits to prevent spam 111 | 112 | ## Troubleshooting 113 | 114 | **Script doesn't start:** Check `cec-client` is installed and CEC adapter connected (`ls /dev/cec*`) 115 | 116 | **AVR doesn't wake:** Verify addresses in config match your devices. Find addresses with: `echo 'scan' | cec-client -s -d 1` 117 | 118 | **Too many commands:** Increase `MIN_INJECTION_INTERVAL_SEC` or check for duplicate addresses 119 | 120 | ## License 121 | 122 | MIT License - Copyright (c) 2025 John Lian - see [LICENSE](LICENSE) file 123 | -------------------------------------------------------------------------------- /cec_auto_audio.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | cec_auto_audio.py 4 | 5 | Goal: 6 | - Keep cec-client mostly passive: just watch bus traffic. 7 | - When a console (any Playback device) becomes Active Source 8 | and Denon does NOT quickly issue Set System Audio Mode, 9 | send a single System Audio Mode Request: 10 | tx 15:70:00:00 11 | 12 | Assumptions matching your setup: 13 | - TV: logical 0x0 14 | - Denon AVR (ARC): logical 0x5 (Audio device) 15 | - libCEC client: logical 0x1 (Recorder 1) 16 | - Consoles: Playback LAs (0x4, 0x8, 0xB in practice) 17 | """ 18 | 19 | import subprocess 20 | import sys 21 | import time 22 | import re 23 | 24 | # ---- CONFIG ------------------------------------------------------------- 25 | 26 | # Treat these logical addresses as "playback devices" (consoles + Apple TV). 27 | CONSOLE_LAS = {0x4, 0x8, 0xB} 28 | 29 | DENON_LA = 0x5 30 | 31 | # How long to wait after a console becomes Active Source to see 32 | # if Denon naturally sends 5f:72:01 before we inject tx 15:70:00:00. 33 | PENDING_TIMEOUT_SEC = 0.5 34 | 35 | # Minimum gap between our own injections, to avoid spam. 36 | MIN_INJECTION_INTERVAL_SEC = 3.0 37 | 38 | # Start in dry-run so you can verify behavior without actually sending. 39 | DRY_RUN = False 40 | 41 | # ------------------------------------------------------------------------ 42 | 43 | 44 | FRAME_RE = re.compile( 45 | r'>>\s+([0-9A-Fa-f]{2}):' # header byte (source/dest LAs) 46 | r'([0-9A-Fa-f]{2})' # opcode 47 | r'(?:[:]([0-9A-Fa-f:]+))?' # optional data bytes 48 | ) 49 | 50 | 51 | def now_str() -> str: 52 | return time.strftime("%H:%M:%S") 53 | 54 | 55 | def parse_frame(line): 56 | """ 57 | Parse a TRAFFIC line like: 58 | TRAFFIC: [ 37491] >> bf:82:36:00 59 | 60 | Returns (src_la, dst_la, opcode, data_bytes) or None if not a frame. 61 | """ 62 | m = FRAME_RE.search(line) 63 | if not m: 64 | return None 65 | 66 | header = int(m.group(1), 16) 67 | opcode = int(m.group(2), 16) 68 | data_raw = m.group(3) 69 | 70 | src_la = header >> 4 71 | dst_la = header & 0xF 72 | 73 | data = [] 74 | if data_raw: 75 | data = [int(b, 16) for b in data_raw.split(":") if b] 76 | 77 | return src_la, dst_la, opcode, data 78 | 79 | 80 | def main(): 81 | print("[INFO] Starting CEC auto-audio helper.") 82 | print(f"[INFO] DRY_RUN = {DRY_RUN}") 83 | print("[INFO] Strategy:") 84 | print(" - Watch for Active Source (opcode 0x82) from any Playback LA.") 85 | print(" - Give Samsung/Denon a moment to do their own 5f:72:01.") 86 | print(" - If they don't, send: tx 15:70:00:00 (System Audio Mode Request).") 87 | print() 88 | 89 | # One interactive cec-client; we both read and write to it. 90 | # -d 8 = TRAFFIC only, which keeps output manageable. 91 | # (You can also bump this to 31 while debugging.) 92 | cmd = ["cec-client", "-d", "8"] 93 | proc = subprocess.Popen( 94 | cmd, 95 | stdin=subprocess.PIPE, 96 | stdout=subprocess.PIPE, 97 | stderr=subprocess.STDOUT, 98 | bufsize=1, 99 | text=True, 100 | ) 101 | 102 | if proc.stdin is None or proc.stdout is None: 103 | print("[ERROR] Failed to open cec-client pipes.") 104 | sys.exit(1) 105 | 106 | # State: pending console that just became active, waiting to see if Denon 107 | # responds on its own with 5f:72:01. 108 | pending = None # dict with keys: la, phys, deadline 109 | last_injection_time = 0.0 110 | 111 | print(f"[INFO] Spawned: {' '.join(cmd)}") 112 | print("[INFO] Watching CEC traffic...\n") 113 | 114 | try: 115 | for line in proc.stdout: 116 | line = line.rstrip("\n") 117 | # Always show raw TRAFFIC lines so you can correlate later if needed. 118 | print(line) 119 | 120 | frame = parse_frame(line) 121 | if not frame: 122 | # Non-TRAFFIC lines / noise; still keep scanning. 123 | continue 124 | 125 | src_la, dst_la, opcode, data = frame 126 | 127 | # 1) Denon Set System Audio Mode (5f:72:01) 128 | if ( 129 | src_la == DENON_LA 130 | and dst_la == 0xF 131 | and opcode == 0x72 132 | and data and data[0] == 0x01 133 | ): 134 | # Denon just turned System Audio Mode ON and presumably powered up. 135 | ts = now_str() 136 | print(f"[AUTO {ts}] Detected Denon Set System Audio Mode (5f:72:01).") 137 | 138 | # If we were waiting to inject for a console, cancel it - the system 139 | # is already doing the right thing on its own. 140 | if pending is not None: 141 | la = pending["la"] 142 | phys = pending["phys"] 143 | print( 144 | f"[AUTO {ts}] Pending console LA {la:X} (phys {phys}) " 145 | f"was satisfied by natural Denon behavior; not injecting." 146 | ) 147 | pending = None 148 | 149 | continue 150 | 151 | # 2) Active Source from some device (opcode 0x82) 152 | if dst_la == 0xF and opcode == 0x82 and len(data) >= 2: 153 | phys = f"{data[0]:02x}:{data[1]:02x}" 154 | ts = now_str() 155 | 156 | # Only care about LAs we treat as playback devices. 157 | if src_la in CONSOLE_LAS: 158 | print( 159 | f"[AUTO {ts}] Playback/console at logical {src_la:X} " 160 | f"became Active Source, phys {phys}." 161 | ) 162 | 163 | # Start a small timer: if Denon hasn't done 5f:72:01 164 | # by the time this expires, we'll inject a System Audio 165 | # Mode Request. 166 | pending = { 167 | "la": src_la, 168 | "phys": phys, 169 | "deadline": time.time() + PENDING_TIMEOUT_SEC, 170 | } 171 | else: 172 | # Some other device (TV, tuner, etc.) became active. 173 | # We don't treat it as a console trigger. 174 | print( 175 | f"[AUTO {ts}] Active Source from logical {src_la:X}, " 176 | "not a playback LA; ignoring." 177 | ) 178 | 179 | continue 180 | 181 | # 3) Timeout check: should we inject our System Audio Mode Request? 182 | now = time.time() 183 | if pending is not None and now >= pending["deadline"]: 184 | src_la = pending["la"] 185 | phys = pending["phys"] 186 | ts = now_str() 187 | 188 | # Rate limiting: don't spam if something weird happens. 189 | if now - last_injection_time < MIN_INJECTION_INTERVAL_SEC: 190 | print( 191 | f"[AUTO {ts}] Pending console LA {src_la:X} (phys {phys}) " 192 | f"reached timeout, but injection was recent; skipping to avoid spam." 193 | ) 194 | pending = None 195 | else: 196 | cmd_str = "tx 15:70:00:00" 197 | if DRY_RUN: 198 | print( 199 | f"[AUTO {ts}] [DRY RUN] Would send: {cmd_str} " 200 | f"(System Audio Mode Request to Denon for TV)." 201 | ) 202 | else: 203 | print( 204 | f"[AUTO {ts}] Sending: {cmd_str} " 205 | f"(System Audio Mode Request to Denon for TV)." 206 | ) 207 | try: 208 | proc.stdin.write(cmd_str + "\n") 209 | proc.stdin.flush() 210 | last_injection_time = now 211 | except BrokenPipeError: 212 | print( 213 | f"[ERROR {ts}] Failed to write '{cmd_str}' to cec-client " 214 | "(BrokenPipeError)." 215 | ) 216 | # No point continuing if we can't send. 217 | break 218 | 219 | # Either way, clear the pending console. 220 | pending = None 221 | 222 | # If there's no pending console or no timeout yet, just keep looping. 223 | 224 | except KeyboardInterrupt: 225 | print("\n[INFO] Interrupted by user, shutting down...") 226 | 227 | finally: 228 | try: 229 | proc.stdin.write("q\n") 230 | proc.stdin.flush() 231 | except Exception: 232 | pass 233 | proc.terminate() 234 | try: 235 | proc.wait(timeout=2) 236 | except Exception: 237 | pass 238 | print("[INFO] cec-client terminated.") 239 | 240 | 241 | if __name__ == "__main__": 242 | main() 243 | --------------------------------------------------------------------------------