Kaynağa Gözat

mlol yes yes configuration hacksery!

Sigma-Ohio 5 ay önce
ebeveyn
işleme
d3d802ebce

+ 0 - 13
MP3

@@ -1,13 +0,0 @@
-EXAMPLES of codecs :
-Codec: MPEG Audio layer 1/2 (mpga)
-Channels: Stereo
-Sample rate: 24000 Hz
-Bits per sample: 32
-Bitrate: 64 kb/s
-
-
-Codec: MPEG Audio layer 1/2 (mpga)
-Channels: Mono
-Sample rate: 22050 Hz
-Bits per sample: 32
-Bitrate: 64 kb/s

+ 268 - 0
README.md

@@ -181,6 +181,274 @@ For a ready-to-use implementation, see the `hex_checksum.py` script in this repo
 ---
 ##
 
+# Bodet Harmony IP Button Configuration Protocol Analysis
+
+## Quick Start Guide - How to Configure IP Buttons
+
+### Protocol Basics
+The Bodet Harmony button configuration uses TCP port 5666 with a simple text+binary protocol:
+
+1. **Connect** to device on TCP port 5666
+2. **Send** text command: `bou 1 get-att\nMelodys\n54321\n\x00` to get current config
+3. **Receive** 184 bytes: 14-byte text header + 170-byte binary configuration 
+4. **Modify** the 170-byte binary config as needed
+5. **Send** text command: `bou 1 set-att\nMelodys\n54321\n` + modified 170-byte config
+6. **Receive** confirmation: `bou 2 set-att\nack\n\x00`
+
+### Configuration Structure (170 bytes)
+- **Bytes 140-143**: Melody numbers (hex values = decimal melody IDs)
+- **Bytes 150-153**: Repeat counts (01-04, 00=infinite)
+- **Bytes 154-159**: Button enable flags (01=on, 00=off) 
+- **Bytes 160-166**: Volume levels (01-08)
+- **Bytes 167-169**: Alarm mode flags (01=alarm, 00=normal melody)
+
+### Zone Configuration (Complex Multi-Zone)
+For multi-zone targeting (like Button 1: Zones 1,2,4,8,16):
+- Uses bitfield encoding in zone section (bytes ~12-139)
+- Example: `8b80` = zones 1+2+4+8+16 combined
+- `0300` = zones 1+2 only
+
+### Authentication
+- Always use: `Melodys` as ecosystem ID
+- Always use: `54321` as authentication token
+- These appear to be static/unchangeable
+
+## Overview
+
+This document analyzes the TCP-based configuration protocol used by Bodet Harmony button devices. The protocol operates on **port 5666** and uses a text-based command structure for device configuration.
+
+## Protocol Structure
+
+### Connection Flow
+1. **GET Configuration**: Software requests current device configuration
+2. **Device Response**: Button device sends current settings as binary payload
+3. **SET Configuration**: Software sends new configuration 
+4. **ACK Response**: Device acknowledges configuration change
+
+### Command Format
+
+Commands follow a text-based structure:
+bou [ID] [command]\n[parameters]\n[binary_data]
+
+## Command Analysis
+
+### GET-ATT Command (Get Attributes)
+
+**Request from Software:**
+626f752031206765742d6174740a4d656c6f6479730a35343332310a00
+
+**Decoded:**
+bou 1 get-att\nMelodys\n54321\n\x00
+
+- `bou 1` = Most likely Button 1 on the Device 
+- `get-att` = Get attributes command
+- `Melodys` = Device Ecosystem (Melody IP)
+- `54321` = Could be device ID, firmware version, or authentication token
+- `\x00` = Null terminator
+
+### Device Configuration Response
+
+Possible Settings that can well be set per button :
+Action type : On or Off
+Melody Number : 1-30
+Number of Repeats : 1-4 (Or maybe 0 for Infinite Repetition like with Sigma master clock and is grayed out if infinite repeats is selected)
+Volume : 1-8
+Repeat Continuously : Yes or No
+Alarm : Yes or No
+All zones (radio button)
+All Selected Zones (ranges from zone 1 to 100 of which each individually can be turned on or off)
+
+**Raw Response (170 bytes):**
+626f752032206765742d6174740a01000101010201010101010101010100000000000020000000000000000000000000200000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a191b04050607080000000101010101060606050505050500000000000000000a00
+
+Known set Parameters for each button :
+Button 1 :I
+Action type : On
+Melody Number : 26
+Repeates -
+Volume : 6
+Repeat Continuously : Yes
+Alarm : No
+All Selected zones : Zone 6 only
+
+**Decoded Header:**
+bou 2 get-att\n[170 bytes of binary configuration data]
+
+#### Configuration Data Structure (Binary Payload Analysis)
+
+The 170-byte payload appears to contain:
+
+**Bytes 0-12: Button Configuration Matrix**
+01000101010201010101010101000000000020000000000000000000000000200000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
+
+This looks like a button mapping matrix where:
+- Each button can be configured with different behaviors
+- Values like `01`, `02` might represent different button types or actions
+- `20` (0x20 = 32 decimal) could be zone assignments or timing values
+
+**Bytes 140-156: Device Settings**
+1a191b04050607080000000101010101060606050505050500000000000000000a00
+
+Breaking this down:
+- `1a191b` = Could be firmware version (26.25.27 in decimal) (this is 100% the melody Number! we have Button 1 : Melody 26, Button 2 : Melody 25, Button 3 : Melody 27, Button 4 : all off)
+- `04050607080000` = Possibly button assignments or melody IDs (could also just be dummy data maybe ?)
+- `000101010101` = Enable/disable flags for 6 buttons (but we only have 4 ????)
+- `060606050505050` = Volume levels or repeat counts for each button (i mean we have not one vol 5 action fyi)
+- `0a00` = Footer/checksum
+
+### SET-ATT Command (Set Attributes)
+
+**Configuration Change Request:**
+626f752031207365742d6174740a4d656c6f6479730a35343332310a[184 bytes config data]
+
+**Decoded:**
+bou 1 set-att\nMelodys\n54321\n[new configuration data]
+
+The configuration data in SET commands is similar to GET responses but may include:
+- Updated button mappings
+- New zone assignments  
+- Modified volume/repeat settings
+
+### ACK Response
+
+**Device Acknowledgment:**
+626f752032207365742d6174740a61636b0a00
+
+**Decoded:**
+bou 2 set-att\nack\n\x00
+
+Simple acknowledgment that configuration was applied successfully.
+
+## Deep Analysis of Configuration Examples
+
+### Example 1 - All Alarm Mode with 4 Repeats
+
+**Configuration String:**
+626f752031207365742d6174740a4d656c6f6479730a35343332310a010001010101010101010000000000000000200000000000000000000000002000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008090a0b050607080404040401010101080808080505050501010101000000000a00
+
+**Button Configuration:**
+- All 4 buttons: Action ON, Alarm YES, Volume 8, 4 Repeats, All Zones
+- Melody Numbers: 8, 9, 10, 11 (buttons 1-4 respectively)
+
+**Key Binary Fields:**
+- Melody section: `08090a0b` = melodies 8,9,10,11 in decimal
+- Repeat section: `04040404` = 4 repeats for all buttons
+- Volume section: `08080808` = volume 8 for all buttons
+- Zone configuration shows "All Zones" pattern
+
+### Example 2 - Varying Repeat Counts
+
+**Configuration String:**
+626f752031207365742d6174740a4d656c6f6479730a35343332310a010001010101010101010000000000000000200000000000000000000000002000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008090a0b050607080102030401010101080808080505050501010101000000000a00
+
+**Button Configuration:**
+- All 4 buttons: Action ON, Alarm YES, Volume 8, All Zones
+- Repeat counts: 1,2,3,4 (buttons 1-4 respectively)
+- Same melody numbers: 8,9,10,11
+
+**Key Discovery:**
+- Repeat section: `01020304` = individual repeat counts per button
+- All other fields remain identical to Example 1
+- Demonstrates granular repeat control
+
+### Example 3 - Alarm Mode Disabled
+
+**Configuration String:**
+626f752031207365742d6174740a4d656c6f6479730a35343332310a010001010101010101010000000000000000200000000000000000000000002000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008090a0b050607080102030401010101080808080505050500000000000000000a00
+
+**Button Configuration:**
+- Same as Example 2 but all buttons changed from Alarm YES to normal melodies
+- Repeat counts maintained: 1,2,3,4
+- Volume 8 maintained for all buttons
+
+**Key Difference:**
+- Alarm mode section shows change from `01010101` to `00000000`
+- Demonstrates alarm/melody mode toggle capability
+
+### Example 4 - Zone 6 Targeting
+
+**Configuration String:**
+626f752031207365742d6174740a4d656c6f6479730a35343332310a010001010101010101010101010100000000200000000000000000000000002000000000000000000000000020000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008090a0b050607080102030401010101080808080505050500000000000000000a00
+
+**Button Configuration:**
+- Same as Example 3 but all buttons now target Zone 6 only instead of All Zones
+- Normal melody mode maintained
+- Same repeat and volume settings
+
+**Zone Configuration Change:**
+- Zone targeting pattern changes from "All Zones" to specific Zone 6 pattern
+- Shows single zone targeting capability
+
+### Example 5 - Complex Multi-Zone Configuration
+
+**Configuration String:**
+626f752031207365742d6174740a4d656c6f6479730a35343332310a0100010101010101010101010101000000008b8000000000000000000000008b0000000000000000000000000b000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008090a0b050607080102030401010101080808080505050500000000000000000a00
+
+**Button Configuration:**
+- Button 1: Zones 1,2,4,8,16
+- Button 2: Zones 1,2,4,8  
+- Button 3: Zones 1,2,4
+- Button 4: Zones 1,2
+
+**Zone Encoding Analysis:**
+- `8b80` = Button 1 zones (1+2+4+8+16 = zones with bits set)
+- `8b00` = Button 2 zones (1+2+4+8)
+- `0b00` = Button 3 zones (1+2+4)
+- `0300` = Button 4 zones (1+2)
+
+**Critical Discovery - Zone Bitfield Encoding:**
+The zone configuration uses a bitfield where:
+- Each zone is represented by a specific bit position
+- Multiple zones can be combined by OR-ing bits
+- Demonstrates advanced multi-zone targeting capability
+
+## Configuration Protocol Structure Analysis
+
+### Binary Payload Structure (170 bytes)
+
+**Header Section (14 bytes):**
+`bou 1 set-att\nMelodys\n54321\n`
+
+**Configuration Data (170 bytes):**
+1. **Button Enable Flags** (bytes 0-11): Button on/off states
+2. **Zone Configuration** (bytes 12-139): Complex bitfield for zone targeting
+3. **Melody Numbers** (bytes 140-143): `08090a0b` = melodies 8,9,10,11
+4. **System Data** (bytes 144-149): `050607080` = unknown system parameters, static accross multiple devices
+5. **Repeat Counts** (bytes 150-153): Individual repeat settings per button
+6. **Enable Flags** (bytes 154-159): `010101` = button enable confirmations
+7. **Volume Levels** (bytes 160-166): Volume settings per button
+8. **Additional Flags** (bytes 167-169): Mode/alarm settings
+9. **Footer** (bytes 170-171): `0a00` = configuration checksum?
+
+## Key Protocol Insights
+
+1. **Granular Control**: Individual buttons can have different repeat counts, volumes, and zone targets
+2. **Zone Flexibility**: Supports both "All Zones" and complex multi-zone configurations
+3. **Alarm Mode Toggle**: Buttons can switch between normal melody and alarm modes
+4. **Binary Efficiency**: Uses bitfields for zone targeting, enabling complex configurations
+5. **Configuration Validation**: Footer checksum ensures data integrity
+
+## Security Implications
+
+1. **Static Authentication**: "54321" token appears unchangeable across all examples
+2. **Predictable Structure**: Configuration format easily reverse-engineered
+3. **Zone Manipulation**: Unauthorized zone reconfiguration possible
+4. **Volume/Alarm Abuse**: Settings could be modified for disruptive purposes
+5. **Multi-Zone Targeting**: Complex zone combinations enable wide-area effects
+
+## Implementation Requirements
+
+For programmatic configuration:
+1. **TCP Connection**: Connect to device port 5666
+2. **Command Structure**: Use "bou 1 set-att" format with proper authentication
+3. **Binary Encoding**: Handle 170-byte configuration structure correctly
+4. **Zone Bitfields**: Implement zone encoding for multi-target configurations
+5. **Validation**: Include proper footer checksums for data integrity
+
+## Conclusion
+
+The Bodet Harmony configuration protocol provides comprehensive control over button behavior through a well-structured binary format. The examples demonstrate sophisticated zone targeting capabilities and granular per-button control. However, the static authentication and predictable structure present security concerns that require network-level protections.
+
 
 ## Reminence of the Journey here.
 

