Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,7 @@ dmypy.json
.pyre/

*.py.swp

# Dynamic_RDS runtime status
Dynamic_RDS_Status.json
Dynamic_RDS_Status.json.tmp
325 changes: 303 additions & 22 deletions Dynamic_RDS.php

Large diffs are not rendered by default.

63 changes: 55 additions & 8 deletions Dynamic_RDS_Engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def logUnhandledException(eType, eValue, eTraceback):

@atexit.register
def cleanup():
# Intentionally keep the RDS status file around for zip file and for UI to know things aren't running
try:
logging.debug('Cleaning up fifo')
os.unlink(fifo_path)
Expand Down Expand Up @@ -77,13 +78,50 @@ def updateRDSData():
transmitter.updateRDSData(rdsStyleToString(config['DynRDSPSStyle'], 8), rdsStyleToString(config['DynRDSRTStyle'], int(config['DynRDSRTSize'])))

if config['DynRDSmqttEnable'] == '1':
mqttStatus = {}
mqttStatus['PStext'] = transmitter.PStext
mqttStatus['RTtext'] = transmitter.RTtext
mqttStatus['PSfragments'] = transmitter.PS.fragments
mqttStatus['RTfragments'] = transmitter.RT.fragments
mqttStatus['RDSValues'] = rdsValues
mqtt.publish('status', json.dumps(mqttStatus, indent=8))
mqtt.publish('status', json.dumps(buildStatus(), indent=8))

writeStatus()

def buildStatus():
# Single source of truth for what is being broadcast - used by MQTT and the web UI
# Si4713 writes PS/RT directly to the chip and has no buffer objects
PSbuffer = getattr(transmitter, 'PS', None)
RTbuffer = getattr(transmitter, 'RT', None)
return {
'PStext': transmitter.PStext,
'RTtext': transmitter.RTtext,
'PSfragments': PSbuffer.fragments if PSbuffer is not None else [],
'RTfragments': RTbuffer.fragments if RTbuffer is not None else [],
'PSdelay': int(config.get('DynRDSPSUpdateRate', 4)),
'RTdelay': int(config.get('DynRDSRTUpdateRate', 7)),
'RDSValues': rdsValues,
'RDSEnabled': config['DynRDSEnableRDS'] == '1',
'transmitterActive': transmitter.active,
'transmitterType': config['DynRDSTransmitter'],
'pid': os.getpid()
}

lastStatus = None

def writeStatus():
# Status file for Dynamic_RDS.php - written on RDS data changes and
# transmitter state transitions, so mtime reflects the last real change
global lastStatus
if transmitter is None:
return
try:
payload = json.dumps(buildStatus(), indent=2)
if payload == lastStatus:
logging.excessive('Status unchanged, skipping write')
return
# Write to a temp file and replace so the web UI never reads a partial file
with open(status_path + '.tmp', 'w', encoding='UTF-8') as f:
f.write(payload)
os.replace(status_path + '.tmp', status_path)
lastStatus = payload
logging.excessive('Status file written')
except Exception:
logging.exception('writeStatus')

def rdsStyleToString(rdsStyle, groupSize):
outputRDS = []
Expand Down Expand Up @@ -163,6 +201,9 @@ def excessive(msg, *args, **kwargs):
logging.error('Unable to create lock. Another instance of Dynamic_RDS_Engine.py running?')
sys.exit(1)

# Status file for the web UI
status_path = script_dir + '/Dynamic_RDS_Status.json'

# Setup fifo
fifo_path = script_dir + "/Dynamic_RDS_FIFO"
try:
Expand Down Expand Up @@ -207,6 +248,7 @@ def excessive(msg, *args, **kwargs):
if line == 'EXIT':
logging.info('Processing exit')
transmitter.shutdown() # TODO: Can fail if transmitter wasn't set - Can fix with an if statement or look into using Transmitter base class initially
writeStatus()
mqtt.disconnect()
sys.exit()

Expand All @@ -217,6 +259,7 @@ def excessive(msg, *args, **kwargs):
transmitter.reset()
if config['DynRDSStart'] == "FPPDStart":
transmitter.startup()
writeStatus()

elif line == 'INIT': # From --list with callback.py
logging.info('Processing init')
Expand Down Expand Up @@ -248,6 +291,7 @@ def excessive(msg, *args, **kwargs):

if config['DynRDSStart'] == "FPPDStart":
transmitter.startup()
writeStatus()

elif line == 'UPDATE':
read_config()
Expand All @@ -257,12 +301,14 @@ def excessive(msg, *args, **kwargs):
rdsValues[key] = ''
updateRDSData()
transmitter.update()
writeStatus()

