diff --git a/.gitignore b/.gitignore
index 524ce9d..44aa170 100644
--- a/.gitignore
+++ b/.gitignore
@@ -129,3 +129,7 @@ dmypy.json
.pyre/
*.py.swp
+
+# Dynamic_RDS runtime status
+Dynamic_RDS_Status.json
+Dynamic_RDS_Status.json.tmp
diff --git a/Dynamic_RDS.php b/Dynamic_RDS.php
index b725961..2b4ee62 100644
--- a/Dynamic_RDS.php
+++ b/Dynamic_RDS.php
@@ -46,7 +46,6 @@ public function getConfigFile(): ?string {
class DynamicRDSStatus {
private array $errors = [];
private array $warnings = [];
- private array $successes = [];
public function addError(string $message): void {
$this->errors[] = $message;
@@ -56,10 +55,6 @@ public function addWarning(string $message): void {
$this->warnings[] = $message;
}
- public function addSuccess(string $message): void {
- $this->successes[] = $message;
- }
-
public function hasErrors(): bool {
return !empty($this->errors);
}
@@ -72,14 +67,6 @@ public function displayMessages(): void {
foreach ($this->warnings as $warning) {
echo '
' . $warning . '
';
}
-
- if (!empty($this->successes)) {
- echo '';
- foreach ($this->successes as $success) {
- echo '
' . $success . '
';
- }
- echo '
';
- }
}
}
@@ -226,6 +213,7 @@ public function createAndDownload(): void {
$this->addFileToZip($zip, $rotatedLog, basename($rotatedLog));
}
$this->addFileToZip($zip, $this->configDirectory . "/plugin.Dynamic_RDS", "plugin.Dynamic_RDS");
+ $this->addFileToZip($zip, $this->dynRDSDir . "/Dynamic_RDS_Status.json", "Dynamic_RDS_Status.json");
$this->addFileToZip($zip, "/boot/firmware/config.txt", "config.txt");
$this->addFileToZip($zip, "/boot/uEnv.txt", "uEnv.txt");
@@ -333,22 +321,19 @@ function renderDynamicRDSStatus(
checkRaspberryPiConfiguration($status, $pluginSettings);
}
- // Add success messages
- if ($engineRunning) {
- $status->addSuccess('Dynamic RDS Engine is running');
- }
-
+ $transmitterInfo = 'No transmitter detected';
if ($transmitterType !== TransmitterType::NONE) {
$i2cType = determineI2CType($platform, $pluginSettings);
- $status->addSuccess(
- 'Detected ' . $transmitterType->value . ' on I2C ' .
- $i2cType . ' bus ' . $i2cBus . ' at address ' . $transmitterType->getAddressHex()
- );
+ $transmitterInfo = $transmitterType->value . ' · I2C ' .
+ $i2cType . ' bus ' . $i2cBus . ' · ' . $transmitterType->getAddressHex();
}
// Display all status messages
$status->displayMessages();
+ // Display live RDS output
+ displayLiveRDSSection($transmitterInfo, $transmitterType !== TransmitterType::NONE);
+
// Output JavaScript
outputJavaScript($transmitterType);
@@ -616,6 +601,301 @@ function displayMQTTSection(array $settings): void {
}
}
+/**
+ * Display live RDS output section
+ */
+function displayLiveRDSSection(string $transmitterInfo, bool $transmitterFound): void {
+?>
+
+
+
+
+
Now Broadcasting ·
+
ON AIR
+
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
Log - plugin-Dynamic_RDS.log (plus rotated copies, if present)
+Last RDS output - Dynamic_RDS_Status.json
Config - plugin.Dynamic_RDS
Version from git rev-parse --short HEAD
Pi/BBB boot config - /boot/firmware/config.txt or /boot/uEnv.txt
diff --git a/Dynamic_RDS_Engine.py b/Dynamic_RDS_Engine.py
index b510685..4d84ee1 100755
--- a/Dynamic_RDS_Engine.py
+++ b/Dynamic_RDS_Engine.py
@@ -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)
@@ -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 = []
@@ -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:
@@ -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()
@@ -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')
@@ -248,6 +291,7 @@ def excessive(msg, *args, **kwargs):
if config['DynRDSStart'] == "FPPDStart":
transmitter.startup()
+ writeStatus()
elif line == 'UPDATE':
read_config()
@@ -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')
@@ -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')
@@ -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)
diff --git a/QN8066.py b/QN8066.py
index 480b928..b7c34b1 100644
--- a/QN8066.py
+++ b/QN8066.py
@@ -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)
diff --git a/README.md b/README.md
index a70b005..2dd0f71 100644
--- a/README.md
+++ b/README.md
@@ -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 I2C 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 I2C 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)
@@ -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 I2C wires, interference can occur
- Try to lower the Chip Power and Amp Power, RF interference can impact I2C
- 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 I2C 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 I2C 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 μs | 75 μs for the US and South Korea, 50 μ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μ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 I2C mode for the Raspberry Pi, and PWM pin selection for both the Pi and BeagleBone Black. Most setups won't need to touch these.
diff --git a/Transmitter.py b/Transmitter.py
index ddc9783..1d7a924 100644
--- a/Transmitter.py
+++ b/Transmitter.py
@@ -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')
diff --git a/api.php b/api.php
index bf4cf75..917131a 100644
--- a/api.php
+++ b/api.php
@@ -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')
);
@@ -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);
diff --git a/config.py b/config.py
index 68fb7ae..033a540 100644
--- a/config.py
+++ b/config.py
@@ -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',
diff --git a/scripts/src_Dynamic_RDS_config.sh b/scripts/src_Dynamic_RDS_config.sh
index dc65e67..02c7865 100755
--- a/scripts/src_Dynamic_RDS_config.sh
+++ b/scripts/src_Dynamic_RDS_config.sh
@@ -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
diff --git a/settings.json b/settings.json
index e1604ba..1feb232 100644
--- a/settings.json
+++ b/settings.json
@@ -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",
@@ -369,7 +369,7 @@
"min": 3,
"max": 60,
"step": 1,
- "default": 8
+ "default": 7
},
"DynRDSRTSize": {
"name": "DynRDSRTSize",