+ 0 - 0
bodet_psa_cli.py


+ 34 - 0
executables/extract-tcp.sh

@@ -0,0 +1,34 @@
+#!/bin/bash
+# Usage: ./extract-tcp.sh input.pcap output.txt
+
+if [ "$#" -ne 2 ]; then
+    echo "Usage: $0 <input.pcap> <output.txt>"
+    exit 1
+fi
+
+INPUT="$1"
+OUTPUT="$2"
+
+# Extract TCP packet information with headers and payload data
+tshark -r "$INPUT" -Y "tcp" -T fields \
+    -e data \
+    -e frame.number \
+    -e frame.time_relative \
+    -e ip.src \
+    -e ip.dst \
+    -e tcp.srcport \
+    -e tcp.dstport \
+    -e tcp.seq \
+    -e tcp.ack \
+    -e tcp.flags \
+    -e tcp.flags.syn \
+    -e tcp.flags.ack \
+    -e tcp.flags.fin \
+    -e tcp.flags.reset \
+    -e tcp.flags.push \
+    -e tcp.len \
+    -E header=y \
+    -E separator="|" > "$OUTPUT"
+
+echo "TCP packet analysis written to $OUTPUT"
+echo "Format: HexData|Frame#|Time|SrcIP|DstIP|SrcPort|DstPort|Seq|Ack|Flags|SYN|ACK|FIN|RST|PSH|DataLen"