elif line == 'START':
logging.info('Processing start')
if config['DynRDSStart'] == "PlaylistStart" or not transmitter.active:
transmitter.startup()
activePlaylist = True
writeStatus()

elif line == 'STOP':
logging.info('Processing stop')
Expand All @@ -274,6 +320,7 @@ def excessive(msg, *args, **kwargs):
if config['DynRDSStop'] == "PlaylistStop":
transmitter.shutdown()
logging.info('Transmitter stopped')
writeStatus()

elif line.startswith('MAINLIST'):
logging.info('Processing MainPlaylist')
Expand Down Expand Up @@ -336,6 +383,6 @@ def excessive(msg, *args, **kwargs):
rdsValues['{T}'] = mpcLatest
updateRDSData()

if transmitter is None or not transmitter.active:
if len(line) == 0 and (transmitter is None or not transmitter.active or config['DynRDSEnableRDS'] != "1"):
logging.debug('Sleeping...')
time.sleep(3)
9 changes: 5 additions & 4 deletions QN8066.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,11 @@ def __init__(self, outer, data, delay=7):
def updateData(self, data):
super().updateData(data)
# Remove all trailing spaces and append chr(0x0d) if length < 64 (max RT fragment size)
for i in range(len(self.fragments)):
self.fragments[i] = self.fragments[i].rstrip()
if len(self.fragments[i]) < 64:
self.fragments[i] += chr(0x0d)
for i, fragment in enumerate(self.fragments):
fragment = fragment.rstrip()
if len(fragment) < 64:
fragment += chr(0x0d)
self.fragments[i] = fragment
self.ab = not self.ab
logging.info('RT %s', self.fragments)

Expand Down
87 changes: 86 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
> [!NOTE]
> Dynamic_RDS supports the **QN8066** and **Si4713** FM transmitter chips

Originally created for Falcon Player 6.0 (FPP) and updated to support FPP 9.0+, the Dynamic_RDS plugin can generate RDS (radio data system) messages similar to what is seen from typical FM stations. The RDS messages are fully customizable with static text, breaks, and grouping along with the supported file tag data fields of title, artist, album, genre, track number, and track length, as well as main playlist position and item count. Currently, the plugin supports the QN8066 chip and the Si4173 chip. The chips are controlled via the I<sup>2</sup>C bus.
Originally created for Falcon Player 6.0 (FPP) and updated to support FPP 10.0+, the Dynamic_RDS plugin can generate RDS (radio data system) messages similar to what is seen from typical FM stations. The RDS messages are fully customizable with static text, breaks, and grouping along with the supported file tag data fields of title, artist, album, genre, track number, and track length, as well as main playlist position and item count. Currently, the plugin runs on Raspberry Pi or BBB and supports the QN8066 chip and the Si4713 chip. The chips are controlled via the I<sup>2</sup>C bus.