+ 35 - 0
research/config-buttons.txt

@@ -0,0 +1,35 @@
+data|frame.number|frame.time_relative|ip.src|ip.dst|tcp.srcport|tcp.dstport|tcp.seq|tcp.ack|tcp.flags|tcp.flags.syn|tcp.flags.ack|tcp.flags.fin|tcp.flags.reset|tcp.flags.push|tcp.len
+- Opened the config page of the propriatary software for the button device
+|1|0.000000000|172.16.1.148|172.20.20.56|62904|5666|0|0|0x0002|1|0|0|0|0|0
+|2|0.005513000|172.20.20.56|172.16.1.148|5666|62904|0|1|0x0012|1|1|0|0|0|0
+|3|0.005613000|172.16.1.148|172.20.20.56|62904|5666|1|1|0x0010|0|1|0|0|0|0
+626f752031206765742d6174740a4d656c6f6479730a35343332310a00|4|0.015427000|172.16.1.148|172.20.20.56|62904|5666|1|1|0x0018|0|1|0|0|1|29
+- Above is probably the software asking what the current configuration is
+|5|0.019160000|172.20.20.56|172.16.1.148|5666|62904|1|30|0x0018|0|1|0|0|1|0
+626f752032206765742d6174740a01000101010201010101010101000000000020000000000000000000000000200000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a191b04050607080000000101010101060606050505050500000000000000000a00|6|0.026362000|172.20.20.56|172.16.1.148|5666|62904|1|30|0x0018|0|1|0|0|1|170
+- Above is probably the button device sending its current configuration
+|7|0.026681000|172.16.1.148|172.20.20.56|62904|5666|30|171|0x0011|0|1|1|0|0|0
+|8|0.041364000|172.20.20.56|172.16.1.148|5666|62904|171|31|0x0018|0|1|0|0|1|0
+|9|0.045727000|172.20.20.56|172.16.1.148|5666|62904|171|0|0x0004|0|0|0|1|0|0
+|10|42.654927000|172.16.1.148|172.20.20.56|62908|5666|0|0|0x0002|1|0|0|0|0|0
+|11|42.673203000|172.20.20.56|172.16.1.148|5666|62908|0|1|0x0012|1|1|0|0|0|0
+|12|42.673340000|172.16.1.148|172.20.20.56|62908|5666|1|1|0x0010|0|1|0|0|0|0
+626f752031207365742d6174740a4d656c6f6479730a35343332310a01000101010201010101000101000000000020000000000000000000000000200000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a191b04050607080100000101010101060606050505050500000000000000000a00|13|42.683610000|172.16.1.148|172.20.20.56|62908|5666|1|1|0x0018|0|1|0|0|1|184
+- Above I've changed the configuration of the button device and hit apply in the software
+|14|42.687708000|172.20.20.56|172.16.1.148|5666|62908|1|185|0x0018|0|1|0|0|1|0
+626f752032207365742d6174740a61636b0a00|15|43.643166000|172.20.20.56|172.16.1.148|5666|62908|1|185|0x0018|0|1|0|0|1|19
+- Above is probably the button sending an okay or something like that
+|16|43.643561000|172.16.1.148|172.20.20.56|62908|5666|185|20|0x0011|0|1|1|0|0|0
+|17|43.649293000|172.20.20.56|172.16.1.148|5666|62908|20|186|0x0018|0|1|0|0|1|0
+|18|43.675325000|172.20.20.56|172.16.1.148|5666|62908|20|0|0x0004|0|0|0|1|0|0
+|19|55.997038000|172.16.1.148|172.20.20.56|62909|5666|0|0|0x0002|1|0|0|0|0|0
+|20|56.001572000|172.20.20.56|172.16.1.148|5666|62909|0|1|0x0012|1|1|0|0|0|0
+|21|56.001773000|172.16.1.148|172.20.20.56|62909|5666|1|1|0x0010|0|1|0|0|0|0
+626f752031207365742d6174740a4d656c6f6479730a35343332310a01000101010201010101010101000000000020000000000000000000000000200000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a191b04050607080000000101010101060606050505050500000000000000000a00|22|56.012191000|172.16.1.148|172.20.20.56|62909|5666|1|1|0x0018|0|1|0|0|1|184
+- Above is me changing the config back to the original one
+|23|56.136788000|172.20.20.56|172.16.1.148|5666|62909|1|185|0x0018|0|1|0|0|1|0
+626f752032207365742d6174740a61636b0a00|24|56.674136000|172.20.20.56|172.16.1.148|5666|62909|1|185|0x0018|0|1|0|0|1|19
+- Above is probably the button sending an okay or something like that
+|25|56.674457000|172.16.1.148|172.20.20.56|62909|5666|185|20|0x0011|0|1|1|0|0|0
+|26|56.678054000|172.20.20.56|172.16.1.148|5666|62909|20|186|0x0018|0|1|0|0|1|0
+|27|56.696349000|172.20.20.56|172.16.1.148|5666|62909|20|0|0x0004|0|0|0|1|0|0

+ 104 - 243
research/sample-data/mp3_psa_streamer.py

@@ -8,46 +8,36 @@ import argparse
 import random
 import tempfile
 import subprocess
-from tqdm import tqdm  # progress bar sexxy
-
-# need that for lame transcoding
-try:
-    from mutagen.mp3 import MP3 # type: ignore
-    MUTAGEN_AVAILABLE = True
-except ImportError:
-    MUTAGEN_AVAILABLE = False
+from tqdm import tqdm  # For progress bar
 
 # Default configuration values
-DEFAULT_MULTICAST_ADDR = "172.16.20.109"
+DEFAULT_MULTICAST_ADDR = "239.192.55.1"
 DEFAULT_PORT = 1681
-DEFAULT_ZONE_INFO = "01 1000 0000 0000 0000 0000 0000"  # Zone 1
-DEFAULT_SEND_DUPLICATES = True # Do it like Bodet SIGMA does and just send duplicates "for redundancy" (UDP for emergency systems who the fuck though that was a good idea)
-DEFAULT_CHUNK_SIZE = 1000  # Need to use 1000 due to not understanding protocol fully yet
-DEFAULT_TTL = 3 # We want it going to Multicast and then to the client, nomore otherwise bodeter speaker gets confused
-DEFAULT_HEADER = "4d454c"  # MEL goofy ah header
-DEFAULT_COMMAND = "0703"  # Probably the stream command
-DEFAULT_STREAM_ID = "110c"  # Write none without qoutes to use random stream ID
+DEFAULT_ZONE_INFO = "100000000000000000000000"  # Zone 1
+DEFAULT_SEND_DUPLICATES = True
+DEFAULT_CHUNK_SIZE = 900
+DEFAULT_TTL = 2
+DEFAULT_HEADER = "4d454c"  # MEL header
+DEFAULT_COMMAND = "070301"  # Stream command
 