## Si4713 transmitter board
Originally, the Si4713 breakout board was available from [AdaFruit](https://www.adafruit.com/product/1958) but it now out of stock. There are many clones of this board that can be found on [AliExpress](https://www.aliexpress.us/w/wholesale-Si4713-transmitter.html) or by a [Google Search](https://www.google.com/search?q=Si4713+transmitter)
Expand Down Expand Up @@ -130,3 +130,88 @@ During the plugin install, an example script is copied to the FPP `media/scripts
- Make sure the PWM wire does NOT run along side the I<sup>2</sup>C wires, interference can occur
- Try to lower the Chip Power and Amp Power, RF interference can impact I<sup>2</sup>C
- Move the antenna further away from the transmitter board and RPi/BBB

## Plugin Settings
All settings are on the plugin's config page, reachable from **Status/Control -> Dynamic RDS**. The page auto-detects your transmitter over I<sup>2</sup>C and hides the settings that don't apply to it.

> [!NOTE]
> Settings marked with a lightning bolt icon take effect immediately on the transmitter — no FPP restart needed.

### RDS Settings
| Setting | Default | Notes |
| --- | --- | --- |
| Enable RDS | On | Turns off all RDS transmission when unchecked |
| PI Code | `819b` | Program Identification code. Some older receivers translate this to a callsign — `819b` is WRAP, `5F64` is WEBS |
| Program Type | 2 - Information / Current Affairs | Standard PTY list; assignments differ between North America and Europe |
| PS Style Text | `{T}\|{A}[\|{P} of {C}]\|Merry\|Christ-\| -mas!` | Program Service, sent 8 characters at a time. This is what most radios display |
| PS Update Rate | 4 sec | Interval between 8-character updates (3-60). It takes ~1 second to send 8 characters, and some radios only display text after receiving a group twice |
| RT Style Text | `{T}[ by {A}][\|Track {P} of {C}] Merry Christmas!` | Radio Text — longer messages, slower update rate |
| RT Update Size | 32 chars | RT supports up to 64, but not all radios display that much at once. 32 is recommended |
| RT Update Rate | 7 sec | Interval between RT updates (3-60). Sending a full 64 characters takes ~4 seconds |

### Style Text Substitutions
The PS and RT style text fields accept substitutions that are filled in from the currently playing media:

| Code | Value |
| --- | --- |
| `{T}` | Title |
| `{A}` | Artist |
| `{B}` | Album |
| `{G}` | Genre |
| `{N}` | Track Number |
| `{L}` | Track Length, as 0:00 |
| `{C}` | Item count in the Main Playlist section |
| `{P}` | Item position in the Main Playlist section |

Formatting rules:
* Any static text can be mixed in freely
* `|` (pipe) splits between RDS groups, acting like a line break
* `[ ]` creates a subgroup — if **any** substitution inside is empty, the whole subgroup is dropped. This is how you avoid stray text like "by" with no artist
* Use `\` in front of `| { } [ ]` to display those characters literally
* The end of the style text implicitly acts as a line break
* `{P}` is set empty when both it and `{C}` are 1, to prevent "Track 1 of 1" messages

### Transmitter Type and Settings
| Setting | Default | Notes |
| --- | --- | --- |
| Transmitter Type | Auto-selected | Set from I<sup>2</sup>C detection (QN8066 at 0x21, Si4713 at 0x63). Can be overridden manually |
| Frequency | 100.10 | 60.00-108.00 for QN8066, 76.00-108.00 for Si4713 |
| Preemphasis | 75 &mu;s | 75 &mu;s for the US and South Korea, 50 &mu;s for most of the rest of the world |
| Antenna Tuning Capacitor *(Si4713)* | 0 | 0 lets the chip auto-tune; manual range is 1-191 |
| Reset Pin / GPIO *(Si4713)* | Pin 7 / GPIO 4 | The Si4713 needs its reset pin high for normal operation |

### Audio Settings (QN8066)
| Setting | Default | Notes |
| --- | --- | --- |
| Gain Adjustment | 0 | Range -15 to +20. Too high or too low causes distortion, random dropouts, or silence |
| Enable Soft Clipping | On | |
| Enable AGC | Off | Not recommended |

### Power Settings
| Setting | Default | Notes |
| --- | --- | --- |
| Chip Power *(QN8066)* | 122 | Range 92-122 |
| Chip Power *(Si4713)* | 115 | Range 88-120. Voltage accuracy above 115 dB&mu;V is not guaranteed |
| Enable PWM *(QN8066)* | Off | Hardware PWM on Pin 12 / GPIO 18 by default, used to control amplifier power. Requires on-board audio to be disabled, so an external sound card is needed |
| Amp Power *(QN8066)* | 0 | Range 0-100, controlled via PWM output |

### Plugin Activation
| Setting | Default | Notes |
| --- | --- | --- |
| Start with | FPPD Start | Or Playlist Start, or Never. On start the transmitter is reset, settings initialized, audio broadcast begins, and static RDS messages are sent |
| Stop with | Never | Or Playlist Stop. On stop the transmitter is reset and listeners hear static |

### MPC / After Hours Music
Enable to pull `%title%` from mpc and display it as `{T}` when FPP is otherwise idle. Only appears if the After Hours Music Player plugin is installed.

### MQTT
Publishes plugin status to MQTT. Requires MQTT to be configured first under **FPP Settings -> MQTT**, and `python3-paho-mqtt`, which can be installed with a button on the config page.

### Log Levels and Logs
Callback and Engine logging levels are set separately (Errors Only / Warn / Info / Debug, plus Excessive for the Engine). Both write to `plugin-Dynamic_RDS.log`, viewable from the config page.

### Report an Issue
Set the log levels to Debug, reproduce the problem, then use **Download log and config zip** and attach the file to a [new issue](https://github.com/ShadowLight8/Dynamic_RDS/issues). The zip contains the log and rotated copies, the `plugin.Dynamic_RDS` config, last RDS output, the plugin version, and your Pi/BBB boot config.

### Advanced Options
Software I<sup>2</sup>C mode for the Raspberry Pi, and PWM pin selection for both the Pi and BeagleBone Black. Most setups won't need to touch these.
4 changes: 2 additions & 2 deletions Transmitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ def __init__(self, data='', frag_size=0, group_size=0, delay=4):
self.pi_byte1 = int('0x' + config['DynRDSPICode'][0:2], 16)
self.pi_byte2 = int('0x' + config['DynRDSPICode'][2:4], 16)
self.pty = int(config['DynRDSPty'])
self.updateData(data)
self.fragments = []
self.currentFragment = 0
self.lastFragmentTime = 0
self.lastFragmentTime = datetime.now()
self.currentGroup = 0
self.updateData(data)

def updateData(self, data):
logging.debug('RDSBuffer updateData')
Expand Down
35 changes: 35 additions & 0 deletions api.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
function getEndpointsDynamic_RDS() {
$endpoints = array(
array('method' => 'GET', 'endpoint' => 'FastUpdate', 'callback' => 'DynRDSFastUpdate'),
array('method' => 'GET', 'endpoint' => 'Status', 'callback' => 'DynRDSStatus'),
array('method' => 'POST', 'endpoint' => 'PiBootChange/:SettingName', 'callback' => 'DynRDSPiBootChange'),
array('method' => 'POST', 'endpoint' => 'ScriptStream', 'callback' => 'DynRDSScriptStream')
);
Expand All @@ -13,6 +14,40 @@ function DynRDSFastUpdate() {
shell_exec("sudo /home/fpp/media/plugins/Dynamic_RDS/callbacks.py --update");
}

function DynRDSStatus() {
$statusFile = __DIR__ . '/Dynamic_RDS_Status.json';

if (!is_file($statusFile)) {
return json_encode(['error' => 'No status file - Dynamic RDS Engine may not have started']);
}

$status = json_decode(file_get_contents($statusFile), true);
if (!is_array($status)) {
return json_encode(['error' => 'Unable to parse status file']);
}

// Writes only happen when the broadcast content changes, so file age is not
// a heartbeat - minutes of silence during a long track is normal. Liveness
// comes from checking the recorded pid is still a running Engine.
$status['age'] = time() - filemtime($statusFile);
$status['running'] = DynRDSEngineAlive($status['pid'] ?? 0);

return json_encode($status);
}

function DynRDSEngineAlive($pid) {
$pid = (int)$pid;
if ($pid <= 0) {
return false;
}
$cmdline = @file_get_contents("/proc/{$pid}/cmdline");
if ($cmdline === false) {
return false;
}
// Guard against a recycled pid now belonging to an unrelated process
return str_contains($cmdline, 'Dynamic_RDS_Engine.py');
}

function DynRDSPiBootChange() {
$settingName = params('SettingName');
$myPluginSettings = json_decode(file_get_contents('php://input'), true);
Expand Down
4 changes: 2 additions & 2 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
'DynRDSEnableRDS': '1',
'DynRDSPSUpdateRate': '4',
'DynRDSPSStyle': '{T}|{A}[|{P} of {C}]|Merry|Christ-| -mas!',
'DynRDSRTUpdateRate': '8',
'DynRDSRTUpdateRate': '7',
'DynRDSRTSize': '32',
'DynRDSRTStyle': '{T}[ by {A}][|Track {P} of {C} ]Merry Christmas!',
'DynRDSRTStyle': '{T}[ by {A}][|Track {P} of {C}] Merry Christmas!',
'DynRDSPty': '2',
'DynRDSPICode': '819b',
'DynRDSTransmitter': 'None',
Expand Down
2 changes: 1 addition & 1 deletion scripts/src_Dynamic_RDS_config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
PS='{T}|{A}[|{P} of {C}]|Merry|Christ-| -mas!'

# Set the RT text (set to '' or comment out to leave unchanged)
RT='{T}[ by {A}][ - Track {P} of {C} ]|Merry Christmas!'
RT='{T}[ by {A}][|Track {P} of {C}] Merry Christmas!'

if [ "$PS" != "" ]; then
echo 'Setting PS Style Text to: '$PS
Expand Down
4 changes: 2 additions & 2 deletions settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@
"type": "text",
"size": 64,
"maxlength": 256,
"default": "{T}[ by {A}][|Track {P} of {C} ]Merry Christmas!"
"default": "{T}[ by {A}][|Track {P} of {C}] Merry Christmas!"
},
"DynRDSRTUpdateRate": {
"name": "DynRDSRTUpdateRate",
Expand All @@ -369,7 +369,7 @@
"min": 3,
"max": 60,
"step": 1,
"default": 8
"default": 7
},
"DynRDSRTSize": {
"name": "DynRDSRTSize",
Expand Down