-# Prem prem maximum it supports
+# Audio quality presets
 HIGH_QUALITY = {
-    "bitrate": "256k",  # 256k max
-    "sample_rate": 48000, # 48000 max
-    "channels": 1,  # mono because uhm one speaker only
-    "sample_format": "s32", # max s32
-    "description": "Highest Quality (256kbps, 48kHz)"
+    "bitrate": "64k",
+    "sample_rate": 48000,
+    "channels": 1,  # mono
+    "sample_format": "s32",  # 16-bit samples (default for MP3)
+    "description": "Ass but no Durchfall"
+    # Note: Original software appears to use LAME 3.99.5
 }
 
-# Bodeter Shitsy Sigma software defaults
 LOW_QUALITY = {
-    "bitrate": "64k", # Bodet Defaults
-    "sample_rate": 32000,  # Bodet MP3 file default
-    "channels": 1,
-    "sample_format": "s16", # bodet MP3 file default
-    "description": "Normal Quality (64kbps, 32kHz)"
+    "bitrate": "64k",
+    "sample_rate": 32000,
+    "channels": 1,  # mono
+    "sample_format": "s16",  # 16-bit samples (default for MP3)
+    "description": "Ass with durchfalls"
 }
 
-DEFAULT_TIMING_FACTOR = 1  # Used back when chat gpt suggested i should adjust the timing... but it was really just that I have to use 1000 byte chunks ...
-
 def compute_psa_checksum(data: bytes) -> bytes:
     """Compute PSA checksum for packet data."""
     var_e = 0x0000  # Starting seed value
@@ -55,7 +45,7 @@ def compute_psa_checksum(data: bytes) -> bytes:
         var_e ^= (data[i] + i) & 0xFFFF
     return var_e.to_bytes(2, 'big')  # 2-byte checksum, big-endian
 
-def create_mp3_packet(sequence_num, zone_info, stream_id, mp3_chunk, show_packet_length=False):
+def create_mp3_packet(sequence_num, zone_info, stream_id, mp3_chunk):
     """Create a PSA packet containing MP3 data."""
     # Part 1: Header (static "MEL")
     header = bytes.fromhex("4d454c")
@@ -64,7 +54,7 @@ def create_mp3_packet(sequence_num, zone_info, stream_id, mp3_chunk, show_packet
     # Start marker (0100)
     start_marker = bytes.fromhex("0100")
     
-    # Sequence number (1 byte, Little Endian with FF padding)
+    # Sequence number (2 bytes, Little Endian with FF padding)
     seq = sequence_num.to_bytes(1, 'little') + b'\xff'
     
     # Command (static 070301)
@@ -73,19 +63,19 @@ def create_mp3_packet(sequence_num, zone_info, stream_id, mp3_chunk, show_packet
     # Zone info
     zones = bytes.fromhex(zone_info)
     
-    # Stream ID (2 bytes)
-    stream_id_bytes = stream_id.to_bytes(2, 'little')
+    # Stream ID (4 bytes)
+    stream_id_bytes = stream_id.to_bytes(4, 'little')
     
     # Constant data (appears in all original packets)
     constant_data = bytes.fromhex("05080503e8")
     
-    
     # Build the payload (everything that comes after start_marker)
     payload = seq + command + zones + stream_id_bytes + constant_data + mp3_chunk
     
-    # Calculate the length for the header - IMPORTANT: This matches what the PSA software does
-    # Length is the total packet length MINUS the header (4d454c) and the length field itself (2 bytes)
-    length_value = len(start_marker) + len(payload) + 7  # +2 for checksum
+    # Calculate the length for the header
+    # Length is everything AFTER the length field: start_marker + payload
+    # But NOT including checksum (which is calculated last)
+    length_value = len(start_marker + payload) + 7
     
     # Insert the length as a 2-byte value (big endian)
     length_bytes = length_value.to_bytes(2, 'big')
@@ -96,11 +86,9 @@ def create_mp3_packet(sequence_num, zone_info, stream_id, mp3_chunk, show_packet
     # Calculate and append checksum
     checksum = compute_psa_checksum(packet_data)
     
-    # Create final packet
+    # Debug the packet length
     final_packet = packet_data + checksum
-    
-    if show_packet_length:
-        print(f"Packet length: {len(final_packet)} bytes, Length field: {length_value} (0x{length_value:04x})")
+    print(f"Packet length: {len(final_packet)} bytes, Length field: {length_value} (0x{length_value:04x})")
     
     return final_packet
 
@@ -110,7 +98,7 @@ def send_multicast_packet(sock, packet, multicast_addr, port, send_duplicates=Tr
         # Send the packet
         sock.sendto(packet, (multicast_addr, port))
         
-        # Send a duplicate (common in multicast to improve "reliability")
+        # Send a duplicate (common in multicast to improve reliability)
         if send_duplicates:
             sock.sendto(packet, (multicast_addr, port))
         
@@ -128,86 +116,54 @@ def setup_multicast_socket(ttl=DEFAULT_TTL):
 def check_dependencies():
     """Check if required dependencies are installed."""
     try:
-        subprocess.run(['lame', '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+        subprocess.run(['ffmpeg', '-version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
     except FileNotFoundError:
-        print("Error: LAME encoder is required but not found.")
-        print("Please install LAME and make sure it's available in your PATH.")
+        print("Error: ffmpeg is required but not found.")
+        print("Please install ffmpeg and make sure it's available in your PATH.")
         sys.exit(1)
-    
-    if not MUTAGEN_AVAILABLE:
-        print("Warning: mutagen library not found. Limited audio file analysis available.")
-        print("Install with: pip install mutagen")
 
 def get_audio_info(mp3_file):
-    """Get audio file information using mutagen if available."""
+    """Get audio file information using ffprobe."""
     try:
-        if MUTAGEN_AVAILABLE:
-            # Use mutagen to analyze the file
-            audio = MP3(mp3_file)
-            
-            # Extract relevant information
-            sample_rate = audio.info.sample_rate
-            bit_rate = int(audio.info.bitrate / 1000)  # Convert to kbps
-            channels = audio.info.channels
-            codec_name = "mp3" if audio.info.layer == 3 else f"mpeg-{audio.info.layer}"
-            
-            return {
-                'bit_rate': bit_rate,
-                'sample_rate': sample_rate,
-                'channels': channels,
-                'codec_name': codec_name
-            }
-        else:
-            # Fallback method: use LAME to identify file info
-            cmd = ['lame', '--decode', mp3_file, '-t', '--brief', '-']
-            result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
-            
-            # LAME outputs limited info to stderr when using --brief
-            info_text = result.stderr
-            
-            # Very basic parsing - this is not ideal but works as a fallback
-            codec_name = "mp3"  # Assume it's MP3
-            sample_rate = 44100  # Default assumption
-            channels = 2  # Default assumption
-            bit_rate = 0  # Unknown
-            
-            for line in info_text.split('\n'):
-                if "MPEG" in line and "Layer" in line:
-                    codec_name = "mp3"
-                if "Hz" in line:
-                    parts = line.split()
-                    for part in parts:
-                        if "Hz" in part:
-                            try:
-                                sample_rate = int(part.replace("Hz", ""))
-                            except ValueError:
-                                pass
-                if "stereo" in line.lower():
-                    channels = 2
-                elif "mono" in line.lower():
-                    channels = 1
-                if "kbps" in line:
-                    parts = line.split()
-                    for part in parts:
-                        if "kbps" in part:
-                            try:
-                                bit_rate = int(part.replace("kbps", ""))
-                            except ValueError:
-                                pass
-            
-            return {
-                'bit_rate': bit_rate,
-                'sample_rate': sample_rate,
-                'channels': channels,
-                'codec_name': codec_name
-            }
+        # Get audio stream information
+        cmd = [
+            'ffprobe', '-v', 'quiet', '-print_format', 'json',
+            '-show_streams', '-select_streams', 'a:0', mp3_file
+        ]
+        result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+        
+        if result.returncode != 0:
+            print(f"Error analyzing audio file: {result.stderr}")
+            return None
+        
+        import json
+        info = json.loads(result.stdout)
+        
+        if 'streams' not in info or len(info['streams']) == 0:
+            print("No audio stream found in the file.")
+            return None
+        
+        stream = info['streams'][0]
+        
+        # Extract relevant information
+        bit_rate = int(stream.get('bit_rate', '0')) // 1000 if 'bit_rate' in stream else None
+        sample_rate = int(stream.get('sample_rate', '0'))
+        channels = int(stream.get('channels', '0'))
+        codec_name = stream.get('codec_name', 'unknown')
+        
+        return {
+            'bit_rate': bit_rate,
+            'sample_rate': sample_rate,
+            'channels': channels,
+            'codec_name': codec_name
+        }
     
     except Exception as e:
         print(f"Error getting audio information: {e}")
         return None
 
 def transcode_mp3(input_file, quality_preset, include_metadata=False):
-    """Transcode MP3 file using LAME encoder."""
+    """Transcode MP3 file to match required specifications."""
     print(f"Transcoding audio to {quality_preset['description']}...")
     
     # Create temporary output file
@@ -215,28 +171,25 @@ def transcode_mp3(input_file, quality_preset, include_metadata=False):
     os.close(fd)
     
     try:
-        # Build LAME command
+        # Base command
         cmd = [
-            'lame',
-            '--quiet',  # Less output
-            '-m', 'm' if quality_preset['channels'] == 1 else 's',  # m=mono, s=stereo
-            '--resample', str(quality_preset['sample_rate'] // 1000),  # Convert to kHz
-            '-b', quality_preset['bitrate'].replace('k', ''),  # Remove 'k' suffix
-            '--cbr',  # Use constant bitrate
+            'ffmpeg',
+            '-y',  # Overwrite output file
+            '-i', input_file,
+            '-codec:a', 'libmp3lame',  # Force LAME encoder
+            '-ac', str(quality_preset['channels']),
+            '-ar', str(quality_preset['sample_rate']),
+            '-b:a', quality_preset['bitrate'],
+            '-sample_fmt', quality_preset['sample_format'],  # Use configurable sample format
         ]
         
         # Add options to minimize metadata if requested
         if not include_metadata:
-            cmd.append('--noreplaygain')
-            # Use more compatible tag options instead of --id3v2-none
-            cmd.append('--tt')
-            cmd.append('')  # Empty title
-            cmd.append('--tc')
-            cmd.append('')  # Empty comment
-            cmd.append('--nohist')
+            cmd.extend(['-metadata', 'title=', '-metadata', 'artist=', 
+                       '-metadata', 'album=', '-metadata', 'comment=',
+                       '-map_metadata', '-1'])  # Strip all metadata
         
-        # Input and output files
-        cmd.extend([input_file, temp_output])
+        cmd.append(temp_output)
         
         result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
         
@@ -253,16 +206,11 @@ def transcode_mp3(input_file, quality_preset, include_metadata=False):
             os.remove(temp_output)
         return None
 
-def prepare_mp3_file(mp3_file, quality, include_metadata=False, sample_format=None):
+def prepare_mp3_file(mp3_file, quality, include_metadata=False):
     """Ensure the MP3 file meets the required specifications."""
     # Check if quality is valid
     quality_preset = HIGH_QUALITY if quality == "high" else LOW_QUALITY
     
-    # Override sample format if specified
-    if sample_format:
-        quality_preset = quality_preset.copy()
-        quality_preset["sample_format"] = sample_format
-    
     # Get audio file information
     info = get_audio_info(mp3_file)
     if not info:
@@ -294,73 +242,29 @@ def prepare_mp3_file(mp3_file, quality, include_metadata=False, sample_format=No
         print("Audio file already meets required specifications.")
         return mp3_file
 
-def calculate_delay_ms(chunk_size, bitrate, timing_factor=DEFAULT_TIMING_FACTOR):
-    """
-    Calculate the appropriate delay between chunks for real-time streaming.
-    
-    Args:
-        chunk_size: Size of each audio chunk in bytes
-        bitrate: Audio bitrate (string like '128k' or integer)
-        timing_factor: Factor to adjust timing (lower = faster playback)
-    
-    Returns:
-        Delay in milliseconds between packets
-    """
-    if isinstance(bitrate, str) and bitrate.endswith('k'):
-        bitrate_kbps = int(bitrate[:-1])
+def calculate_delay_ms(chunk_size, quality):
+    """Calculate the appropriate delay between chunks for real-time streaming."""
+    # Calculate bytes_per_second dynamically from the bitrate
+    if quality == "high":
+        # 128kbps = 16,000 bytes/second
+        bytes_per_second = 16000
     else:
-        bitrate_kbps = int(bitrate)
+        # 40kbps = 5,000 bytes/second
+        bytes_per_second = 5000
     
-    bytes_per_second = bitrate_kbps * 1000 // 8
-    delay_ms = (chunk_size / bytes_per_second) * 1000 * timing_factor
+    # Calculate how long this chunk would play for in real-time
+    delay_ms = (chunk_size / bytes_per_second) * 1000
     return delay_ms
 
-class StreamTimer:
-    """Maintains proper timing for streaming audio packets."""
-    
-    def __init__(self, chunk_size, bitrate, timing_factor=DEFAULT_TIMING_FACTOR):
-        """Initialize the stream timer with audio parameters."""
-        self.chunk_size = chunk_size
-        self.bitrate = bitrate
-        self.timing_factor = timing_factor
-        self.start_time = None
-        self.packets_sent = 0
-        self.delay_per_packet = calculate_delay_ms(chunk_size, bitrate, timing_factor) / 1000.0
-        
-    def start(self):
-        """Start the stream timer."""
-        self.start_time = time.time()
-        self.packets_sent = 0
-        
-    def wait_for_next_packet(self):
-        """Wait until it's time to send the next packet."""
-        if self.start_time is None:
-            self.start()
-            return
-            
-        self.packets_sent += 1
-        
-        # Calculate when this packet should be sent
-        target_time = self.start_time + (self.packets_sent * self.delay_per_packet)
-        
-        # Calculate how long to wait
-        now = time.time()
-        wait_time = target_time - now
-        
-        # Only wait if we're ahead of schedule
-        if wait_time > 0:
-            time.sleep(wait_time)
-
 def stream_mp3_to_psa(mp3_file, multicast_addr, port, zone_info, send_duplicates,
-                      chunk_size, quality, include_metadata=False, sample_format=None,
-                      show_packet_length=False, timing_factor=DEFAULT_TIMING_FACTOR, fixed_stream_id=None):
+                      chunk_size, quality, include_metadata=False):
     """Stream an MP3 file to PSA system."""
     try:
         # Check dependencies
         check_dependencies()
         
         # Prepare MP3 file (transcode if needed)
-        prepared_file = prepare_mp3_file(mp3_file, quality, include_metadata, sample_format)
+        prepared_file = prepare_mp3_file(mp3_file, quality, include_metadata)
         if not prepared_file:
             print("Failed to prepare MP3 file.")
             return
@@ -368,30 +272,24 @@ def stream_mp3_to_psa(mp3_file, multicast_addr, port, zone_info, send_duplicates
         using_temp_file = prepared_file != mp3_file
         
         try:
-            # Use fixed stream ID if provided, otherwise generate a random one
-            if fixed_stream_id is not None:
-                stream_id = fixed_stream_id
-            else:
-                stream_id = random.randint(0, 0xFFFF)  # 16-bit (2 bytes) stream ID
+            # Generate random stream ID for this transmission
+            stream_id = random.randint(0, 0xFFFFFFFF)  # Changed to 32-bit to accommodate 4 bytes
             
             # Open the MP3 file
             with open(prepared_file, 'rb') as f:
                 mp3_data = f.read()
             
             # Calculate number of chunks
-            chunks = chunk_mp3_data(mp3_data, chunk_size)
+            chunks = [mp3_data[i:i+chunk_size] for i in range(0, len(mp3_data), chunk_size)]
             total_chunks = len(chunks)
             
-            # Get the appropriate quality preset
-            quality_preset = HIGH_QUALITY if quality == "high" else LOW_QUALITY
-            
-            # Calculate the appropriate delay for real-time streaming using the actual bitrate
-            delay_ms = calculate_delay_ms(chunk_size, quality_preset['bitrate'])
+            # Calculate the appropriate delay for real-time streaming
+            delay_ms = calculate_delay_ms(chunk_size, quality)
             
             print(f"Streaming {os.path.basename(mp3_file)} ({len(mp3_data)/1024:.2f} KB)")
             print(f"Split into {total_chunks} packets of {chunk_size} bytes each")
             print(f"Sending to multicast {multicast_addr}:{port}")
-            print(f"Stream ID: {stream_id:04x}, Zone info: {zone_info}")
+            print(f"Stream ID: {stream_id:08x}, Zone info: {zone_info}")
             print(f"Duplicate packets: {'Yes' if send_duplicates else 'No'}")
             print(f"Quality preset: {HIGH_QUALITY['description'] if quality == 'high' else LOW_QUALITY['description']}")
             print(f"Including metadata: {'Yes' if include_metadata else 'No'}")
@@ -400,17 +298,13 @@ def stream_mp3_to_psa(mp3_file, multicast_addr, port, zone_info, send_duplicates
             # Setup multicast socket
             sock = setup_multicast_socket()
             
-            # Initialize the stream timer with the appropriate parameters
-            timer = StreamTimer(chunk_size, quality_preset['bitrate'], timing_factor)
-            timer.start()
-            
             # Process and send each chunk
             sequence_num = 0
             with tqdm(total=total_chunks, desc="Streaming progress") as pbar:
                 try:
                     for i, chunk in enumerate(chunks):
                         # Create packet with current sequence number
-                        packet = create_mp3_packet(sequence_num, zone_info, stream_id, chunk, show_packet_length)
+                        packet = create_mp3_packet(sequence_num, zone_info, stream_id, chunk)
                         
                         # Send the packet
                         success = send_multicast_packet(sock, packet, multicast_addr, port, send_duplicates)
@@ -421,9 +315,9 @@ def stream_mp3_to_psa(mp3_file, multicast_addr, port, zone_info, send_duplicates
                         # Update progress bar
                         pbar.update(1)
                         
-                        # Wait for the next packet timing using our timer
+                        # Delay to match real-time playback
                         if i < total_chunks - 1:  # No need to wait after the last chunk
-                            timer.wait_for_next_packet()
+                            time.sleep(delay_ms / 1000.0)
                 except Exception as e:
                     print(f"Error during streaming: {e}")
             
@@ -441,28 +335,6 @@ def stream_mp3_to_psa(mp3_file, multicast_addr, port, zone_info, send_duplicates
         print(f"Error during streaming: {e}")
         sys.exit(1)
 
-def chunk_mp3_data(data, chunk_size):
-    """
-    Split MP3 data into chunks of specified size.
-    
-    Args:
-        data: The complete MP3 data as bytes
-        chunk_size: Size of each chunk in bytes
-    
-    Returns:
-        A list of data chunks
-    """
-    chunks = []
-    # Calculate how many chunks we'll have
-    num_chunks = (len(data) + chunk_size - 1) // chunk_size
-    
-    for i in range(num_chunks):
-        start = i * chunk_size
-        end = min((i + 1) * chunk_size, len(data))
-        chunks.append(data[start:end])
-    
-    return chunks
-
 def main():
     # Set up command line arguments
     parser = argparse.ArgumentParser(description="Stream MP3 to Bodet PSA System")
@@ -483,12 +355,6 @@ def main():
                         help="Include metadata in MP3 stream (default: no metadata)")
     parser.add_argument("-s", "--sample-format", choices=["s16", "s24", "s32"], default=None,
                         help="Sample format to use for transcoding (default: s16)")
-    parser.add_argument("--show-packet-length", action="store_true",
-                        help="Show packet length information during streaming")
-    parser.add_argument("-t", "--timing-factor", type=float, default=DEFAULT_TIMING_FACTOR,
-                        help=f"Timing adjustment factor (lower = faster playback, default: {DEFAULT_TIMING_FACTOR})")
-    parser.add_argument("--fixed-stream-id", type=int, 
-                        help="Use a fixed stream ID instead of a random one (0-65535)")
     
     args = parser.parse_args()
     
@@ -501,13 +367,8 @@ def main():
         not args.no_duplicates,
         args.chunk_size,
         args.quality,
-        args.include_metadata,
-        args.sample_format,
-        args.show_packet_length,
-        args.timing_factor,
-        args.fixed_stream_id  # Pass fixed_stream_id parameter
+        args.include_metadata
     )
 
-# Remove duplicate main() call
 if __name__ == "__main__":
     main()

+ 0 - 0
30_tagesschau-gong.mp3 → research/sample-data/random-music/30_tagesschau-gong.mp3