From 9f9ceb0519d6d31f7f30be5ab7cfe8257f3566be Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Tue, 4 Nov 2025 21:23:24 -0500 Subject: [PATCH 01/50] Modernize and clean up config page --- Dynamic_RDS.php | 749 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 563 insertions(+), 186 deletions(-) diff --git a/Dynamic_RDS.php b/Dynamic_RDS.php index 6dda86d..2b4fb54 100644 --- a/Dynamic_RDS.php +++ b/Dynamic_RDS.php @@ -1,164 +1,478 @@ - -
-

Status

-open($zipName, ZipArchive::CREATE)!==TRUE) { - echo '
Unable to create ZIP file to download
'; - exit; - } - if (is_file($dynRDSDir . "/Dynamic_RDS_callbacks.log")) { - $zip->addFile($dynRDSDir . "/Dynamic_RDS_callbacks.log", "Dynamic_RDS_callbacks.log"); - } - if (is_file($dynRDSDir . "/Dynamic_RDS_Engine.log")) { - $zip->addFile($dynRDSDir . "/Dynamic_RDS_Engine.log", "Dynamic_RDS_Engine.log"); - } - if (is_file($configDirectory . "/plugin.Dynamic_RDS")) { - $zip->addFile($configDirectory . "/plugin.Dynamic_RDS", "plugin.Dynamic_RDS"); - } - if (is_file("/boot/config.txt")) { - $zip->addFile("/boot/config.txt", "old-config.txt"); - } - if (is_file("/boot/firmware/config.txt")) { - $zip->addFile("/boot/firmware/config.txt", "config.txt"); - } - if (is_file("/boot/uEnv.txt")) { - $zip->addFile("/boot/uEnv.txt", "uEnv.txt"); - } - $zip->addFromString('Dynamic_RDS_version.txt', shell_exec('git -C ' . $dynRDSDir . ' rev-parse --short HEAD')); - $zip->close(); - if (is_file($zipName)) { - header("Content-Disposition: attachment; filename=\"" . basename($zipName) . "\""); - header("Content-Type: application/octet-stream"); - header("Content-Length: ".filesize($zipName)); - header("Connection: close"); - flush(); - readfile($zipName); - unlink($zipName); - } - exit; -} - -$errorDetected = false; - -if (empty(trim(shell_exec("dpkg -s python3-smbus | grep installed")))) { - echo '
python3-smbus is missing
'; - $errorDetected = true; -} - -$i2cbus = -1; -if ($isBBB && file_exists('/dev/i2c-2')) { - $i2cbus = 2; -} elseif (file_exists('/dev/i2c-0')) { - $i2cbus = 0; -} elseif (file_exists('/dev/i2c-1')) { - $i2cbus = 1; -} else { - echo '
Unable to find an I2C bus - On RPi, check /boot/firmware/config.txt for I2C entry
'; - $errorDetected = true; -} - -$engineRunning = true; -if (empty(trim(shell_exec("ps -ef | grep python.*Dynamic_RDS_Engine.py | grep -v grep")))) { - sleep(1); - if (empty(trim(shell_exec("ps -ef | grep python.*Dynamic_RDS_Engine.py | grep -v grep")))) { - echo '
Dynamic RDS Engine is not running - Check logs for errors - Restart of FPPD is recommended
'; - $engineRunning = false; - $errorDetected = true; - } -} - -$transmitterType = ''; -$transmitterAddress = ''; -if ($i2cbus != -1) { - if (trim(shell_exec("sudo i2cget -y " . $i2cbus . " 0x21 2>&1")) != "Error: Read failed") { - $transmitterType = 'QN8066'; - $transmitterAddress = '0x21'; - } elseif (trim(shell_exec("sudo i2cget -y " . $i2cbus . " 0x63 2>&1")) != "Error: Read failed") { - $transmitterType = 'Si4713'; - $transmitterAddress = '0x63'; - } else { - echo '
No transmitter detected on I2C bus ' . $i2cbus . ' at addresses 0x21 (QN8066) or 0x63 (Si4713)
'; - echo 'Power cycle or reset of transmitter is recommended. SSH into FPP and run i2cdetect -y -r ' . $i2cbus . ' to check I2C status
'; - $errorDetected = true; - } -} - -if ($isRPi && isset($pluginSettings['DynRDSQN8066PIPWM']) && $pluginSettings['DynRDSQN8066PIPWM'] == 1 && is_numeric(strpos($pluginSettings['DynRDSAdvPIPWMPin'], ','))) { - if (shell_exec("lsmod | grep 'snd_bcm2835.*1\>'")) { - echo '
On-board sound card appears active and will interfere with hardware PWM. Try a reboot first, next toggle the Enable PI Hardware PWM setting below and reboot. If issues persist check /boot/firmware/config.txt and comment out dtparam=audio=on
'; - } - if (!file_exists('/sys/class/pwm/pwmchip0')) { - echo '
Hardware PWM has not been loaded. Try a reboot first, next toggle the Enable PI Hardware PWM setting below and reboot. If issues persist then check /boot/firmware/config.txt and add dtoverlay=pwm
'; - } -} - -$i2cBusType = 'hardware'; -if ($isRPi) { - if (isset($pluginSettings['DynRDSAdvPISoftwareI2C']) && $pluginSettings['DynRDSAdvPISoftwareI2C'] == 1) { - $i2cBusType = 'software'; - if (shell_exec("lsmod | grep i2c_bcm2835")) { - echo '
Hardware I2C appears active. Try a reboot first, next toggle the Use PI Software I2C setting below and reboot. If issues persist check /boot/firmware/config.txt and comment out dtparam=i2c_arm=on
'; - $i2cBusType = 'hardware'; - } - if (empty(shell_exec("lsmod | grep i2c_gpio"))) { - echo '
Software I2C has not been loaded. Try a reboot first, next toggle the Use PI Software I2C setting below and reboot. If issues persist then check /boot/firmware/config.txt and add dtoverlay=i2c-gpio,i2c_gpio_sda=2,i2c_gpio_scl=3,i2c_gpio_delay_us=4,bus=1
'; - } - } else { - if (shell_exec("lsmod | grep i2c_gpio")) { - echo '
Software I2C appears active. Try a reboot first, next toggle the Use PI Software I2C setting below and reboot. If issues persist check /boot/firmware/config.txt and comment out dtoverlay=i2c-gpio,i2c_gpio_sda=2,i2c_gpio_scl=3,i2c_gpio_delay_us=4,bus=1
'; - $i2cBusType = 'software'; - } - if (empty(shell_exec("lsmod | grep -e i2c_bcm2835 -e i2c_designware_core"))) { - echo '
Hardware I2C has not been loaded. Try a reboot first, next toggle the Use PI Software I2C setting below and reboot. If issues persist then check /boot/firmware/config.txt and add dtparam=i2c_arm=on
'; - } - } -} - -if ($engineRunning || $transmitterType != '') { - echo '
'; - if ($engineRunning) { - echo '
Dynamic RDS Engine is running
'; - } - if ($transmitterType != '') { - echo '
Detected ' . $transmitterType . ' on I2C ' . $i2cBusType . ' bus ' . $i2cbus . ' at address ' . $transmitterAddress . '
'; - } - echo '
'; + 0x21, + self::SI4713 => 0x63, + self::NONE => 0x00, + }; + } + + public function getAddressHex(): string { + return sprintf('0x%02x', $this->getI2CAddress()); + } } -?> +enum PlatformType: string { + case RASPBERRY_PI = 'RaspberryPi'; + case BEAGLEBONE_BLACK = 'BeagleBoneBlack'; + case UNKNOWN = 'Unknown'; + + public static function detect(): self { + if (file_exists('/boot/firmware/config.txt')) { + return self::RASPBERRY_PI; + } + if (file_exists('/boot/uEnv.txt')) { + return self::BEAGLEBONE_BLACK; + } + return self::UNKNOWN; + } + + public function getConfigFile(): ?string { + return match($this) { + self::RASPBERRY_PI => '/boot/firmware/config.txt', + self::BEAGLEBONE_BLACK => '/boot/uEnv.txt', + self::UNKNOWN => null, + }; + } +} + +class DynamicRDSStatus { + private array $errors = []; + private array $warnings = []; + private array $successes = []; + + public function addError(string $message): void { + $this->errors[] = $message; + } + + 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); + } + + public function displayMessages(): void { + foreach ($this->errors as $error) { + echo '
' . $error . '
'; + } + + foreach ($this->warnings as $warning) { + echo '
' . $warning . '
'; + } + + if (!empty($this->successes)) { + echo '
'; + foreach ($this->successes as $success) { + echo '
' . $success . '
'; + } + echo '
'; + } + } +} + +class ShellCommandExecutor { + public static function execute(string $command, array $args = []): string { + $escapedArgs = array_map('escapeshellarg', $args); + $fullCommand = sprintf($command, ...$escapedArgs); + return shell_exec($fullCommand) ?? ''; + } + + public static function isEmpty(string $output): bool { + return empty(trim($output)); + } +} + +class I2CDetector { + private int $bus; + + public function __construct(int $bus) { + if ($bus < 0) { + throw new \InvalidArgumentException("Invalid I2C bus: {$bus}"); + } + $this->bus = $bus; + } + + public function detectTransmitter(): TransmitterType { + // Check QN8066 at 0x21 + if ($this->isDevicePresent(0x21)) { + return TransmitterType::QN8066; + } + + // Check Si4713 at 0x63 + if ($this->isDevicePresent(0x63)) { + return TransmitterType::SI4713; + } + + return TransmitterType::NONE; + } + + private function isDevicePresent(int $address): bool { + $cmd = sprintf('sudo i2cget -y %d 0x%02x 2>&1', $this->bus, $address); + $result = trim(shell_exec($cmd) ?? ''); + return $result !== "Error: Read failed"; + } + + public function getBus(): int { + return $this->bus; + } +} + +class I2CBusDetector { + public static function detectBus(PlatformType $platform): int { + if ($platform === PlatformType::BEAGLEBONE_BLACK && file_exists('/dev/i2c-2')) { + return 2; + } + + if (file_exists('/dev/i2c-0')) { + return 0; + } + + if (file_exists('/dev/i2c-1')) { + return 1; + } + + return -1; + } +} + +class DependencyChecker { + public static function isPython3SmbusInstalled(): bool { + $output = ShellCommandExecutor::execute('dpkg -s python3-smbus | grep installed'); + return !ShellCommandExecutor::isEmpty($output); + } + + public static function isEngineRunning(): bool { + $output = ShellCommandExecutor::execute('ps -ef | grep python.*Dynamic_RDS_Engine.py | grep -v grep'); + if (!ShellCommandExecutor::isEmpty($output)) + return !ShellCommandExecutor::isEmpty($output); + sleep(1); + $output = ShellCommandExecutor::execute('ps -ef | grep python.*Dynamic_RDS_Engine.py | grep -v grep'); + return !ShellCommandExecutor::isEmpty($output); + } + + public static function isMPCInstalled(): bool { + return is_file('/bin/mpc') || is_file('/usr/bin/mpc'); + } + + public static function isPahoMQTTInstalled(): bool { + return file_exists('/usr/lib/python3/dist-packages/paho') || + file_exists('/usr/local/lib/python3.9/dist-packages/paho') || + file_exists('/usr/local/lib/python3.11/dist-packages/paho'); + } +} + +class RaspberryPiChecker { + public static function isOnBoardSoundActive(): bool { + $output = ShellCommandExecutor::execute("lsmod | grep 'snd_bcm2835.*1\\>'"); + return !ShellCommandExecutor::isEmpty($output); + } + + public static function isHardwarePWMLoaded(): bool { + return file_exists('/sys/class/pwm/pwmchip0'); + } + + public static function isHardwareI2CActive(): bool { + $output = ShellCommandExecutor::execute('lsmod | grep -e i2c_bcm2835 -e i2c_designware_core'); + return !ShellCommandExecutor::isEmpty($output); + } + + public static function isSoftwareI2CActive(): bool { + $output = ShellCommandExecutor::execute('lsmod | grep i2c_gpio'); + return !ShellCommandExecutor::isEmpty($output); + } +} + +class ZipDownloader { + private string $dynRDSDir; + private string $configDirectory; + + public function __construct(string $dynRDSDir, string $configDirectory) { + $this->dynRDSDir = $dynRDSDir; + $this->configDirectory = $configDirectory; + } + + public function createAndDownload(): void { + $zip = new ZipArchive(); + $zipName = $this->dynRDSDir . "/Dynamic_RDS_logs_config_" . date("YmdHis") . ".zip"; + + if ($zip->open($zipName, ZipArchive::CREATE) !== true) { + echo '
Unable to create ZIP file
'; + return; + } + + try { + $this->addFileToZip($zip, $this->dynRDSDir . "/Dynamic_RDS_callbacks.log", "Dynamic_RDS_callbacks.log"); + $this->addFileToZip($zip, $this->dynRDSDir . "/Dynamic_RDS_Engine.log", "Dynamic_RDS_Engine.log"); + $this->addFileToZip($zip, $this->configDirectory . "/plugin.Dynamic_RDS", "plugin.Dynamic_RDS"); + $this->addFileToZip($zip, "/boot/firmware/config.txt", "config.txt"); + $this->addFileToZip($zip, "/boot/uEnv.txt", "uEnv.txt"); + + // Add plugin version info + $version = ShellCommandExecutor::execute('git -C %s rev-parse --short HEAD', [$this->dynRDSDir]); + $zip->addFromString('Dynamic_RDS_version.txt', $version); + $zip->close(); + $this->downloadFile($zipName); + } finally { + if (file_exists($zipName)) { + unlink($zipName); + } + } + } + + private function addFileToZip(ZipArchive $zip, string $filePath, string $zipPath): void { + if (is_file($filePath)) { + $zip->addFile($filePath, $zipPath); + } + } + + private function downloadFile(string $filePath): void { + if (!is_file($filePath)) { + return; + } + + header("Content-Disposition: attachment; filename=\"" . basename($filePath) . "\""); + header("Content-Type: application/octet-stream"); + header("Content-Length: " . filesize($filePath)); + header("Connection: close"); + flush(); + readfile($filePath); + } +} + +function renderDynamicRDSStatus( + string $pluginDirectory, + string $configDirectory, + array $pluginSettings, + array $settings +): void { + $status = new DynamicRDSStatus(); + + // Handle page display + $noPage = isset($_GET['nopage']); + + if (!$noPage) { + echo '
'; + echo ''; + echo '

Status

'; + } + + // Detect platform + $platform = PlatformType::detect(); + $dynRDSDir = $pluginDirectory . '/' . ($_GET['plugin'] ?? 'Dynamic_RDS'); + + // Handle ZIP download + if (isset($_POST["DownloadZip"])) { + $zipDownloader = new ZipDownloader($dynRDSDir, $configDirectory); + $zipDownloader->createAndDownload(); + exit; + } + + // Check dependencies + if (!DependencyChecker::isPython3SmbusInstalled()) { + $status->addError('python3-smbus is missing '); + } + + // Detect I2C bus + $i2cBus = I2CBusDetector::detectBus($platform); + if ($i2cBus === -1) { + $status->addError('Unable to find an I2C bus - On RPi, check /boot/firmware/config.txt for I2C entry'); + } + + // Check engine status + $engineRunning = DependencyChecker::isEngineRunning(); + if (!$engineRunning) { + $status->addError('Dynamic RDS Engine is not running - Check logs for errors - Restart of FPPD is recommended'); + } + + // Detect transmitter + $transmitterType = TransmitterType::NONE; + if ($i2cBus !== -1) { + try { + $i2cDetector = new I2CDetector($i2cBus); + $transmitterType = $i2cDetector->detectTransmitter(); + + if ($transmitterType === TransmitterType::NONE) { + $status->addError( + 'No transmitter detected on I2C bus ' . $i2cBus . + ' at addresses 0x21 (QN8066) or 0x63 (Si4713)
' . + 'Power cycle or reset of transmitter is recommended. ' . + 'SSH into FPP and run i2cdetect -y -r ' . $i2cBus . ' to check I2C status', + ); + } + } catch (\Exception $e) { + $status->addError('Error detecting transmitter: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); + } + } + + if ($platform === PlatformType::RASPBERRY_PI) { + checkRaspberryPiConfiguration($status, $pluginSettings); + } + + // Add success messages + if ($engineRunning) { + $status->addSuccess('Dynamic RDS Engine is running'); + } + + if ($transmitterType !== TransmitterType::NONE) { + $i2cType = determineI2CType($platform, $pluginSettings); + $status->addSuccess( + 'Detected ' . $transmitterType->value . ' on I2C ' . + $i2cType . ' bus ' . $i2cBus . ' at address ' . $transmitterType->getAddressHex() + ); + } + + // Display all status messages + $status->displayMessages(); + + // Output JavaScript + outputJavaScript($transmitterType); + + // Display settings groups + displaySettingsGroups($settings); + + if (!$noPage) { + echo '
'; + } +} + +/** + * Check Raspberry Pi specific configuration + */ +function checkRaspberryPiConfiguration(DynamicRDSStatus $status, array $pluginSettings): void { + // Check PWM configuration + if (isset($pluginSettings['DynRDSQN8066PIPWM']) && + $pluginSettings['DynRDSQN8066PIPWM'] == 1 && + isset($pluginSettings['DynRDSAdvPIPWMPin']) && + str_contains($pluginSettings['DynRDSAdvPIPWMPin'], ',')) { + + if (RaspberryPiChecker::isOnBoardSoundActive()) { + $status->addWarning( + 'On-board sound card appears active and will interfere with hardware PWM. ' . + 'Try a reboot first, next toggle the Enable PI Hardware PWM setting below and reboot. ' . + 'If issues persist check /boot/firmware/config.txt and comment out dtparam=audio=on', + ); + } + + if (!RaspberryPiChecker::isHardwarePWMLoaded()) { + $status->addWarning( + 'Hardware PWM has not been loaded. Try a reboot first, ' . + 'next toggle the Enable PI Hardware PWM setting below and reboot. ' . + 'If issues persist then check /boot/firmware/config.txt and add dtoverlay=pwm', + ); + } + } + + // Check I2C configuration + checkI2CConfiguration($status, $pluginSettings); +} + +/** + * Check I2C configuration (hardware vs software) + */ +function checkI2CConfiguration(DynamicRDSStatus $status, array $pluginSettings): void { + $useSoftwareI2C = isset($pluginSettings['DynRDSAdvPISoftwareI2C']) && + $pluginSettings['DynRDSAdvPISoftwareI2C'] == 1; + + if ($useSoftwareI2C) { + if (RaspberryPiChecker::isHardwareI2CActive()) { + $status->addWarning( + 'Hardware I2C appears active. Try a reboot first, ' . + 'next toggle the Use PI Software I2C setting below and reboot. ' . + 'If issues persist check /boot/firmware/config.txt and comment out dtparam=i2c_arm=on', + ); + } + + if (!RaspberryPiChecker::isSoftwareI2CActive()) { + $status->addWarning( + 'Software I2C has not been loaded. Try a reboot first, ' . + 'next toggle the Use PI Software I2C setting below and reboot. ' . + 'If issues persist then check /boot/firmware/config.txt and add ' . + '
dtoverlay=i2c-gpio,i2c_gpio_sda=2,i2c_gpio_scl=3,i2c_gpio_delay_us=4,bus=1', + ); + } + } else { + if (RaspberryPiChecker::isSoftwareI2CActive()) { + $status->addWarning( + 'Software I2C appears active. Try a reboot first, ' . + 'next toggle the Use PI Software I2C setting below and reboot. ' . + 'If issues persist check /boot/firmware/config.txt and comment out ' . + '
dtoverlay=i2c-gpio,i2c_gpio_sda=2,i2c_gpio_scl=3,i2c_gpio_delay_us=4,bus=1', + ); + } + + if (!RaspberryPiChecker::isHardwareI2CActive()) { + $status->addWarning( + 'Hardware I2C has not been loaded. Try a reboot first, ' . + 'next toggle the Use PI Software I2C setting below and reboot. ' . + 'If issues persist then check /boot/firmware/config.txt and add dtparam=i2c_arm=on', + ); + } + } +} + +/** + * Determine I2C type (hardware or software) + */ +function determineI2CType(PlatformType $platform, array $pluginSettings): string { + if ($platform !== PlatformType::RASPBERRY_PI) { + return 'hardware'; + } + + $useSoftwareI2C = isset($pluginSettings['DynRDSAdvPISoftwareI2C']) && + $pluginSettings['DynRDSAdvPISoftwareI2C'] == 1; + + if ($useSoftwareI2C && RaspberryPiChecker::isSoftwareI2CActive()) { + return 'software'; + } + + return 'hardware'; +} + +/** + * Output JavaScript functions + */ +function outputJavaScript(TransmitterType $transmitterType): void { + ?> + indicates a live change to transmitter, no FPP restart required", + 1, "Dynamic_RDS", "DynRDSFastUpdate"); + + PrintSettingGroup("DynRDSPowerSettings", "", "", 1, "Dynamic_RDS", "DynRDSPiBootUpdate"); + + PrintSettingGroup("DynRDSPluginActivation", "", "Set when the transmitter is active", 1, "Dynamic_RDS"); + + if (DependencyChecker::isMPCInstalled()) { + PrintSettingGroup("DynRDSmpc", "", + "Pull RDS data from MPC / After Hours Music plugin when idle", + 1, "Dynamic_RDS", "DynRDSFastUpdate"); + } else { + echo '

MPC / After Hours Music

'; + echo '
Install the After Hours Music Player Plugin to enabled. MPC not detected

'; + } + + displayMQTTSection($settings); + + PrintSettingGroup("DynRDSLogLevel", "", "", 1, "Dynamic_RDS", "DynRDSFastUpdate"); + + displayLogsSection(); -

RDS Style Text Guide

Values from File Tags or Track Info @@ -183,63 +538,85 @@ function ScriptStreamProgressDialogDone() {
  • {P} = Item position or number in Main Playlist section
  • Any static text can be used
    | (pipe) will split between RDS groups, like a line break
    -[ ] creates a subgroup such that if ANY substitution in the subgroup is emtpy, the entire subgroup is omitted
    +[ ] creates a subgroup such that if ANY substitution in the subgroup is empty, the entire subgroup is omitted
    Use a \ in front of | { } [ or ] to display those characters
    End of the style text will implicitly function as a line break
    -", "", 1, "Dynamic_RDS"); - -PrintSettingGroup("DynRDSTransmitterSettings", "", "", 1, "Dynamic_RDS"); - -PrintSettingGroup("DynRDSAudioSettings", "", "indicates a live change to transmitter, no FPP restart required", 1, "Dynamic_RDS", "DynRDSFastUpdate"); - -PrintSettingGroup("DynRDSPowerSettings", "", "", 1, "Dynamic_RDS", "DynRDSPiBootUpdate"); - -PrintSettingGroup("DynRDSPluginActivation", "", "Set when the transmitter is active", 1, "Dynamic_RDS"); - -if (!(is_file('/bin/mpc') || is_file('/usr/bin/mpc'))) { - echo '

    MPC / After Hours Music

    Install the After Hours Music Player Plugin to enabled. MPC not detected

    '; -} else { - PrintSettingGroup("DynRDSmpc", "", "Pull RDS data from MPC / After Hours Music plugin when idle", 1, "Dynamic_RDS", "DynRDSFastUpdate"); +HTML; } -if ($settings['MQTTHost'] == '') { - echo '

    MQTT

    Requires that MQTT has been configured under FPP Settings -> MQTT

    '; -} elseif (!(file_exists('/usr/lib/python3/dist-packages/paho') || file_exists('/usr/local/lib/python3.9/dist-packages/paho'))) { - echo '

    MQTT

    python3-paho-mqtt is needed to enable MQTT support
    '; -} else { - PrintSettingGroup("DynRDSmqtt", "", "Broker Host is " . $settings['MQTTHost'] . ":" . $settings['MQTTPort'] . "", 1, "Dynamic_RDS", ""); +/** + * Display MQTT section + */ +function displayMQTTSection(array $settings): void { + if (empty($settings['MQTTHost'])) { + echo '

    MQTT

    '; + echo '
    Requires that MQTT has been configured under '; + echo 'FPP Settings -> MQTT

    '; + } elseif (!DependencyChecker::isPahoMQTTInstalled()) { + echo '

    MQTT

    '; + echo '
    python3-paho-mqtt is needed to enable MQTT support '; + echo '
    '; + } else { + $mqttHost = htmlspecialchars($settings['MQTTHost'], ENT_QUOTES, 'UTF-8'); + $mqttPort = htmlspecialchars($settings['MQTTPort'], ENT_QUOTES, 'UTF-8'); + PrintSettingGroup("DynRDSmqtt", "", + "Broker Host is {$mqttHost}:{$mqttPort}", + 1, "Dynamic_RDS", ""); + } } -PrintSettingGroup("DynRDSLogLevel", "", "", 1, "Dynamic_RDS", "DynRDSFastUpdate"); -?> - +/** + * Display logs section + */ +function displayLogsSection(): void { + ?>

    View Logs

    -

    Dynamic_RDS_callbacks.log -

    -

    Dynamic_RDS_Engine.log -

    +

    Dynamic_RDS_callbacks.log + +

    +

    Dynamic_RDS_Engine.log + +


    +

    Report an Issue

    - +

    Create a new issue at https://github.com/ShadowLight8/Dynamic_RDS/issues, describe what you're seeing, and attach the zip file.

    Zip file includes:

    + - From 47204d90c1930d0faf7abe14fdf3973a671087ab Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Tue, 4 Nov 2025 21:27:38 -0500 Subject: [PATCH 02/50] Start of Si4713 support work --- Si4713.py | 293 +++++++++++++++++++++++++++++++++++++++++++++++++ Transmitter.py | 4 + settings.json | 2 +- 3 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 Si4713.py diff --git a/Si4713.py b/Si4713.py new file mode 100644 index 0000000..33fc94d --- /dev/null +++ b/Si4713.py @@ -0,0 +1,293 @@ +import logging +import sys +import os +from time import sleep +from datetime import datetime + +from config import config +from basicI2C import basicI2C +from Transmitter import Transmitter + +class Si4713(Transmitter): + def __init__(self): + logging.info('Initializing Si4713 transmitter') + super().__init__() + self.I2C = basicI2C(0x63) # Si4713 default I2C address + self.PS = self.PSBuffer(self, ' ', int(config['DynRDSPSUpdateRate'])) + self.RT = self.RTBuffer(self, ' ', int(config['DynRDSRTUpdateRate'])) + + # Si4713 Commands + CMD_POWER_UP = 0x01 + CMD_GET_REV = 0x10 + CMD_POWER_DOWN = 0x11 + CMD_SET_PROPERTY = 0x12 + CMD_GET_PROPERTY = 0x13 + CMD_TX_TUNE_FREQ = 0x30 + CMD_TX_TUNE_POWER = 0x31 + CMD_TX_TUNE_MEASURE = 0x32 + CMD_TX_TUNE_STATUS = 0x33 + CMD_TX_ASQ_STATUS = 0x34 + CMD_TX_RDS_BUFF = 0x35 + CMD_TX_RDS_PS = 0x36 + CMD_GET_INT_STATUS = 0x14 + + # Si4713 Properties + PROP_TX_COMPONENT_ENABLE = 0x2100 + PROP_TX_AUDIO_DEVIATION = 0x2101 + PROP_TX_PILOT_DEVIATION = 0x2102 + PROP_TX_RDS_DEVIATION = 0x2103 + PROP_TX_PREEMPHASIS = 0x2106 + PROP_TX_RDS_PI = 0x2C01 + PROP_TX_RDS_PS_MIX = 0x2C02 + PROP_TX_RDS_PS_MISC = 0x2C03 + PROP_TX_RDS_PS_REPEAT_COUNT = 0x2C04 + PROP_REFCLK_FREQ = 0x0201 + + # Status bits + STATUS_CTS = 0x80 + + def _wait_for_cts(self, timeout=100): + """Wait for Clear To Send status""" + start_time = datetime.now() + while (datetime.now() - start_time).total_seconds() * 1000 < timeout: + status = self.I2C.read(0x00, 1)[0] + if status & self.STATUS_CTS: + return True + sleep(0.001) + return False + + def _send_command(self, cmd, args=None): + """Send a command to the Si4713""" + if args is None: + args = [] + + # Write command and arguments + self.I2C.write(cmd, args, False) + + # Wait for CTS + return self._wait_for_cts() + + def _set_property(self, prop, value): + """Set a property on the Si4713""" + args = [ + 0x00, # Reserved + (prop >> 8) & 0xFF, # Property high byte + prop & 0xFF, # Property low byte + (value >> 8) & 0xFF, # Value high byte + value & 0xFF # Value low byte + ] + return self._send_command(self.CMD_SET_PROPERTY, args) + + def startup(self): + logging.info('Starting Si4713 transmitter') + + # Power up in transmit mode + args = [ + 0x12, # CTS interrupt disabled, GPO2 output enabled, transmit mode + 0x50 # Analog input mode + ] + if not self._send_command(self.CMD_POWER_UP, args): + logging.error('Failed to power up Si4713') + sys.exit(-1) + + sleep(0.11) # Wait for power up + + # Verify chip by getting revision + self._send_command(self.CMD_GET_REV, []) + rev_data = self.I2C.read(0x00, 9) + if not (rev_data[0] & self.STATUS_CTS): + logging.error('Failed to read Si4713 revision. Is this a Si4713 chip?') + sys.exit(-1) + + logging.info('Si4713 Part Number: %02x, Firmware: %d.%d, Component: %d.%d', + rev_data[1], rev_data[2], rev_data[3], rev_data[6], rev_data[7]) + + # Set reference clock (32.768 kHz crystal) + self._set_property(self.PROP_REFCLK_FREQ, 32768) + + # Enable stereo, pilot, and RDS + self._set_property(self.PROP_TX_COMPONENT_ENABLE, 0x0007) + + # Set audio deviation (68.25 kHz) + self._set_property(self.PROP_TX_AUDIO_DEVIATION, 6825) + + # Set pilot deviation (6.75 kHz) + self._set_property(self.PROP_TX_PILOT_DEVIATION, 675) + + # Set RDS deviation (2 kHz) + self._set_property(self.PROP_TX_RDS_DEVIATION, 200) + + # Set pre-emphasis + if config['DynRDSPreemphasis'] == "50us": + self._set_property(self.PROP_TX_PREEMPHASIS, 1) # 50 us + else: + self._set_property(self.PROP_TX_PREEMPHASIS, 0) # 75 us + + # Configure RDS + self._set_property(self.PROP_TX_RDS_PS_MIX, 0x03) # Mix mode + self._set_property(self.PROP_TX_RDS_PS_MISC, 0x1808) # Standard settings + self._set_property(self.PROP_TX_RDS_PS_REPEAT_COUNT, 3) # Repeat 3 times + + # Set frequency from config + tempFreq = int(float(config['DynRDSFrequency']) * 100) # Convert to 10 kHz units + args = [ + 0x00, # Reserved + (tempFreq >> 8) & 0xFF, # Frequency high byte + tempFreq & 0xFF # Frequency low byte + ] + self._send_command(self.CMD_TX_TUNE_FREQ, args) + sleep(0.25) # Wait for tune + + # Set transmission power + power = 115 # Max power ~1W + if 'DynRDSSi4713ChipPower' in config: + power = int(config['DynRDSSi4713ChipPower']) + + args = [ + 0x00, # Reserved + power & 0xFF, + 0x00 # Antenna cap (0 = auto) + ] + self._send_command(self.CMD_TX_TUNE_POWER, args) + sleep(0.25) + + self.update() + super().startup() + + def update(self): + # Si4713 doesn't have AGC or soft clipping settings like QN8066 + # Most audio settings are configured via properties during startup + pass + + def shutdown(self): + logging.info('Stopping Si4713 transmitter') + # Power down the transmitter + self._send_command(self.CMD_POWER_DOWN, []) + super().shutdown() + + def reset(self, resetdelay=1): + # Used to restart the transmitter + self.shutdown() + del self.I2C + self.I2C = basicI2C(0x63) + sleep(resetdelay) + self.startup() + + def status(self): + # Get transmitter status + self._send_command(self.CMD_TX_TUNE_STATUS, [0x01]) # Clear interrupt + status_data = self.I2C.read(0x00, 8) + + if status_data[0] & self.STATUS_CTS: + freq = (status_data[2] << 8) | status_data[3] + power = status_data[5] + antenna_cap = status_data[6] + noise = status_data[7] + + logging.info('Status - Freq: %.1f MHz - Power: %d - Antenna Cap: %d - Noise: %d', + freq / 100.0, power, antenna_cap, noise) + + super().status() + + def updateRDSData(self, PSdata='', RTdata=''): + logging.debug('Si4713 updateRDSData') + super().updateRDSData(PSdata, RTdata) + self.PS.updateData(PSdata) + self.RT.updateData(RTdata) + + def sendNextRDSGroup(self): + # If more advanced mixing of RDS groups is needed, this is where it would occur + logging.excessive('Si4713 sendNextRDSGroup') + self.PS.sendNextGroup() + self.RT.sendNextGroup() + + def transmitRDS(self, rdsBytes): + """ + Transmit RDS group using Si4713's TX_RDS_BUFF command + rdsBytes: 8-byte array containing the RDS group + """ + logging.excessive('Transmit %s', ' '.join('0x{:02x}'.format(a) for a in rdsBytes)) + + # Si4713 uses CMD_TX_RDS_BUFF to load RDS data + # Command format: CMD, status, FIFO count, RDS data (8 bytes) + args = [0x00] # Clear interrupt + args.extend(rdsBytes) + + success = self._send_command(self.CMD_TX_RDS_BUFF, args) + + if not success: + logging.error('Failed to transmit RDS group') + # RDS has failed to update, reset the Si4713 + self.reset() + return + + # RDS specifications indicate 87.6ms to send a group + sleep(0.087) + + class PSBuffer(Transmitter.RDSBuffer): + # Sends RDS type 0B groups - Program Service + # Fragment size of 8, Groups send 2 characters at a time + def __init__(self, outer, data, delay=4): + super().__init__(data, 8, 2, delay) + # Include outer for the common transmitRDS function that both PSBuffer and RTBuffer use + self.outer = outer + + def updateData(self, data): + super().updateData(data) + # Adjust last fragment to make all 8 characters long + self.fragments[-1] = self.fragments[-1].ljust(self.frag_size) + logging.info('PS %s', self.fragments) + + def sendNextGroup(self): + if self.currentGroup == 0 and (datetime.now() - self.lastFragmentTime).total_seconds() >= self.delay: + self.currentFragment = (self.currentFragment + 1) % len(self.fragments) + self.lastFragmentTime = datetime.now() + logging.debug('Send PS Fragment \'%s\'', self.fragments[self.currentFragment]) + + rdsBytes = [self.pi_byte1, self.pi_byte2, 0b10<<2 | self.pty>>3, (0b00111 & self.pty)<<5 | self.currentGroup, self.pi_byte1, self.pi_byte2] + rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size])) + rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 1])) + + self.outer.transmitRDS(rdsBytes) + self.currentGroup = (self.currentGroup + 1) % (self.frag_size // self.group_size) + + class RTBuffer(Transmitter.RDSBuffer): + # Sends RDS type 2A groups - RadioText + # Max fragment size of 64, Groups send 4 characters at a time + def __init__(self, outer, data, delay=7): + self.ab = 0 + super().__init__(data, int(config['DynRDSRTSize']), 4, delay) + self.outer = outer + + def updateData(self, data): + super().updateData(data) + # Add 0x0d to end of last fragment to indicate RT is done + # TODO: This isn't quite correct - Should put 0x0d where a break is indicated in the rdsStyleText + if len(self.fragments[-1]) < self.frag_size: + self.fragments[-1] += chr(0x0d) + self.ab = not self.ab + logging.info('RT %s', self.fragments) + + def sendNextGroup(self): + # Will block for ~80-90ms for RDS Group to be sent + # Check time, if it has been long enough AND a full RT fragment has been sent, move to next fragment + # Flip A/B bit, send next group, if last group set full RT sent flag + # Need to make sure full RT group has been sent at least once before moving on + if self.currentGroup == 0 and (datetime.now() - self.lastFragmentTime).total_seconds() >= self.delay: + self.currentFragment = (self.currentFragment + 1) % len(self.fragments) + self.lastFragmentTime = datetime.now() + self.ab = not self.ab + # Change \r (0x0d) to be [0d] for logging so it is visible in case of debugging + logging.debug('Send RT Fragment \'%s\'', self.fragments[self.currentFragment].replace('\r','<0d>')) + + # TODO: Seems like this could be improved + rdsBytes = [self.pi_byte1, self.pi_byte2, 0b1000<<2 | self.pty>>3, (0b00111 & self.pty)<<5 | self.ab<<4 | self.currentGroup] + rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size])) + rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 1]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 2 else 0x20) + rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 2]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 3 else 0x20) + rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 3]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 4 else 0x20) + + self.outer.transmitRDS(rdsBytes) + self.currentGroup += 1 + if self.currentGroup * self.group_size >= len(self.fragments[self.currentFragment]): + self.currentGroup = 0 diff --git a/Transmitter.py b/Transmitter.py index 48c661c..ddc9783 100644 --- a/Transmitter.py +++ b/Transmitter.py @@ -18,6 +18,10 @@ # PSBuffer (RDSBuffer) # RTBuffer (RDSBuffer) +# Si4713 (Transmitter) +# PSBuffer (RDSBuffer) +# RTBuffer (RDSBuffer) + class Transmitter: def __init__(self): # Common class init diff --git a/settings.json b/settings.json index b668cc4..a8bed36 100644 --- a/settings.json +++ b/settings.json @@ -84,7 +84,7 @@ "options": { "SELECT TRANSMITTER": "None", "QN8066": "QN8066", - "Si4713 (planned for future release)": "zzSi4713" + "Si4713": "Si4713" }, "default": "None", "children": { From 757f55bf986db353aea49264eb9ad123500dc10b Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Fri, 7 Nov 2025 21:41:57 -0500 Subject: [PATCH 03/50] Switch to smbus2, handle different frequency ranges between chips, add log level to debug note, white space clean up --- Dynamic_RDS.php | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/Dynamic_RDS.php b/Dynamic_RDS.php index 2b4fb54..b0ad58e 100644 --- a/Dynamic_RDS.php +++ b/Dynamic_RDS.php @@ -150,7 +150,7 @@ public static function detectBus(PlatformType $platform): int { class DependencyChecker { public static function isPython3SmbusInstalled(): bool { - $output = ShellCommandExecutor::execute('dpkg -s python3-smbus | grep installed'); + $output = ShellCommandExecutor::execute('dpkg -s python3-smbus2 | grep installed'); return !ShellCommandExecutor::isEmpty($output); } @@ -455,6 +455,24 @@ function outputJavaScript(TransmitterType $transmitterType): void { } }; +function DynRDSTransmitterFrequencyUpdate() { + var iconHTML = " "; + var transmitterSelect = document.getElementById("DynRDSTransmitter"); + + if (transmitterSelect && transmitterSelect.value !== "None") { + var frequencyInput = document.getElementById('DynRDSFrequency'); + var descriptionDiv = document.querySelector('#DynRDSFrequencyRow .description'); + + if (frequencyInput && descriptionDiv && transmitterSelect.value === "QN8066") { + frequencyInput.min = '60'; + descriptionDiv.innerHTML = iconHTML + 'Frequency (60.00-108.00)'; + } else if (frequencyInput && descriptionDiv && transmitterSelect.value === "Si4713") { + frequencyInput.min = '76'; + descriptionDiv.innerHTML = iconHTML + 'Frequency (76.00-108.00)'; + } + } +} + function DynRDSFastUpdate() { $.get('api/plugin/Dynamic_RDS/FastUpdate'); } @@ -489,10 +507,10 @@ function ScriptStreamProgressDialogDone() { function displaySettingsGroups(array $settings): void { PrintSettingGroup("DynRDSRDSSettings", getRDSStyleGuideHTML(), "", 1, "Dynamic_RDS"); - PrintSettingGroup("DynRDSTransmitterSettings", "", "", 1, "Dynamic_RDS"); + PrintSettingGroup("DynRDSTransmitterSettings", "", "", 1, "Dynamic_RDS", "DynRDSTransmitterFrequencyUpdate"); - PrintSettingGroup("DynRDSAudioSettings", "", - "indicates a live change to transmitter, no FPP restart required", + PrintSettingGroup("DynRDSAudioSettings", "", + "indicates a live change to transmitter, no FPP restart required", 1, "Dynamic_RDS", "DynRDSFastUpdate"); PrintSettingGroup("DynRDSPowerSettings", "", "", 1, "Dynamic_RDS", "DynRDSPiBootUpdate"); @@ -500,8 +518,8 @@ function displaySettingsGroups(array $settings): void { PrintSettingGroup("DynRDSPluginActivation", "", "Set when the transmitter is active", 1, "Dynamic_RDS"); if (DependencyChecker::isMPCInstalled()) { - PrintSettingGroup("DynRDSmpc", "", - "Pull RDS data from MPC / After Hours Music plugin when idle", + PrintSettingGroup("DynRDSmpc", "", + "Pull RDS data from MPC / After Hours Music plugin when idle", 1, "Dynamic_RDS", "DynRDSFastUpdate"); } else { echo '

    MPC / After Hours Music

    '; @@ -600,7 +618,7 @@ function displayReportIssueSection(): void { Download log and config zip

    -

    Create a new issue at https://github.com/ShadowLight8/Dynamic_RDS/issues, describe what you're seeing, and attach the zip file.

    +

    Increase the Log Levels to Debug, then create a new issue at https://github.com/ShadowLight8/Dynamic_RDS/issues, describe what you're seeing, and attach the zip file.

    Zip file includes:
    • Logs - Dynamic_RDS_callbacks.log and Dynamic_RDS_Engine.log
    • From ea263639324574bed34d18d2ea3c688a9b2c9b6a Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Fri, 7 Nov 2025 21:44:40 -0500 Subject: [PATCH 04/50] Switch to smbus2 --- Dynamic_RDS_Engine.py | 3 ++- Si4713.py | 1 + basicI2C.py | 4 ++-- callbacks.py | 4 ++-- scripts/fpp_install.sh | 6 +++--- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Dynamic_RDS_Engine.py b/Dynamic_RDS_Engine.py index 6d76f9b..72b098c 100755 --- a/Dynamic_RDS_Engine.py +++ b/Dynamic_RDS_Engine.py @@ -16,6 +16,7 @@ from config import config, read_config_from_file from QN8066 import QN8066 +from Si4713 import Si4713 from basicMQTT import basicMQTT, pahoMQTT def logUnhandledException(eType, eValue, eTraceback): @@ -210,7 +211,7 @@ def excessive(msg, *args, **kwargs): if config['DynRDSTransmitter'] == "QN8066": transmitter = QN8066() elif config['DynRDSTransmitter'] == "Si4713": - transmitter = None # To be implemented later + transmitter = Si4713() if transmitter is None: logging.error('Transmitter not set. Check Transmitter Type.') diff --git a/Si4713.py b/Si4713.py index 33fc94d..ec4cd43 100644 --- a/Si4713.py +++ b/Si4713.py @@ -3,6 +3,7 @@ import os from time import sleep from datetime import datetime +from gpiozero import DigitalOutputDevice from config import config from basicI2C import basicI2C diff --git a/basicI2C.py b/basicI2C.py index 14d08c2..00bdeff 100644 --- a/basicI2C.py +++ b/basicI2C.py @@ -2,7 +2,7 @@ import os import sys from time import sleep -import smbus +import smbus2 # =============== # Basic I2C Class @@ -23,7 +23,7 @@ def __init__(self, address, bus=1): self.bus = smbus.SMBus(bus) except Exception: logging.exception("SMBus Init Error") - sleep(2) # TODO: Is this sleep still needed for the bus to init? + #sleep(2) # TODO: Is this sleep still needed for the bus to init? def write(self, address, values, isFatal = False): # Simple i2c write - Always takes an list, even for 1 byte diff --git a/callbacks.py b/callbacks.py index d3fd06f..4a3248d 100755 --- a/callbacks.py +++ b/callbacks.py @@ -41,9 +41,9 @@ def logUnhandledException(eType, eValue, eTraceback): # If smbus is missing, don't try to start up the Engine as it will fail try: - import smbus + import smbus2 except ImportError as impErr: - logging.error("Failed to import smbus %s", impErr.args[0]) + logging.error("Failed to import smbus2 %s", impErr.args[0]) sys.exit(1) # RPi.GPIO is used for software PWM on the RPi, fail if it is missing diff --git a/scripts/fpp_install.sh b/scripts/fpp_install.sh index 3fe2c5b..96872af 100755 --- a/scripts/fpp_install.sh +++ b/scripts/fpp_install.sh @@ -3,10 +3,10 @@ echo "Copying, if missing, optional config script to FPP scripts directory..." cp -v -n ~/media/plugins/Dynamic_RDS/scripts/src_Dynamic_RDS_config.sh ~/media/scripts/Dynamic_RDS_config.sh -echo -e "\nInstalling python3-smbus..." -sudo apt-get install -y python3-smbus +#echo -e "\nInstalling python3-smbus..." +#sudo apt-get install -y python3-smbus -if test -f /boot/config.txt; then +if test -f /boot/firmware/config.txt; then echo -e "\nInstalling python3-rpi-lgpio..." sudo apt-get install -y python3-rpi-lgpio fi From 56fb2bc73a8da40b13be649b812221a296c4c14a Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Fri, 7 Nov 2025 21:44:58 -0500 Subject: [PATCH 05/50] Adding new settings for Si4713 --- settings.json | 67 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/settings.json b/settings.json index a8bed36..3e23942 100644 --- a/settings.json +++ b/settings.json @@ -14,11 +14,13 @@ ] }, "DynRDSTransmitterSettings": { - "description": "Transmitter Type and Common Settings", + "description": "Transmitter Type and Settings", "settings": [ "DynRDSTransmitter", "DynRDSFrequency", - "DynRDSPreemphasis" + "DynRDSPreemphasis", + "DynRDSSi4713TuningCap", + "DynRDSSi4713GPIOReset" ] }, "DynRDSAudioSettings": { @@ -35,7 +37,8 @@ "settings": [ "DynRDSQN8066ChipPower", "DynRDSQN8066PIPWM", - "DynRDSQN8066AmpPower" + "DynRDSQN8066AmpPower", + "DynRDSSi4713ChipPower" ] }, "DynRDSPluginActivation": { @@ -97,7 +100,10 @@ "DynRDSQN8066AmpPower" ], "Si4713": [ - "DynRDSSi4713TestAudio" + "DynRDSSi4713TestAudio", + "DynRDSSi4713GPIOReset", + "DynRDSSi4713TuningCap", + "DynRDSSi4713ChipPower" ] } }, @@ -175,6 +181,19 @@ "step": 1, "default": 0 }, + "DynRDSSi4713ChipPower": { + "name": "DynRDSSi4713ChipPower", + "description": "Chip Power (88-120)", + "tip": "Adjust the power output from the transmitter chip. Voltage accuracy above 115dBμV is not guaranteed.", + "suffix": "dBμV", + "restart": 0, + "reboot": 0, + "type": "number", + "min": 88, + "max": 120, + "step": 1, + "default": 115 + }, "DynRDSFrequency": { "name": "DynRDSFrequency", "description": "Frequency (60.00-108.00)", @@ -201,6 +220,46 @@ }, "default": "75us" }, + "DynRDSSi4713TuningCap": { + "name": "DynRDSSi4713TuningCap", + "description": "Antenna Tuning Capacitor", + "tip": "Setting to 0, the Si4713 will attempt to auto set the capacitor. The range is 1-191.", + "restart": 1, + "reboot": 0, + "type": "number", + "min": 0, + "max": 191, + "step": 1, + "suffix": "* 0.25pF", + "default": 0 + }, + "DynRDSSi4713GPIOReset": { + "name": "DynRDSSi4713GPIOReset", + "description": "Reset Pin / GPIO", + "tip": "The Si4713 requires the reset pin to be high to enter normal operations. This can also be used to reset the transmitter.", + "restart": 1, + "reboot": 0, + "type": "select", + "options": { + "Pin 7 / GPIO 4 (Default)": "4", + "Pin 8 / GPIO 14": "14", + "Pin 10 / GPIO 15": "15", + "Pin 11 / GPIO 17": "17", + "Pin 12 / GPIO 18": "18", + "Pin 13 / GPIO 27": "27", + "Pin 15 / GPIO 22": "22", + "Pin 16 / GPIO 23": "23", + "Pin 18 / GPIO 24": "24", + "Pin 22 / GPIO 25": "22", + "Pin 27 / GPIO 0": "27", + "Pin 28 / GPIO 1": "28", + "Pin 29 / GPIO 5": "29", + "Pin 31 / GPIO 6": "31", + "Pin 36 / GPIO 16": "36", + "Pin 37 / GPIO 26": "37" + }, + "default": "4" + }, "DynRDSQN8066Gain": { "name": "DynRDSQN8066Gain", "description": "Gain Adjustment (-15 to +20)", From a2372bf3ec8ffced3ae308f1499bf0316f7e7f08 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Thu, 13 Nov 2025 23:23:20 -0500 Subject: [PATCH 06/50] Work in power up of Si4713, missed an smbus to smbus2 change, missing comma in config.py --- Si4713.py | 85 ++++++++++++++++++++++++++--------------------------- basicI2C.py | 2 +- config.py | 9 +++++- 3 files changed, 51 insertions(+), 45 deletions(-) diff --git a/Si4713.py b/Si4713.py index ec4cd43..e1ef65d 100644 --- a/Si4713.py +++ b/Si4713.py @@ -48,24 +48,16 @@ def __init__(self): STATUS_CTS = 0x80 def _wait_for_cts(self, timeout=100): - """Wait for Clear To Send status""" - start_time = datetime.now() - while (datetime.now() - start_time).total_seconds() * 1000 < timeout: - status = self.I2C.read(0x00, 1)[0] - if status & self.STATUS_CTS: + iterations = timeout # Each iteration is ~1ms + for _ in range(iterations): + if self.I2C.read(0x00, 1)[0] & self.STATUS_CTS: return True sleep(0.001) return False - def _send_command(self, cmd, args=None): - """Send a command to the Si4713""" - if args is None: - args = [] - - # Write command and arguments - self.I2C.write(cmd, args, False) - - # Wait for CTS + def _send_command(self, cmd, args = None, isFatal = False): + args = args or [] + self.I2C.write(cmd, args, isFatal) return self._wait_for_cts() def _set_property(self, prop, value): @@ -82,41 +74,46 @@ def _set_property(self, prop, value): def startup(self): logging.info('Starting Si4713 transmitter') - # Power up in transmit mode - args = [ - 0x12, # CTS interrupt disabled, GPO2 output enabled, transmit mode - 0x50 # Analog input mode - ] - if not self._send_command(self.CMD_POWER_UP, args): - logging.error('Failed to power up Si4713') + logging.debug('Executing Reset with Pin %s', config['DynRDSSi4713GPIOReset']) + with DigitalOutputDevice(int(config['DynRDSSi4713GPIOReset'])) as resetPin: + resetPin.on() + sleep(0.01) + resetPin.off() + sleep(0.01) + resetPin.on() + sleep(0.11) + + # Power up in transmit mode (Crystal oscillator and Analog audio input) + self.I2C.write(self.CMD_POWER_UP, [0b00010010, 0b01010000], True) + sleep(0.11) # Wait for power up + if not self._wait_for_cts(): + logging.error('Si4713 failed to be read after power up') sys.exit(-1) - sleep(0.11) # Wait for power up - # Verify chip by getting revision - self._send_command(self.CMD_GET_REV, []) - rev_data = self.I2C.read(0x00, 9) - if not (rev_data[0] & self.STATUS_CTS): - logging.error('Failed to read Si4713 revision. Is this a Si4713 chip?') + self._send_command(self.CMD_GET_REV, [], True) + rev_data = self.I2C.read(0x00, 9, True) + logging.info(f'Si4713 Part Number: 47{rev_data[1]:02d}, Firmware: {rev_data[2]}.{rev_data[3]}, ' + f'Patch ID: {rev_data[4]}.{rev_data[5]}, Component: {rev_data[6]}.{rev_data[7]}, ' + f'Chip Revision: {rev_data[8]}') + if rev_data[1] != 13: + logging.error('Part Number value is %02d instead of 13. Is this a Si4713 chip?', rev_data[1]) sys.exit(-1) - logging.info('Si4713 Part Number: %02x, Firmware: %d.%d, Component: %d.%d', - rev_data[1], rev_data[2], rev_data[3], rev_data[6], rev_data[7]) - # Set reference clock (32.768 kHz crystal) - self._set_property(self.PROP_REFCLK_FREQ, 32768) + #self._set_property(self.PROP_REFCLK_FREQ, 32768) # Enable stereo, pilot, and RDS self._set_property(self.PROP_TX_COMPONENT_ENABLE, 0x0007) # Set audio deviation (68.25 kHz) - self._set_property(self.PROP_TX_AUDIO_DEVIATION, 6825) + #self._set_property(self.PROP_TX_AUDIO_DEVIATION, 6825) # Set pilot deviation (6.75 kHz) - self._set_property(self.PROP_TX_PILOT_DEVIATION, 675) + #self._set_property(self.PROP_TX_PILOT_DEVIATION, 675) # Set RDS deviation (2 kHz) - self._set_property(self.PROP_TX_RDS_DEVIATION, 200) + #self._set_property(self.PROP_TX_RDS_DEVIATION, 200) # Set pre-emphasis if config['DynRDSPreemphasis'] == "50us": @@ -125,9 +122,9 @@ def startup(self): self._set_property(self.PROP_TX_PREEMPHASIS, 0) # 75 us # Configure RDS - self._set_property(self.PROP_TX_RDS_PS_MIX, 0x03) # Mix mode - self._set_property(self.PROP_TX_RDS_PS_MISC, 0x1808) # Standard settings - self._set_property(self.PROP_TX_RDS_PS_REPEAT_COUNT, 3) # Repeat 3 times + #self._set_property(self.PROP_TX_RDS_PS_MIX, 0x03) # Mix mode + #self._set_property(self.PROP_TX_RDS_PS_MISC, 0x1808) # Standard settings + #self._set_property(self.PROP_TX_RDS_PS_REPEAT_COUNT, 3) # Repeat 3 times # Set frequency from config tempFreq = int(float(config['DynRDSFrequency']) * 100) # Convert to 10 kHz units @@ -137,20 +134,22 @@ def startup(self): tempFreq & 0xFF # Frequency low byte ] self._send_command(self.CMD_TX_TUNE_FREQ, args) - sleep(0.25) # Wait for tune + sleep(0.1) # Wait for tune # Set transmission power - power = 115 # Max power ~1W - if 'DynRDSSi4713ChipPower' in config: - power = int(config['DynRDSSi4713ChipPower']) + power = int(config['DynRDSSi4713ChipPower']) + antcap = int(config['DynRDSSi4713TuningCap']) args = [ + 0x00, # Reserved 0x00, # Reserved power & 0xFF, - 0x00 # Antenna cap (0 = auto) + antcap & 0xFF # Antenna cap (0 = auto) ] self._send_command(self.CMD_TX_TUNE_POWER, args) - sleep(0.25) + sleep(0.02) + + sys.exit(0) self.update() super().startup() diff --git a/basicI2C.py b/basicI2C.py index 00bdeff..efa01ef 100644 --- a/basicI2C.py +++ b/basicI2C.py @@ -20,7 +20,7 @@ def __init__(self, address, bus=1): bus = 0 logging.info('Using i2c bus %s', bus) try: - self.bus = smbus.SMBus(bus) + self.bus = smbus2.SMBus(bus) except Exception: logging.exception("SMBus Init Error") #sleep(2) # TODO: Is this sleep still needed for the bus to init? diff --git a/config.py b/config.py index 89684e7..c44bde0 100644 --- a/config.py +++ b/config.py @@ -13,12 +13,14 @@ 'DynRDSTransmitter': 'None', 'DynRDSFrequency': '100.1', 'DynRDSPreemphasis': '75us', + 'DynRDSQN8066Gain': '0', 'DynRDSQN8066SoftClipping': '0', 'DynRDSQN8066AGC': '0', 'DynRDSQN8066ChipPower': '122', 'DynRDSQN8066PIPWM': 0, 'DynRDSQN8066AmpPower': '0', + 'DynRDSStart': 'FPPDStart', 'DynRDSStop': 'Never', 'DynRDSCallbackLogLevel': 'INFO', @@ -27,7 +29,12 @@ 'DynRDSAdvPISoftwareI2C': '0', 'DynRDSAdvPIPWMPin': '18,2', 'DynRDSAdvBBBPWMPin': 'P9_16,1,B', -'DynRDSmqttEnable': '0' +'DynRDSmqttEnable': '0', + +'DynRDSSi4713GPIOReset': '4', +'DynRDSSi4713TuningCap': '0', +'DynRDSSi4713ChipPower': '115', +'DynRDSSi4713TestAudio': '' } def read_config_from_file(): From 024f7a971bf6ca0ec021e05c17e082f8ba61bd9a Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Fri, 14 Nov 2025 20:55:16 -0500 Subject: [PATCH 07/50] Basic PS working --- Si4713.py | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/Si4713.py b/Si4713.py index e1ef65d..adbae74 100644 --- a/Si4713.py +++ b/Si4713.py @@ -92,14 +92,19 @@ def startup(self): # Verify chip by getting revision self._send_command(self.CMD_GET_REV, [], True) - rev_data = self.I2C.read(0x00, 9, True) - logging.info(f'Si4713 Part Number: 47{rev_data[1]:02d}, Firmware: {rev_data[2]}.{rev_data[3]}, ' - f'Patch ID: {rev_data[4]}.{rev_data[5]}, Component: {rev_data[6]}.{rev_data[7]}, ' - f'Chip Revision: {rev_data[8]}') - if rev_data[1] != 13: - logging.error('Part Number value is %02d instead of 13. Is this a Si4713 chip?', rev_data[1]) + revData = self.I2C.read(0x00, 9, True) + logging.info(f'Si4713 Part Number: 47{revData[1]:02d}, Firmware: {revData[2]}.{revData[3]}, ' + f'Patch ID: {revData[4]}.{revData[5]}, Component: {revData[6]}.{revData[7]}, ' + f'Chip Revision: {revData[8]}') + if revData[1] != 13: + logging.error('Part Number value is %02d instead of 13. Is this a Si4713 chip?', revData[1]) sys.exit(-1) + # TODO: Make a function to use in status? + self._send_command(self.CMD_TX_RDS_BUFF, [0, 0, 0, 0, 0, 0, 0], True) + rdsBuffData = self.I2C.read(0x00, 6, True) + logging.debug(f'Circular Buffer: {rdsBuffData[3]}/{rdsBuffData[2]}, Fifo Buffer: {rdsBuffData[5]}/{rdsBuffData[4]}') + # Set reference clock (32.768 kHz crystal) #self._set_property(self.PROP_REFCLK_FREQ, 32768) @@ -149,7 +154,12 @@ def startup(self): self._send_command(self.CMD_TX_TUNE_POWER, args) sleep(0.02) - sys.exit(0) + # Set TX_RDS_PS_MISC + # TODO: Decide on bit 11 - 0=FIFO and BUFFER use PTY and TP as when written, 1=Force to be this setting + self._set_property(self.PROP_TX_RDS_PS_MISC, 0b0001100000001000 | int(config['DynRDSPty'])<<5) + + # Set TX_RDS_PI + self._set_property(self.PROP_TX_RDS_PI, int(config['DynRDSPICode'], 16)) self.update() super().startup() @@ -226,9 +236,9 @@ def transmitRDS(self, rdsBytes): class PSBuffer(Transmitter.RDSBuffer): # Sends RDS type 0B groups - Program Service - # Fragment size of 8, Groups send 2 characters at a time + # Fragment size of 8, Groups send 4 characters at a time def __init__(self, outer, data, delay=4): - super().__init__(data, 8, 2, delay) + super().__init__(data, 8, 4, delay) # Include outer for the common transmitRDS function that both PSBuffer and RTBuffer use self.outer = outer @@ -244,12 +254,16 @@ def sendNextGroup(self): self.lastFragmentTime = datetime.now() logging.debug('Send PS Fragment \'%s\'', self.fragments[self.currentFragment]) - rdsBytes = [self.pi_byte1, self.pi_byte2, 0b10<<2 | self.pty>>3, (0b00111 & self.pty)<<5 | self.currentGroup, self.pi_byte1, self.pi_byte2] + rdsBytes = [self.currentGroup] rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size])) rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 1])) + rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 2])) + rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 3])) - self.outer.transmitRDS(rdsBytes) + self.outer._send_command(self.outer.CMD_TX_RDS_PS, rdsBytes) + #self.outer.transmitRDS(rdsBytes) self.currentGroup = (self.currentGroup + 1) % (self.frag_size // self.group_size) + #sleep(0.25) class RTBuffer(Transmitter.RDSBuffer): # Sends RDS type 2A groups - RadioText @@ -281,13 +295,13 @@ def sendNextGroup(self): logging.debug('Send RT Fragment \'%s\'', self.fragments[self.currentFragment].replace('\r','<0d>')) # TODO: Seems like this could be improved - rdsBytes = [self.pi_byte1, self.pi_byte2, 0b1000<<2 | self.pty>>3, (0b00111 & self.pty)<<5 | self.ab<<4 | self.currentGroup] + rdsBytes = [0b1000<<2 | self.pty>>3, (0b00111 & self.pty)<<5 | self.ab<<4 | self.currentGroup] rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size])) rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 1]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 2 else 0x20) rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 2]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 3 else 0x20) rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 3]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 4 else 0x20) - self.outer.transmitRDS(rdsBytes) + #self.outer.transmitRDS(rdsBytes) self.currentGroup += 1 if self.currentGroup * self.group_size >= len(self.fragments[self.currentFragment]): self.currentGroup = 0 From ebd9fcbbe14352fa17ff8904d342e20e17456209 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sat, 15 Nov 2025 12:16:47 -0500 Subject: [PATCH 08/50] Use Si4713 internal PS buffers instead of always having to push in I2C --- Dynamic_RDS_Engine.py | 6 +++--- Si4713.py | 35 ++++++++++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Dynamic_RDS_Engine.py b/Dynamic_RDS_Engine.py index 72b098c..26807a5 100755 --- a/Dynamic_RDS_Engine.py +++ b/Dynamic_RDS_Engine.py @@ -69,10 +69,10 @@ def read_config(): def updateRDSData(): # Take the data from FPP and the configuration to build the actual RDS string - logging.info('New RDS Data') logging.debug('RDS Values %s', rdsValues) # TODO: DynRDSRTSize functionally works, but I think this should source from the RTBuffer class post initialization + # TODO: Check if transmitter is active? transmitter.updateRDSData(rdsStyleToString(config['DynRDSPSStyle'], 8), rdsStyleToString(config['DynRDSRTStyle'], int(config['DynRDSRTSize']))) if config['DynRDSmqttEnable'] == '1': @@ -91,7 +91,7 @@ def rdsStyleToString(rdsStyle, groupSize): try: for i, v in enumerate(rdsStyle): - logging.debug("i {} - v {} - squStart {} - skip {} - outputRDS {}".format(i,v,squStart,skip,outputRDS)) + #logging.excessive("rdsSytle i %s - v %s - squStart %s - skip %s - outputRDS %s", i, v, squStart, skip, outputRDS) if skip: skip -= 1 elif v == '\\' and i < len(rdsStyle) - 1: @@ -131,7 +131,7 @@ def rdsStyleToString(rdsStyle, groupSize): # Setup logging script_dir = os.path.dirname(os.path.abspath(sys.argv[0])) -#logging.basicConfig(stream=sys.stderr, level=logging.DEBUG, format='%(asctime)s:%(name)s:%(levelname)s:%(message)s') +logging.basicConfig(stream=sys.stderr, level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s', datefmt='%H:%M:%S') logging.basicConfig(filename=script_dir + '/Dynamic_RDS_Engine.log', level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s', datefmt='%H:%M:%S') # Adding in excessive log level below debug for very noisy items diff --git a/Si4713.py b/Si4713.py index adbae74..1b778dc 100644 --- a/Si4713.py +++ b/Si4713.py @@ -42,6 +42,7 @@ def __init__(self): PROP_TX_RDS_PS_MIX = 0x2C02 PROP_TX_RDS_PS_MISC = 0x2C03 PROP_TX_RDS_PS_REPEAT_COUNT = 0x2C04 + PROP_TX_RDS_PS_MESSAGE_COUNT = 0x2C05 PROP_REFCLK_FREQ = 0x0201 # Status bits @@ -127,7 +128,7 @@ def startup(self): self._set_property(self.PROP_TX_PREEMPHASIS, 0) # 75 us # Configure RDS - #self._set_property(self.PROP_TX_RDS_PS_MIX, 0x03) # Mix mode + #self._set_property(self.PROP_TX_RDS_PS_MIX, 0x06) # Mix mode #self._set_property(self.PROP_TX_RDS_PS_MISC, 0x1808) # Standard settings #self._set_property(self.PROP_TX_RDS_PS_REPEAT_COUNT, 3) # Repeat 3 times @@ -163,6 +164,7 @@ def startup(self): self.update() super().startup() + self.updateRDSData(self.PStext, self.RTtext) def update(self): # Si4713 doesn't have AGC or soft clipping settings like QN8066 @@ -202,14 +204,16 @@ def status(self): def updateRDSData(self, PSdata='', RTdata=''): logging.debug('Si4713 updateRDSData') super().updateRDSData(PSdata, RTdata) - self.PS.updateData(PSdata) - self.RT.updateData(RTdata) + if self.active: + logging.debug('Si4713 updateRDSData active') + self.PS.updateData(PSdata) + self.RT.updateData(RTdata) def sendNextRDSGroup(self): # If more advanced mixing of RDS groups is needed, this is where it would occur logging.excessive('Si4713 sendNextRDSGroup') self.PS.sendNextGroup() - self.RT.sendNextGroup() + #self.RT.sendNextGroup() def transmitRDS(self, rdsBytes): """ @@ -238,17 +242,38 @@ class PSBuffer(Transmitter.RDSBuffer): # Sends RDS type 0B groups - Program Service # Fragment size of 8, Groups send 4 characters at a time def __init__(self, outer, data, delay=4): + self.outer = outer super().__init__(data, 8, 4, delay) # Include outer for the common transmitRDS function that both PSBuffer and RTBuffer use - self.outer = outer def updateData(self, data): super().updateData(data) + + if len(self.fragments) > 12: + logging.error('Too many PS fragments: %d (max 12)', len(self.fragments)) + return + # Adjust last fragment to make all 8 characters long self.fragments[-1] = self.fragments[-1].ljust(self.frag_size) logging.info('PS %s', self.fragments) + self.outer._set_property(self.outer.PROP_TX_RDS_PS_MESSAGE_COUNT, 3) + + group = 0 + for fragment in self.fragments: + for chunk in range(self.frag_size // self.group_size): + start = chunk * self.group_size + rdsBytes = [group] + rdsBytes.append(ord(fragment[start])) + rdsBytes.append(ord(fragment[start + 1])) + rdsBytes.append(ord(fragment[start + 2])) + rdsBytes.append(ord(fragment[start + 3])) + self.outer._send_command(self.outer.CMD_TX_RDS_PS, rdsBytes) + group += 1 + def sendNextGroup(self): + sleep(0.25) + return if self.currentGroup == 0 and (datetime.now() - self.lastFragmentTime).total_seconds() >= self.delay: self.currentFragment = (self.currentFragment + 1) % len(self.fragments) self.lastFragmentTime = datetime.now() From 1bb33ebfd096619f208ddb1cc6a7e0d9853933ff Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sat, 15 Nov 2025 13:53:57 -0500 Subject: [PATCH 09/50] Basic sending of RT --- Si4713.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Si4713.py b/Si4713.py index 1b778dc..e853e1b 100644 --- a/Si4713.py +++ b/Si4713.py @@ -213,7 +213,7 @@ def sendNextRDSGroup(self): # If more advanced mixing of RDS groups is needed, this is where it would occur logging.excessive('Si4713 sendNextRDSGroup') self.PS.sendNextGroup() - #self.RT.sendNextGroup() + self.RT.sendNextGroup() def transmitRDS(self, rdsBytes): """ @@ -224,10 +224,13 @@ def transmitRDS(self, rdsBytes): # Si4713 uses CMD_TX_RDS_BUFF to load RDS data # Command format: CMD, status, FIFO count, RDS data (8 bytes) - args = [0x00] # Clear interrupt + args = [0b00000100] # Clear interrupt args.extend(rdsBytes) success = self._send_command(self.CMD_TX_RDS_BUFF, args) + self._send_command(self.CMD_TX_RDS_BUFF, [0, 0, 0, 0, 0, 0, 0], True) + rdsBuffData = self.I2C.read(0x00, 6, True) + logging.debug(f'Circular Buffer: {rdsBuffData[3]}/{rdsBuffData[2]+rdsBuffData[3]}, Fifo Buffer: {rdsBuffData[5]}/{rdsBuffData[4]+rdsBuffData[5]}') if not success: logging.error('Failed to transmit RDS group') @@ -326,7 +329,7 @@ def sendNextGroup(self): rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 2]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 3 else 0x20) rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 3]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 4 else 0x20) - #self.outer.transmitRDS(rdsBytes) + self.outer.transmitRDS(rdsBytes) self.currentGroup += 1 if self.currentGroup * self.group_size >= len(self.fragments[self.currentFragment]): self.currentGroup = 0 From 7e87c570ab85b2d464691990746e59606c62e575 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sat, 15 Nov 2025 17:36:24 -0500 Subject: [PATCH 10/50] Doing some logging tweaks and cleanup --- Si4713.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/Si4713.py b/Si4713.py index e853e1b..cabeed3 100644 --- a/Si4713.py +++ b/Si4713.py @@ -94,9 +94,11 @@ def startup(self): # Verify chip by getting revision self._send_command(self.CMD_GET_REV, [], True) revData = self.I2C.read(0x00, 9, True) - logging.info(f'Si4713 Part Number: 47{revData[1]:02d}, Firmware: {revData[2]}.{revData[3]}, ' - f'Patch ID: {revData[4]}.{revData[5]}, Component: {revData[6]}.{revData[7]}, ' - f'Chip Revision: {revData[8]}') + #logging.info(f'Si4713 Part Number: 47{revData[1]:02d}, Firmware: {revData[2]}.{revData[3]}, ' + # f'Patch ID: {revData[4]}.{revData[5]}, Component: {revData[6]}.{revData[7]}, ' + # f'Chip Revision: {revData[8]}') + logging.info('Si47%02x - FW %c.%c - Chip Rev %c', + revData[1], revData[2], revData[3], revData[8]) if revData[1] != 13: logging.error('Part Number value is %02d instead of 13. Is this a Si4713 chip?', revData[1]) sys.exit(-1) @@ -104,7 +106,9 @@ def startup(self): # TODO: Make a function to use in status? self._send_command(self.CMD_TX_RDS_BUFF, [0, 0, 0, 0, 0, 0, 0], True) rdsBuffData = self.I2C.read(0x00, 6, True) - logging.debug(f'Circular Buffer: {rdsBuffData[3]}/{rdsBuffData[2]}, Fifo Buffer: {rdsBuffData[5]}/{rdsBuffData[4]}') + logging.debug('Circular Buffer: %d/%d, Fifo Buffer: %d/%d', + rdsBuffData[3], rdsBuffData[2] + rdsBuffData[3], + rdsBuffData[5], rdsBuffData[4] + rdsBuffData[5]) # Set reference clock (32.768 kHz crystal) #self._set_property(self.PROP_REFCLK_FREQ, 32768) @@ -196,7 +200,7 @@ def status(self): antenna_cap = status_data[6] noise = status_data[7] - logging.info('Status - Freq: %.1f MHz - Power: %d - Antenna Cap: %d - Noise: %d', + logging.info('Status - Freq: %.1f MHz - Power: %d - Antenna Cap: %d - Noise: %d', freq / 100.0, power, antenna_cap, noise) super().status() @@ -220,7 +224,7 @@ def transmitRDS(self, rdsBytes): Transmit RDS group using Si4713's TX_RDS_BUFF command rdsBytes: 8-byte array containing the RDS group """ - logging.excessive('Transmit %s', ' '.join('0x{:02x}'.format(a) for a in rdsBytes)) + logging.excessive("Transmit %s", ' '.join(f"0x{b:02X}" for b in rdsBytes)) # Si4713 uses CMD_TX_RDS_BUFF to load RDS data # Command format: CMD, status, FIFO count, RDS data (8 bytes) @@ -230,7 +234,9 @@ def transmitRDS(self, rdsBytes): success = self._send_command(self.CMD_TX_RDS_BUFF, args) self._send_command(self.CMD_TX_RDS_BUFF, [0, 0, 0, 0, 0, 0, 0], True) rdsBuffData = self.I2C.read(0x00, 6, True) - logging.debug(f'Circular Buffer: {rdsBuffData[3]}/{rdsBuffData[2]+rdsBuffData[3]}, Fifo Buffer: {rdsBuffData[5]}/{rdsBuffData[4]+rdsBuffData[5]}') + logging.debug('Circular Buffer: %d/%d, Fifo Buffer: %d/%d', + rdsBuffData[3], rdsBuffData[2] + rdsBuffData[3], + rdsBuffData[5], rdsBuffData[4] + rdsBuffData[5]) if not success: logging.error('Failed to transmit RDS group') @@ -260,7 +266,7 @@ def updateData(self, data): self.fragments[-1] = self.fragments[-1].ljust(self.frag_size) logging.info('PS %s', self.fragments) - self.outer._set_property(self.outer.PROP_TX_RDS_PS_MESSAGE_COUNT, 3) + self.outer._set_property(self.outer.PROP_TX_RDS_PS_MESSAGE_COUNT, 3) group = 0 for fragment in self.fragments: From 5af6d40ad9a216cd38e7e00a7441cc89252c7cc9 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sat, 15 Nov 2025 18:05:53 -0500 Subject: [PATCH 11/50] Clean up of basicI2C and callbacks --- basicI2C.py | 14 +++++++------- callbacks.py | 41 ++++++++++++++++++++++++----------------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/basicI2C.py b/basicI2C.py index efa01ef..dd63635 100644 --- a/basicI2C.py +++ b/basicI2C.py @@ -22,24 +22,24 @@ def __init__(self, address, bus=1): try: self.bus = smbus2.SMBus(bus) except Exception: - logging.exception("SMBus Init Error") + logging.exception('SMBus Init Error') #sleep(2) # TODO: Is this sleep still needed for the bus to init? def write(self, address, values, isFatal = False): # Simple i2c write - Always takes an list, even for 1 byte - logging.excessive('I2C write at 0x%02x of %s', address, ' '.join('0x{:02x}'.format(a) for a in values)) + logging.excessive('I2C write at 0x%02x of %s', address, ' '.join(f'0x{b:02X}' for b in values)) for i in range(8): try: self.bus.write_i2c_block_data(self.address, address, values) except Exception: - logging.exception("write_i2c_block_data error") + logging.exception('write_i2c_block_data error') if i >= 1: sleep(i * .25) continue else: break else: - logging.error("failed to write after multiple attempts") + logging.error('failed to write after multiple attempts') if isFatal: sys.exit(-1) @@ -48,17 +48,17 @@ def read(self, address, num_bytes, isFatal = False): for i in range(8): try: retVal = self.bus.read_i2c_block_data(self.address, address, num_bytes) - logging.excessive('I2C read at 0x%02x of %s byte(s) returned %s', address, num_bytes, ' '.join('0x{:02x}'.format(a) for a in retVal)) + logging.excessive('I2C read at 0x%02x of %s byte(s) returned %s', address, num_bytes, ' '.join(f'0x{b:02X}' for b in retVal)) return retVal except Exception: - logging.exception("read_i2c_block_data error") + logging.exception('read_i2c_block_data error') if i >= 1: sleep(i * .25) continue else: break else: - logging.error("failed to read after multiple attempts") + logging.error('failed to read after multiple attempts') if isFatal: sys.exit(-1) return [] diff --git a/callbacks.py b/callbacks.py index 4a3248d..c0221de 100755 --- a/callbacks.py +++ b/callbacks.py @@ -13,7 +13,7 @@ from config import config,read_config_from_file def logUnhandledException(eType, eValue, eTraceback): - logging.error("Unhandled exception", exc_info=(eType, eValue, eTraceback)) + logging.error('Unhandled exception', exc_info=(eType, eValue, eTraceback)) sys.excepthook = logUnhandledException if len(argv) <= 1: @@ -39,20 +39,21 @@ def logUnhandledException(eType, eValue, eTraceback): logging.info('---') logging.debug('Arguments %s', argv[1:]) +# TODO: Should be able to remove these import check since they are part of FPP 9 image # If smbus is missing, don't try to start up the Engine as it will fail -try: - import smbus2 -except ImportError as impErr: - logging.error("Failed to import smbus2 %s", impErr.args[0]) - sys.exit(1) +#try: +# import smbus2 +#except ImportError as impErr: +# logging.error("Failed to import smbus2 %s", impErr.args[0]) +# sys.exit(1) # RPi.GPIO is used for software PWM on the RPi, fail if it is missing -if os.getenv('FPPPLATFORM', '') == 'Raspberry Pi' and config['DynRDSTransmitter'] == "QN8066": - try: - import RPi.GPIO - except ImportError as impErr: - logging.error("Failed to import RPi.GPIO %s", impErr.args[0]) - sys.exit(1) +#if os.getenv('FPPPLATFORM', '') == 'Raspberry Pi' and config['DynRDSTransmitter'] == "QN8066": +# try: +# import RPi.GPIO +# except ImportError as impErr: +# logging.error("Failed to import RPi.GPIO %s", impErr.args[0]) +# sys.exit(1) # Environ has a few useful items when FPPD runs callbacks.py, but logging it all the time, even at debug, is too much #logging.debug('Environ %s', os.environ) @@ -72,14 +73,20 @@ def logUnhandledException(eType, eValue, eTraceback): if argv[1] == '--exit' or (argv[1] == '--type' and argv[2] == 'lifecycle' and argv[3] == 'shutdown'): logging.info('Exit, but not running') sys.exit() - logging.info('Starting %s', updater_path) with open(os.devnull, 'w', encoding='UTF-8') as devnull: - proc = subprocess.Popen(['python3', updater_path], stdin=devnull, stdout=devnull, stderr=subprocess.PIPE, close_fds=True) - time.sleep(1) # Allow engine a second to start or fail before checking status - engineStarted = True + proc = subprocess.Popen(['python3', updater_path], stdin=devnull, stdout=devnull, stderr=subprocess.PIPE, text=True, close_fds=True) + try: + # Wait up to 1 second to see if the process exits + proc.wait(timeout=1) + logging.error('%s exited early - %s', updater_path, proc.returncode) + engineStarted = False + except subprocess.TimeoutExpired: + # Timeout means process is STILL RUNNING / success + engineStarted = True except socket.error: - logging.debug('Lock found - %s is running', updater_path) + logging.debug('Lock found — %s is already running', updater_path) + engineStarted = False # Always setup FIFO - Expects Engine to be running to open the read side of the FIFO fifo_path = script_dir + '/Dynamic_RDS_FIFO' From 4aa9bf75f18f70063c162520e6976cb0a7fdd7d9 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sat, 15 Nov 2025 18:33:38 -0500 Subject: [PATCH 12/50] Misc cleanup and FPP 9 tweaks --- QN8066.py | 2 +- basicPWM.py | 1 + callbacks.py | 22 +++++++--------------- pluginInfo.json | 7 +++---- scripts/fpp_install.sh | 3 --- scripts/fpp_uninstall.sh | 6 ++++-- scripts/src_Dynamic_RDS_config.sh | 4 ++-- 7 files changed, 18 insertions(+), 27 deletions(-) diff --git a/QN8066.py b/QN8066.py index d69057c..6b8589c 100644 --- a/QN8066.py +++ b/QN8066.py @@ -147,7 +147,7 @@ def transmitRDS(self, rdsBytes): rdsStatusByte = self.I2C.read(0x01, 1)[0] rdsSendToggleBit = rdsStatusByte >> 1 & 0b1 rdsSentStatusToggleBit = self.I2C.read(0x1a, 1)[0] >> 2 & 0b1 - logging.excessive('Transmit %s - Send Bit %s - Status Bit %s', ' '.join('0x{:02x}'.format(a) for a in rdsBytes), rdsSendToggleBit, rdsSentStatusToggleBit) + logging.excessive('Transmit %s - Send Bit %s - Status Bit %s', ' '.join(f'0x{b:02x}' for a in rdsBytes), rdsSendToggleBit, rdsSentStatusToggleBit) self.I2C.write(0x1c, rdsBytes) self.I2C.write(0x01, [rdsStatusByte ^ 0b10]) # RDS specifications indicate 87.6ms to send a group diff --git a/basicPWM.py b/basicPWM.py index 4c7a8df..647d373 100644 --- a/basicPWM.py +++ b/basicPWM.py @@ -65,6 +65,7 @@ def __init__(self, pinToUse=7): self.pinToUse = pinToUse self.pwm = None # TODO: Ponder if import RPi.GPIO as GPIO is a good idea + # TODO: Look at switching to gpiozero GPIO.setmode(GPIO.BOARD) GPIO.setup(self.pinToUse, GPIO.OUT) GPIO.output(self.pinToUse,0) diff --git a/callbacks.py b/callbacks.py index c0221de..39d1291 100755 --- a/callbacks.py +++ b/callbacks.py @@ -7,7 +7,6 @@ import subprocess import socket import sys -import time from sys import argv from config import config,read_config_from_file @@ -39,21 +38,14 @@ def logUnhandledException(eType, eValue, eTraceback): logging.info('---') logging.debug('Arguments %s', argv[1:]) -# TODO: Should be able to remove these import check since they are part of FPP 9 image -# If smbus is missing, don't try to start up the Engine as it will fail -#try: -# import smbus2 -#except ImportError as impErr: -# logging.error("Failed to import smbus2 %s", impErr.args[0]) -# sys.exit(1) - # RPi.GPIO is used for software PWM on the RPi, fail if it is missing -#if os.getenv('FPPPLATFORM', '') == 'Raspberry Pi' and config['DynRDSTransmitter'] == "QN8066": -# try: -# import RPi.GPIO -# except ImportError as impErr: -# logging.error("Failed to import RPi.GPIO %s", impErr.args[0]) -# sys.exit(1) +# TODO: Look at switching to gpiozero +if os.getenv('FPPPLATFORM', '') == 'Raspberry Pi' and config['DynRDSTransmitter'] == "QN8066": + try: + import RPi.GPIO + except ImportError as impErr: + logging.error("Failed to import RPi.GPIO %s", impErr.args[0]) + sys.exit(1) # Environ has a few useful items when FPPD runs callbacks.py, but logging it all the time, even at debug, is too much #logging.debug('Environ %s', os.environ) diff --git a/pluginInfo.json b/pluginInfo.json index 7f1cd79..9f6ac6b 100644 --- a/pluginInfo.json +++ b/pluginInfo.json @@ -2,8 +2,7 @@ "repoName": "Dynamic_RDS", "name": "Dynamic RDS", "author": "Nick Anderson (ShadowLight8)", - "description": "Manage an FM Transmitter and generate customizable RDS messages similar to typical FM stations. Reads multiple fields from the media's metadata and playlist. Run on Raspberry Pi and BBB. Supports the QN8066 chip.", - + "description": "Manage a QN8066 or Si4713 FM Transmitter and generate customizable RDS messages similar to typical FM stations. Reads multiple fields from the media's metadata and playlist. Run on Raspberry Pi and BBB.", "homeURL": "https://github.com/ShadowLight8/Dynamic_RDS", "srcURL": "https://github.com/ShadowLight8/Dynamic_RDS.git", "bugURL": "https://github.com/ShadowLight8/Dynamic_RDS/issues", @@ -22,13 +21,13 @@ "maxFPPVersion": "7.99", "branch": "main", "sha": "5dfa0bf88e5b948c0cf130e4a8b6363f7d5b6126", - "allowUpdates": 1 + "allowUpdates": 0 }, { "minFPPVersion": "8.0", "maxFPPVersion": "8.99", "branch": "main", - "sha": "", + "sha": "7d77e66d82cb10c7784452ea3a5426dfcf7ecd1e", "allowUpdates": 1 }, { diff --git a/scripts/fpp_install.sh b/scripts/fpp_install.sh index 96872af..9c62171 100755 --- a/scripts/fpp_install.sh +++ b/scripts/fpp_install.sh @@ -3,9 +3,6 @@ echo "Copying, if missing, optional config script to FPP scripts directory..." cp -v -n ~/media/plugins/Dynamic_RDS/scripts/src_Dynamic_RDS_config.sh ~/media/scripts/Dynamic_RDS_config.sh -#echo -e "\nInstalling python3-smbus..." -#sudo apt-get install -y python3-smbus - if test -f /boot/firmware/config.txt; then echo -e "\nInstalling python3-rpi-lgpio..." sudo apt-get install -y python3-rpi-lgpio diff --git a/scripts/fpp_uninstall.sh b/scripts/fpp_uninstall.sh index c02f93a..32d4464 100755 --- a/scripts/fpp_uninstall.sh +++ b/scripts/fpp_uninstall.sh @@ -10,5 +10,7 @@ else echo -e "\nLeaving modified optional config script" fi -echo -e "\nYou can manually uninstall python3-smbus if nothing else uses it." -echo "Command is: sudo apt-get remove -y python3-smbus" +if test -f /boot/firmware/config.txt; then + echo -e "\nYou can manually uninstall python3-rpi-lgpio if nothing else uses it." + echo "Command is: sudo apt-get remove -y python3-rpi-lgpio" +fi diff --git a/scripts/src_Dynamic_RDS_config.sh b/scripts/src_Dynamic_RDS_config.sh index b3f4758..75051fb 100755 --- a/scripts/src_Dynamic_RDS_config.sh +++ b/scripts/src_Dynamic_RDS_config.sh @@ -4,7 +4,7 @@ ############################################################################### # Set the PS text (set to '' or comment out to leave unchanged) -PS='Merry|Christ-| -mas!|{T}|{A}|[{N} of {C}]' +PS='Merry|Christ-| -mas!|{T}|{A}|[{N} of {C}]' # Set the RT text (set to '' or comment out to leave unchanged) RT='Merry Christmas! {T}[ by {A}]|[Track {N} of {C}]' @@ -21,7 +21,7 @@ curl -d "$RT" -X POST http://localhost/api/plugin/Dynamic_RDS/settings/DynRDSRTS echo -e '\n' fi -echo 'Applying changes' +echo 'Applying changes...' curl http://localhost/api/plugin/Dynamic_RDS/FastUpdate From ef04ecd392fac9f3a4b4cc11a7705b11d688c840 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 16 Nov 2025 09:20:08 -0500 Subject: [PATCH 13/50] Fixed missing var --- QN8066.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QN8066.py b/QN8066.py index 6b8589c..0ce763f 100644 --- a/QN8066.py +++ b/QN8066.py @@ -147,7 +147,7 @@ def transmitRDS(self, rdsBytes): rdsStatusByte = self.I2C.read(0x01, 1)[0] rdsSendToggleBit = rdsStatusByte >> 1 & 0b1 rdsSentStatusToggleBit = self.I2C.read(0x1a, 1)[0] >> 2 & 0b1 - logging.excessive('Transmit %s - Send Bit %s - Status Bit %s', ' '.join(f'0x{b:02x}' for a in rdsBytes), rdsSendToggleBit, rdsSentStatusToggleBit) + logging.excessive('Transmit %s - Send Bit %s - Status Bit %s', ' '.join(f'0x{a:02x}' for a in rdsBytes), rdsSendToggleBit, rdsSentStatusToggleBit) self.I2C.write(0x1c, rdsBytes) self.I2C.write(0x01, [rdsStatusByte ^ 0b10]) # RDS specifications indicate 87.6ms to send a group From 9710b511dfd46771b0f5d59a41130d412e46c8ca Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 16 Nov 2025 09:26:23 -0500 Subject: [PATCH 14/50] Update actions to latest versions --- .github/workflows/docker-image.yml | 4 ++-- .github/workflows/pylint.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index a3982aa..884beb5 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -15,6 +15,6 @@ jobs: ports: - 80 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 name: Build the Docker image - + diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 75191ca..e970184 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -9,9 +9,9 @@ jobs: matrix: python-version: ["3.11.2"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies From 2eca50a7c8f8f9c04e555c19eb7fd3cc57c92782 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 16 Nov 2025 20:10:09 -0500 Subject: [PATCH 15/50] Rough working RT. Timing isn't great yet --- Si4713.py | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 7 deletions(-) diff --git a/Si4713.py b/Si4713.py index cabeed3..0021e32 100644 --- a/Si4713.py +++ b/Si4713.py @@ -97,7 +97,7 @@ def startup(self): #logging.info(f'Si4713 Part Number: 47{revData[1]:02d}, Firmware: {revData[2]}.{revData[3]}, ' # f'Patch ID: {revData[4]}.{revData[5]}, Component: {revData[6]}.{revData[7]}, ' # f'Chip Revision: {revData[8]}') - logging.info('Si47%02x - FW %c.%c - Chip Rev %c', + logging.info('Si47%02d - FW %d.%d - Chip Rev %d', revData[1], revData[2], revData[3], revData[8]) if revData[1] != 13: logging.error('Part Number value is %02d instead of 13. Is this a Si4713 chip?', revData[1]) @@ -109,6 +109,7 @@ def startup(self): logging.debug('Circular Buffer: %d/%d, Fifo Buffer: %d/%d', rdsBuffData[3], rdsBuffData[2] + rdsBuffData[3], rdsBuffData[5], rdsBuffData[4] + rdsBuffData[5]) + self.totalCircularBuffers = rdsBuffData[2] + rdsBuffData[3] # Set reference clock (32.768 kHz crystal) #self._set_property(self.PROP_REFCLK_FREQ, 32768) @@ -132,9 +133,9 @@ def startup(self): self._set_property(self.PROP_TX_PREEMPHASIS, 0) # 75 us # Configure RDS - #self._set_property(self.PROP_TX_RDS_PS_MIX, 0x06) # Mix mode + self._set_property(self.PROP_TX_RDS_PS_MIX, 0x05) # Mix mode #self._set_property(self.PROP_TX_RDS_PS_MISC, 0x1808) # Standard settings - #self._set_property(self.PROP_TX_RDS_PS_REPEAT_COUNT, 3) # Repeat 3 times + self._set_property(self.PROP_TX_RDS_PS_REPEAT_COUNT, 5) # Repeat 3 times # Set frequency from config tempFreq = int(float(config['DynRDSFrequency']) * 100) # Convert to 10 kHz units @@ -210,14 +211,115 @@ def updateRDSData(self, PSdata='', RTdata=''): super().updateRDSData(PSdata, RTdata) if self.active: logging.debug('Si4713 updateRDSData active') - self.PS.updateData(PSdata) - self.RT.updateData(RTdata) + self._updatePS(PSdata) + self._updateRT(RTdata) + + def _updatePS(self, psText): + logging.info('Called _updatePS') + if len(psText) > 96: + logging.error('PS text too long: %d (max 96) - truncating', len(psText)) + psText = psText[:96] + + # Ensure psText is a multiple of 8 in length + psText = psText.ljust((len(psText) + 7) // 8 * 8) + logging.info('PS %s', psText) + + for block in range(len(psText) // 4): + start = block * 4 + rdsBytes = [block] + rdsBytes.append(ord(psText[start])) + rdsBytes.append(ord(psText[start + 1])) + rdsBytes.append(ord(psText[start + 2])) + rdsBytes.append(ord(psText[start + 3])) + self._send_command(self.CMD_TX_RDS_PS, rdsBytes) + + self._set_property(self.PROP_TX_RDS_PS_MESSAGE_COUNT, (len(psText) // 8)) + + def _updateRT(self, rtText): + logging.info('Called _updateRT') + + #rtText ='012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345' + rtMaxLength = self.totalCircularBuffers // 3 * 4 + + logging.error('Abs Max Length: %d', rtMaxLength) + + logging.error('RT length: %d', len(rtText)) + + if len(rtText) > rtMaxLength: + rtText = rtText[:rtMaxLength] + logging.error('RT text too long: %d (max %d) - truncating', len(rtText), rtMaxLength) + + logging.error('RT length 2: %d', len(rtText)) + + if len(rtText) % 32 != 0: + if len(rtText) == rtMaxLength: + rtText = rtText[:-1] + chr(0x0d) + else: + rtText = rtText + chr(0x0d) * (4 - len(rtText) % 4) + + logging.debug('Adj RT %d \'%s\'', len(rtText), rtText.replace('\r','<0d>')) + + # Empty circular buffer + self._send_command(self.CMD_TX_RDS_BUFF, [0b00000010, 0, 0, 0, 0, 0, 0]) + + segmentOffset = 0 + ab_flag = 1 + for i in range(0, len(rtText), 4): + if i % 32 == 0: + ab_flag = not ab_flag + segmentOffset = 0 + + rtBytes = [0b00000100, 0b00100000, ab_flag<<4 | segmentOffset] + rtBytes.extend(list(rtText[i:i+4].encode('ascii'))) + logging.info(rtBytes) + self._send_command(self.CMD_TX_RDS_BUFF, rtBytes) + rdsBuffData = self.I2C.read(0x00, 6, True) + logging.debug('Circular Buffer: %d/%d, Fifo Buffer: %d/%d', + rdsBuffData[3], rdsBuffData[2] + rdsBuffData[3], + rdsBuffData[5], rdsBuffData[4] + rdsBuffData[5]) + segmentOffset += 1 + + # Will block for ~80-90ms for RDS Group to be sent + # Check time, if it has been long enough AND a full RT fragment has been sent, move to next fragment + # Flip A/B bit, send next group, if last group set full RT sent flag + # Need to make sure full RT group has been sent at least once before moving on + #if self.currentGroup == 0 and (datetime.now() - self.lastFragmentTime).total_seconds() >= self.delay: + # self.currentFragment = (self.currentFragment + 1) % len(self.fragments) + # self.lastFragmentTime = datetime.now() + # self.ab = not self.ab + # Change \r (0x0d) to be [0d] for logging so it is visible in case of debugging + + + #if len(self.fragments[-1]) < self.frag_size: + # self.fragments[-1] += chr(0x0d) + + + # TODO: Seems like this could be improved + #rdsBytes = [0b1000<<2 | self.pty>>3, (0b00111 & self.pty)<<5 | self.ab<<4 | self.currentGroup] + #rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size])) + #rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 1]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 2 else 0x20) + #rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 2]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 3 else 0x20) + #rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 3]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 4 else 0x20) + + #self.outer.transmitRDS(rdsBytes) + #self.currentGroup += 1 + #if self.currentGroup * self.group_size >= len(self.fragments[self.currentFragment]): + # self.currentGroup = 0 + + + + + + + + def sendNextRDSGroup(self): # If more advanced mixing of RDS groups is needed, this is where it would occur logging.excessive('Si4713 sendNextRDSGroup') - self.PS.sendNextGroup() - self.RT.sendNextGroup() + sleep(0.25) + #self.PS.sendNextGroup() + #self.RT.sendNextGroup() def transmitRDS(self, rdsBytes): """ From d8c7749b749371be095d7e5623e8781a9e439ea2 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 16 Nov 2025 23:12:21 -0500 Subject: [PATCH 16/50] Large cleanup in Si4713.py. Getting closer --- Si4713.py | 196 ++++-------------------------------------------------- 1 file changed, 13 insertions(+), 183 deletions(-) diff --git a/Si4713.py b/Si4713.py index 0021e32..9f9b0c7 100644 --- a/Si4713.py +++ b/Si4713.py @@ -1,6 +1,7 @@ import logging import sys import os +from threading import Timer from time import sleep from datetime import datetime from gpiozero import DigitalOutputDevice @@ -14,8 +15,6 @@ def __init__(self): logging.info('Initializing Si4713 transmitter') super().__init__() self.I2C = basicI2C(0x63) # Si4713 default I2C address - self.PS = self.PSBuffer(self, ' ', int(config['DynRDSPSUpdateRate'])) - self.RT = self.RTBuffer(self, ' ', int(config['DynRDSRTUpdateRate'])) # Si4713 Commands CMD_POWER_UP = 0x01 @@ -94,9 +93,6 @@ def startup(self): # Verify chip by getting revision self._send_command(self.CMD_GET_REV, [], True) revData = self.I2C.read(0x00, 9, True) - #logging.info(f'Si4713 Part Number: 47{revData[1]:02d}, Firmware: {revData[2]}.{revData[3]}, ' - # f'Patch ID: {revData[4]}.{revData[5]}, Component: {revData[6]}.{revData[7]}, ' - # f'Chip Revision: {revData[8]}') logging.info('Si47%02d - FW %d.%d - Chip Rev %d', revData[1], revData[2], revData[3], revData[8]) if revData[1] != 13: @@ -111,21 +107,9 @@ def startup(self): rdsBuffData[5], rdsBuffData[4] + rdsBuffData[5]) self.totalCircularBuffers = rdsBuffData[2] + rdsBuffData[3] - # Set reference clock (32.768 kHz crystal) - #self._set_property(self.PROP_REFCLK_FREQ, 32768) - # Enable stereo, pilot, and RDS self._set_property(self.PROP_TX_COMPONENT_ENABLE, 0x0007) - # Set audio deviation (68.25 kHz) - #self._set_property(self.PROP_TX_AUDIO_DEVIATION, 6825) - - # Set pilot deviation (6.75 kHz) - #self._set_property(self.PROP_TX_PILOT_DEVIATION, 675) - - # Set RDS deviation (2 kHz) - #self._set_property(self.PROP_TX_RDS_DEVIATION, 200) - # Set pre-emphasis if config['DynRDSPreemphasis'] == "50us": self._set_property(self.PROP_TX_PREEMPHASIS, 1) # 50 us @@ -134,8 +118,13 @@ def startup(self): # Configure RDS self._set_property(self.PROP_TX_RDS_PS_MIX, 0x05) # Mix mode - #self._set_property(self.PROP_TX_RDS_PS_MISC, 0x1808) # Standard settings - self._set_property(self.PROP_TX_RDS_PS_REPEAT_COUNT, 5) # Repeat 3 times + self._set_property(self.PROP_TX_RDS_PS_REPEAT_COUNT, 9) # Repeat 3 times + # TODO: Timing guidance is needed + # MIX @ 5 and REPEAT @ 9 - PS ~4 sec, RT ~5.5 sec + # Lowering MIX still speed up RT refresh + # Lowering REP will speed up PS, Raising REP will slow down PS + # TODO: Decide on bit 11 - 0=FIFO and BUFFER use PTY and TP as when written, 1=Force to be this setting + self._set_property(self.PROP_TX_RDS_PS_MISC, 0b0001100000001000 | int(config['DynRDSPty'])<<5) # Set frequency from config tempFreq = int(float(config['DynRDSFrequency']) * 100) # Convert to 10 kHz units @@ -160,10 +149,6 @@ def startup(self): self._send_command(self.CMD_TX_TUNE_POWER, args) sleep(0.02) - # Set TX_RDS_PS_MISC - # TODO: Decide on bit 11 - 0=FIFO and BUFFER use PTY and TP as when written, 1=Force to be this setting - self._set_property(self.PROP_TX_RDS_PS_MISC, 0b0001100000001000 | int(config['DynRDSPty'])<<5) - # Set TX_RDS_PI self._set_property(self.PROP_TX_RDS_PI, int(config['DynRDSPICode'], 16)) @@ -213,6 +198,9 @@ def updateRDSData(self, PSdata='', RTdata=''): logging.debug('Si4713 updateRDSData active') self._updatePS(PSdata) self._updateRT(RTdata) + # Initial burst of RT groups to get it displayed quickly + self._set_property(self.PROP_TX_RDS_PS_MIX, 0x01) # Mix mode + Timer(1, lambda: (logging.info('LAMBDA'), self._set_property(self.PROP_TX_RDS_PS_MIX, 0x05))).start() def _updatePS(self, psText): logging.info('Called _updatePS') @@ -238,7 +226,6 @@ def _updatePS(self, psText): def _updateRT(self, rtText): logging.info('Called _updateRT') - #rtText ='012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345' rtMaxLength = self.totalCircularBuffers // 3 * 4 logging.error('Abs Max Length: %d', rtMaxLength) @@ -257,6 +244,7 @@ def _updateRT(self, rtText): else: rtText = rtText + chr(0x0d) * (4 - len(rtText) % 4) + # TODO: It might be better to stick with 32 char groups only logging.debug('Adj RT %d \'%s\'', len(rtText), rtText.replace('\r','<0d>')) # Empty circular buffer @@ -272,6 +260,7 @@ def _updateRT(self, rtText): rtBytes = [0b00000100, 0b00100000, ab_flag<<4 | segmentOffset] rtBytes.extend(list(rtText[i:i+4].encode('ascii'))) logging.info(rtBytes) + # TODO: Can add to buffer twice as a way to slow down update speed self._send_command(self.CMD_TX_RDS_BUFF, rtBytes) rdsBuffData = self.I2C.read(0x00, 6, True) logging.debug('Circular Buffer: %d/%d, Fifo Buffer: %d/%d', @@ -279,165 +268,6 @@ def _updateRT(self, rtText): rdsBuffData[5], rdsBuffData[4] + rdsBuffData[5]) segmentOffset += 1 - # Will block for ~80-90ms for RDS Group to be sent - # Check time, if it has been long enough AND a full RT fragment has been sent, move to next fragment - # Flip A/B bit, send next group, if last group set full RT sent flag - # Need to make sure full RT group has been sent at least once before moving on - #if self.currentGroup == 0 and (datetime.now() - self.lastFragmentTime).total_seconds() >= self.delay: - # self.currentFragment = (self.currentFragment + 1) % len(self.fragments) - # self.lastFragmentTime = datetime.now() - # self.ab = not self.ab - # Change \r (0x0d) to be [0d] for logging so it is visible in case of debugging - - - #if len(self.fragments[-1]) < self.frag_size: - # self.fragments[-1] += chr(0x0d) - - - # TODO: Seems like this could be improved - #rdsBytes = [0b1000<<2 | self.pty>>3, (0b00111 & self.pty)<<5 | self.ab<<4 | self.currentGroup] - #rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size])) - #rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 1]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 2 else 0x20) - #rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 2]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 3 else 0x20) - #rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 3]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 4 else 0x20) - - #self.outer.transmitRDS(rdsBytes) - #self.currentGroup += 1 - #if self.currentGroup * self.group_size >= len(self.fragments[self.currentFragment]): - # self.currentGroup = 0 - - - - - - - - - def sendNextRDSGroup(self): - # If more advanced mixing of RDS groups is needed, this is where it would occur logging.excessive('Si4713 sendNextRDSGroup') sleep(0.25) - #self.PS.sendNextGroup() - #self.RT.sendNextGroup() - - def transmitRDS(self, rdsBytes): - """ - Transmit RDS group using Si4713's TX_RDS_BUFF command - rdsBytes: 8-byte array containing the RDS group - """ - logging.excessive("Transmit %s", ' '.join(f"0x{b:02X}" for b in rdsBytes)) - - # Si4713 uses CMD_TX_RDS_BUFF to load RDS data - # Command format: CMD, status, FIFO count, RDS data (8 bytes) - args = [0b00000100] # Clear interrupt - args.extend(rdsBytes) - - success = self._send_command(self.CMD_TX_RDS_BUFF, args) - self._send_command(self.CMD_TX_RDS_BUFF, [0, 0, 0, 0, 0, 0, 0], True) - rdsBuffData = self.I2C.read(0x00, 6, True) - logging.debug('Circular Buffer: %d/%d, Fifo Buffer: %d/%d', - rdsBuffData[3], rdsBuffData[2] + rdsBuffData[3], - rdsBuffData[5], rdsBuffData[4] + rdsBuffData[5]) - - if not success: - logging.error('Failed to transmit RDS group') - # RDS has failed to update, reset the Si4713 - self.reset() - return - - # RDS specifications indicate 87.6ms to send a group - sleep(0.087) - - class PSBuffer(Transmitter.RDSBuffer): - # Sends RDS type 0B groups - Program Service - # Fragment size of 8, Groups send 4 characters at a time - def __init__(self, outer, data, delay=4): - self.outer = outer - super().__init__(data, 8, 4, delay) - # Include outer for the common transmitRDS function that both PSBuffer and RTBuffer use - - def updateData(self, data): - super().updateData(data) - - if len(self.fragments) > 12: - logging.error('Too many PS fragments: %d (max 12)', len(self.fragments)) - return - - # Adjust last fragment to make all 8 characters long - self.fragments[-1] = self.fragments[-1].ljust(self.frag_size) - logging.info('PS %s', self.fragments) - - self.outer._set_property(self.outer.PROP_TX_RDS_PS_MESSAGE_COUNT, 3) - - group = 0 - for fragment in self.fragments: - for chunk in range(self.frag_size // self.group_size): - start = chunk * self.group_size - rdsBytes = [group] - rdsBytes.append(ord(fragment[start])) - rdsBytes.append(ord(fragment[start + 1])) - rdsBytes.append(ord(fragment[start + 2])) - rdsBytes.append(ord(fragment[start + 3])) - self.outer._send_command(self.outer.CMD_TX_RDS_PS, rdsBytes) - group += 1 - - def sendNextGroup(self): - sleep(0.25) - return - if self.currentGroup == 0 and (datetime.now() - self.lastFragmentTime).total_seconds() >= self.delay: - self.currentFragment = (self.currentFragment + 1) % len(self.fragments) - self.lastFragmentTime = datetime.now() - logging.debug('Send PS Fragment \'%s\'', self.fragments[self.currentFragment]) - - rdsBytes = [self.currentGroup] - rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size])) - rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 1])) - rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 2])) - rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 3])) - - self.outer._send_command(self.outer.CMD_TX_RDS_PS, rdsBytes) - #self.outer.transmitRDS(rdsBytes) - self.currentGroup = (self.currentGroup + 1) % (self.frag_size // self.group_size) - #sleep(0.25) - - class RTBuffer(Transmitter.RDSBuffer): - # Sends RDS type 2A groups - RadioText - # Max fragment size of 64, Groups send 4 characters at a time - def __init__(self, outer, data, delay=7): - self.ab = 0 - super().__init__(data, int(config['DynRDSRTSize']), 4, delay) - self.outer = outer - - def updateData(self, data): - super().updateData(data) - # Add 0x0d to end of last fragment to indicate RT is done - # TODO: This isn't quite correct - Should put 0x0d where a break is indicated in the rdsStyleText - if len(self.fragments[-1]) < self.frag_size: - self.fragments[-1] += chr(0x0d) - self.ab = not self.ab - logging.info('RT %s', self.fragments) - - def sendNextGroup(self): - # Will block for ~80-90ms for RDS Group to be sent - # Check time, if it has been long enough AND a full RT fragment has been sent, move to next fragment - # Flip A/B bit, send next group, if last group set full RT sent flag - # Need to make sure full RT group has been sent at least once before moving on - if self.currentGroup == 0 and (datetime.now() - self.lastFragmentTime).total_seconds() >= self.delay: - self.currentFragment = (self.currentFragment + 1) % len(self.fragments) - self.lastFragmentTime = datetime.now() - self.ab = not self.ab - # Change \r (0x0d) to be [0d] for logging so it is visible in case of debugging - logging.debug('Send RT Fragment \'%s\'', self.fragments[self.currentFragment].replace('\r','<0d>')) - - # TODO: Seems like this could be improved - rdsBytes = [0b1000<<2 | self.pty>>3, (0b00111 & self.pty)<<5 | self.ab<<4 | self.currentGroup] - rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size])) - rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 1]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 2 else 0x20) - rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 2]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 3 else 0x20) - rdsBytes.append(ord(self.fragments[self.currentFragment][self.currentGroup * self.group_size + 3]) if len(self.fragments[self.currentFragment]) - self.currentGroup * self.group_size >= 4 else 0x20) - - self.outer.transmitRDS(rdsBytes) - self.currentGroup += 1 - if self.currentGroup * self.group_size >= len(self.fragments[self.currentFragment]): - self.currentGroup = 0 From ac0806a84d3d862ce5f3d6aeeecd5dcf7e1b1f7b Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 16 Nov 2025 23:14:18 -0500 Subject: [PATCH 17/50] Lint cleanup --- Si4713.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Si4713.py b/Si4713.py index 9f9b0c7..5d87378 100644 --- a/Si4713.py +++ b/Si4713.py @@ -1,9 +1,7 @@ import logging import sys -import os from threading import Timer from time import sleep -from datetime import datetime from gpiozero import DigitalOutputDevice from config import config @@ -15,6 +13,7 @@ def __init__(self): logging.info('Initializing Si4713 transmitter') super().__init__() self.I2C = basicI2C(0x63) # Si4713 default I2C address + self.totalCircularBuffers = 0 # Si4713 Commands CMD_POWER_UP = 0x01 From f4f5a0a59e4b20c4a3d8a76dabc783fdead85750 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Tue, 18 Nov 2025 19:36:39 -0500 Subject: [PATCH 18/50] Update menu icon --- menu.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/menu.inc b/menu.inc index edb40dd..1449487 100644 --- a/menu.inc +++ b/menu.inc @@ -42,7 +42,7 @@ foreach ($menuEntries as $entry) if (isset($entry['wrap']) && ($entry['wrap'] == 0)) $nopage = '&nopage=1'; - printf("
    • %s
    • \n", + printf("
    • %s
    • \n", $plugin, $entry['page'], $nopage, $entry['text']); } } From b83ac05d1352b18ab6b39521131a26c599da4b4a Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Wed, 19 Nov 2025 21:54:03 -0500 Subject: [PATCH 19/50] Adjustments to RDS message timing and clean up --- Si4713.py | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/Si4713.py b/Si4713.py index 5d87378..637e77d 100644 --- a/Si4713.py +++ b/Si4713.py @@ -194,12 +194,12 @@ def updateRDSData(self, PSdata='', RTdata=''): logging.debug('Si4713 updateRDSData') super().updateRDSData(PSdata, RTdata) if self.active: - logging.debug('Si4713 updateRDSData active') self._updatePS(PSdata) self._updateRT(RTdata) # Initial burst of RT groups to get it displayed quickly - self._set_property(self.PROP_TX_RDS_PS_MIX, 0x01) # Mix mode - Timer(1, lambda: (logging.info('LAMBDA'), self._set_property(self.PROP_TX_RDS_PS_MIX, 0x05))).start() + logging.debug('Lambda: RT group burst') + self._set_property(self.PROP_TX_RDS_PS_MIX, 0x02) # Mix mode + Timer(1, lambda: (logging.debug('Lambda: RT group burst done'), self._set_property(self.PROP_TX_RDS_PS_MIX, 0x05))).start() def _updatePS(self, psText): logging.info('Called _updatePS') @@ -223,34 +223,27 @@ def _updatePS(self, psText): self._set_property(self.PROP_TX_RDS_PS_MESSAGE_COUNT, (len(psText) // 8)) def _updateRT(self, rtText): - logging.info('Called _updateRT') + logging.info('RT length: %d', len(rtText)) - rtMaxLength = self.totalCircularBuffers // 3 * 4 - - logging.error('Abs Max Length: %d', rtMaxLength) - - logging.error('RT length: %d', len(rtText)) + # Calculate max number of complete BCD groups * 4 chars per group, down to the nearest 32, back to characters + rtMaxLength = self.totalCircularBuffers // 3 * 4 // 32 * 32 + logging.info('Abs Max Length: %d', rtMaxLength) if len(rtText) > rtMaxLength: rtText = rtText[:rtMaxLength] - logging.error('RT text too long: %d (max %d) - truncating', len(rtText), rtMaxLength) - - logging.error('RT length 2: %d', len(rtText)) + logging.info('RT text too long: %d (max %d) - truncating', len(rtText), rtMaxLength) + # Pad the last group so transmitting takes the same time as prior blocks if len(rtText) % 32 != 0: - if len(rtText) == rtMaxLength: - rtText = rtText[:-1] + chr(0x0d) - else: - rtText = rtText + chr(0x0d) * (4 - len(rtText) % 4) + rtText = rtText.ljust((len(rtText) + 31) // 32 * 32) - # TODO: It might be better to stick with 32 char groups only - logging.debug('Adj RT %d \'%s\'', len(rtText), rtText.replace('\r','<0d>')) + logging.info('Adj RT %d \'%s\'', len(rtText), rtText.replace('\r','<0d>')) # Empty circular buffer self._send_command(self.CMD_TX_RDS_BUFF, [0b00000010, 0, 0, 0, 0, 0, 0]) segmentOffset = 0 - ab_flag = 1 + ab_flag = True for i in range(0, len(rtText), 4): if i % 32 == 0: ab_flag = not ab_flag @@ -258,14 +251,13 @@ def _updateRT(self, rtText): rtBytes = [0b00000100, 0b00100000, ab_flag<<4 | segmentOffset] rtBytes.extend(list(rtText[i:i+4].encode('ascii'))) - logging.info(rtBytes) # TODO: Can add to buffer twice as a way to slow down update speed self._send_command(self.CMD_TX_RDS_BUFF, rtBytes) - rdsBuffData = self.I2C.read(0x00, 6, True) - logging.debug('Circular Buffer: %d/%d, Fifo Buffer: %d/%d', - rdsBuffData[3], rdsBuffData[2] + rdsBuffData[3], - rdsBuffData[5], rdsBuffData[4] + rdsBuffData[5]) + rdsBuffData = self.I2C.read(0x00, 6) segmentOffset += 1 + logging.debug('Circular Buffer: %d/%d, Fifo Buffer: %d/%d', + rdsBuffData[3], rdsBuffData[2] + rdsBuffData[3], + rdsBuffData[5], rdsBuffData[4] + rdsBuffData[5]) def sendNextRDSGroup(self): logging.excessive('Si4713 sendNextRDSGroup') From 6ed486c7dfeeda84fe38bb4ecb7a81d7c7976601 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sat, 29 Nov 2025 21:56:50 -0500 Subject: [PATCH 20/50] Si4713 and callback logging cleanup. Ignore query_next actions to callback --- Si4713.py | 27 +++++++++++++-------------- callbacks.py | 18 +++++++++--------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/Si4713.py b/Si4713.py index 637e77d..a279eba 100644 --- a/Si4713.py +++ b/Si4713.py @@ -73,7 +73,7 @@ def _set_property(self, prop, value): def startup(self): logging.info('Starting Si4713 transmitter') - logging.debug('Executing Reset with Pin %s', config['DynRDSSi4713GPIOReset']) + logging.info('Executing Reset with Pin %s', config['DynRDSSi4713GPIOReset']) with DigitalOutputDevice(int(config['DynRDSSi4713GPIOReset'])) as resetPin: resetPin.on() sleep(0.01) @@ -101,7 +101,7 @@ def startup(self): # TODO: Make a function to use in status? self._send_command(self.CMD_TX_RDS_BUFF, [0, 0, 0, 0, 0, 0, 0], True) rdsBuffData = self.I2C.read(0x00, 6, True) - logging.debug('Circular Buffer: %d/%d, Fifo Buffer: %d/%d', + logging.info('Circular Buffer: %d/%d, Fifo Buffer: %d/%d', rdsBuffData[3], rdsBuffData[2] + rdsBuffData[3], rdsBuffData[5], rdsBuffData[4] + rdsBuffData[5]) self.totalCircularBuffers = rdsBuffData[2] + rdsBuffData[3] @@ -175,6 +175,7 @@ def reset(self, resetdelay=1): self.startup() def status(self): + # TODO: Review before Si4713 support is done # Get transmitter status self._send_command(self.CMD_TX_TUNE_STATUS, [0x01]) # Clear interrupt status_data = self.I2C.read(0x00, 8) @@ -197,19 +198,19 @@ def updateRDSData(self, PSdata='', RTdata=''): self._updatePS(PSdata) self._updateRT(RTdata) # Initial burst of RT groups to get it displayed quickly - logging.debug('Lambda: RT group burst') + logging.debug('RT group burst') self._set_property(self.PROP_TX_RDS_PS_MIX, 0x02) # Mix mode - Timer(1, lambda: (logging.debug('Lambda: RT group burst done'), self._set_property(self.PROP_TX_RDS_PS_MIX, 0x05))).start() + Timer(1, lambda: [logging.debug('RT group burst done'), self._set_property(self.PROP_TX_RDS_PS_MIX, 0x05)]).start() def _updatePS(self, psText): - logging.info('Called _updatePS') + logging.debug('Si4713 _updatePS') if len(psText) > 96: - logging.error('PS text too long: %d (max 96) - truncating', len(psText)) + logging.warning('PS text too long: %d (max 96) - truncating', len(psText)) psText = psText[:96] # Ensure psText is a multiple of 8 in length psText = psText.ljust((len(psText) + 7) // 8 * 8) - logging.info('PS %s', psText) + logging.info('PS \'%s\'', psText) for block in range(len(psText) // 4): start = block * 4 @@ -223,21 +224,21 @@ def _updatePS(self, psText): self._set_property(self.PROP_TX_RDS_PS_MESSAGE_COUNT, (len(psText) // 8)) def _updateRT(self, rtText): - logging.info('RT length: %d', len(rtText)) + logging.debug('Si4713 _updateRT') # Calculate max number of complete BCD groups * 4 chars per group, down to the nearest 32, back to characters rtMaxLength = self.totalCircularBuffers // 3 * 4 // 32 * 32 - logging.info('Abs Max Length: %d', rtMaxLength) + logging.debug('RT length: %d, Abs Max Length: %d', len(rtText), rtMaxLength) if len(rtText) > rtMaxLength: rtText = rtText[:rtMaxLength] - logging.info('RT text too long: %d (max %d) - truncating', len(rtText), rtMaxLength) + logging.warning('RT text too long: %d (max %d) - truncating', len(rtText), rtMaxLength) # Pad the last group so transmitting takes the same time as prior blocks if len(rtText) % 32 != 0: rtText = rtText.ljust((len(rtText) + 31) // 32 * 32) - logging.info('Adj RT %d \'%s\'', len(rtText), rtText.replace('\r','<0d>')) + logging.info('RT \'%s\'', rtText.replace('\r','<0d>')) # Empty circular buffer self._send_command(self.CMD_TX_RDS_BUFF, [0b00000010, 0, 0, 0, 0, 0, 0]) @@ -255,9 +256,7 @@ def _updateRT(self, rtText): self._send_command(self.CMD_TX_RDS_BUFF, rtBytes) rdsBuffData = self.I2C.read(0x00, 6) segmentOffset += 1 - logging.debug('Circular Buffer: %d/%d, Fifo Buffer: %d/%d', - rdsBuffData[3], rdsBuffData[2] + rdsBuffData[3], - rdsBuffData[5], rdsBuffData[4] + rdsBuffData[5]) + logging.info('Circular Buffer: %d/%d', rdsBuffData[3], rdsBuffData[2] + rdsBuffData[3]) def sendNextRDSGroup(self): logging.excessive('Si4713 sendNextRDSGroup') diff --git a/callbacks.py b/callbacks.py index 39d1291..6d5f853 100755 --- a/callbacks.py +++ b/callbacks.py @@ -1,3 +1,4 @@ + #!/usr/bin/python3 import logging @@ -35,8 +36,8 @@ def logUnhandledException(eType, eValue, eTraceback): logging.getLogger().setLevel(config['DynRDSCallbackLogLevel']) -logging.info('---') -logging.debug('Arguments %s', argv[1:]) +logging.debug('---') +logging.debug('Args %s', argv[1:]) # RPi.GPIO is used for software PWM on the RPi, fail if it is missing # TODO: Look at switching to gpiozero @@ -50,7 +51,7 @@ def logUnhandledException(eType, eValue, eTraceback): # Environ has a few useful items when FPPD runs callbacks.py, but logging it all the time, even at debug, is too much #logging.debug('Environ %s', os.environ) -# Always start the Engine since it does the real work for all command +# Always try to start the Engine since it does the real work for all command updater_path = script_dir + '/Dynamic_RDS_Engine.py' engineStarted = False proc = None @@ -96,9 +97,9 @@ def logUnhandledException(eType, eValue, eTraceback): with open(fifo_path, 'w', encoding='UTF-8') as fifo: if len(argv) >= 4: - logging.info('Processing %s %s %s', argv[1], argv[2], argv[3]) + logging.info('Args %s %s %s', argv[1], argv[2], argv[3]) else: - logging.info('Processing %s', argv[1]) + logging.info('Args %s', argv[1]) # If Engine was started AND the argument isn't --list, INIT must be sent to Engine before the requested argument if engineStarted and argv[1] != '--list': @@ -123,7 +124,6 @@ def logUnhandledException(eType, eValue, eTraceback): fifo.write('EXIT\n') elif argv[1] == '--type' and argv[2] == 'media': - logging.debug('Type media') try: j = json.loads(argv[4]) except Exception: @@ -161,8 +161,6 @@ def logUnhandledException(eType, eValue, eTraceback): fifo.write('L' + media_length + '\n') # Length is always sent last for media-based updates to optimize when the Engine has to update the RDS Data elif argv[1] == '--type' and argv[2] == 'playlist': - logging.debug('Type playlist') - try: j = json.loads(argv[4]) except ValueError: @@ -170,13 +168,15 @@ def logUnhandledException(eType, eValue, eTraceback): playlist_action = j['Action'] if 'Action' in j else 'stop' - logging.info('Playlist action %s', j['Action']) + logging.info('Action %s', j['Action']) if playlist_action == 'start': # or playlist_action == 'playing': fifo.write('START\n') elif playlist_action == 'stop': fifo.write('STOP\n') sys.exit() + elif playlist_action == 'query_next': # Skip this + sys.exit() if j['Section'] == 'MainPlaylist': logging.debug('Playlist name %s', j['name']) From a5b41c1d818a513b4bdd21ce5360d205f94535ef Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Mon, 1 Dec 2025 20:15:41 -0500 Subject: [PATCH 21/50] Adding image of the Si4713 breakout board --- images/Si4713-transmitter.jpg | Bin 0 -> 44413 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 images/Si4713-transmitter.jpg diff --git a/images/Si4713-transmitter.jpg b/images/Si4713-transmitter.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3cfcf072a7cdc7c2678ce082de75bfab5bbbf4d1 GIT binary patch literal 44413 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 E2b17DvH$=8 literal 0 HcmV?d00001 From c796081d209fb9840b29d6779f4237f2945bc1ae Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Mon, 1 Dec 2025 20:17:36 -0500 Subject: [PATCH 22/50] Combined calls to updateRDSData when playlist and media updates occur --- Dynamic_RDS_Engine.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/Dynamic_RDS_Engine.py b/Dynamic_RDS_Engine.py index 26807a5..a8030b4 100755 --- a/Dynamic_RDS_Engine.py +++ b/Dynamic_RDS_Engine.py @@ -8,8 +8,9 @@ import socket import sys import subprocess +import time import unicodedata -from time import sleep + from datetime import date, datetime, timedelta from urllib.request import urlopen from urllib.parse import quote @@ -131,7 +132,8 @@ def rdsStyleToString(rdsStyle, groupSize): # Setup logging script_dir = os.path.dirname(os.path.abspath(sys.argv[0])) -logging.basicConfig(stream=sys.stderr, level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s', datefmt='%H:%M:%S') + +#logging.basicConfig(stream=sys.stderr, level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s', datefmt='%H:%M:%S') logging.basicConfig(filename=script_dir + '/Dynamic_RDS_Engine.log', level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s', datefmt='%H:%M:%S') # Adding in excessive log level below debug for very noisy items @@ -182,10 +184,21 @@ def excessive(msg, *args, **kwargs): mqtt = None activePlaylist = False nextMPCUpdate = datetime.now() +pendingPlaylistUpdate = False +pendingMediaUpdate = False +lastUpdateTime = None # Check if new information is in the FIFO and process accordingly with open(fifo_path, 'r', encoding='UTF-8') as fifo: while True: + if ((pendingPlaylistUpdate and pendingMediaUpdate) or + ((pendingPlaylistUpdate or pendingMediaUpdate) and (lastUpdateTime is not None and (time.monotonic() - lastUpdateTime) >= 0.3))): + logging.info('Updating pending RDS Data: playlist=%s, media=%s', pendingPlaylistUpdate, pendingMediaUpdate) + updateRDSData() + pendingPlaylistUpdate = False + pendingMediaUpdate = False + lastUpdateTime = None + line = fifo.readline().rstrip() if len(line) > 0: logging.debug('line %s', line) @@ -281,7 +294,8 @@ def excessive(msg, *args, **kwargs): elif line[0] == 'P': logging.debug('Processing playlist position') rdsValues['{P}'] = line[1:] - updateRDSData() # Always follows MAINLIST, so only a single update is needed + pendingPlaylistUpdate = True + lastUpdateTime = time.monotonic() # rdsValues that need additional parsing elif line[0] == 'L': @@ -295,8 +309,8 @@ def excessive(msg, *args, **kwargs): # TANL is always sent together with L being last item, so we only need to update the RDS Data once with the new values # TODO: This will likely change as more data is added, so a new way will have to be determined - updateRDSData() - #activePlaylist = True # TODO: Is this needed still? + pendingMediaUpdate = True + lastUpdateTime = time.monotonic() transmitter.status() # All of the rdsValues that are stored as is From f1abee7167927143daae618ecd2d257b45a7a839 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Mon, 1 Dec 2025 20:18:32 -0500 Subject: [PATCH 23/50] Added code in callback.py to wait for Engine to shutdown. Minor lint cleanup --- Si4713.py | 2 +- callbacks.py | 28 +++++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Si4713.py b/Si4713.py index a279eba..b65576b 100644 --- a/Si4713.py +++ b/Si4713.py @@ -236,7 +236,7 @@ def _updateRT(self, rtText): # Pad the last group so transmitting takes the same time as prior blocks if len(rtText) % 32 != 0: - rtText = rtText.ljust((len(rtText) + 31) // 32 * 32) + rtText = rtText.ljust((len(rtText) + 31) // 32 * 32) logging.info('RT \'%s\'', rtText.replace('\r','<0d>')) diff --git a/callbacks.py b/callbacks.py index 6d5f853..1a6a4e4 100755 --- a/callbacks.py +++ b/callbacks.py @@ -1,4 +1,3 @@ - #!/usr/bin/python3 import logging @@ -8,8 +7,9 @@ import subprocess import socket import sys -from sys import argv +import time +from sys import argv from config import config,read_config_from_file def logUnhandledException(eType, eValue, eTraceback): @@ -92,7 +92,7 @@ def logUnhandledException(eType, eValue, eTraceback): logging.debug('Fifo already exists') if proc is not None and proc.poll() is not None: - logging.error('%s failed to stay running - %s', updater_path, proc.stderr.read().decode()) + logging.error('%s failed to stay running - %s', updater_path, proc.stderr.read()) sys.exit(1) with open(fifo_path, 'w', encoding='UTF-8') as fifo: @@ -122,6 +122,28 @@ def logUnhandledException(eType, eValue, eTraceback): elif argv[1] == '--exit' or (argv[1] == '--type' and argv[2] == 'lifecycle' and argv[3] == 'shutdown'): # Used by FPPD lifecycle shutdown. Also useful for testing or scripting fifo.write('EXIT\n') + fifo.flush() + + timeout = 5 + startTime = time.monotonic() + logging.info('Waiting for Engine to shutdown (timeout: %ss)', timeout) + + # Poll the socket lock until it's released + while time.monotonic() - startTime < timeout: + try: + # Try to acquire the lock - if successful, Engine has released it + lock_socket = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + lock_socket.bind('\0Dynamic_RDS_Engine') + lock_socket.close() + # Successfully bound = Engine has shut down + elapsed = time.monotonic() - startTime + logging.info('Engine shutdown after %.2fs', elapsed) + sys.exit() + except socket.error: + # Lock still held by Engine, continue waiting + time.sleep(0.05) # Sleep 50ms between attempts + continue + logging.warning('Engine shutdown timeout after %ss', timeout) elif argv[1] == '--type' and argv[2] == 'media': try: From 5edba5ecf2dcd8349a2afbe7de2d43533b5273d2 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Mon, 1 Dec 2025 20:18:51 -0500 Subject: [PATCH 24/50] Updates to README.md for intial Si4713 info --- README.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2c593d2..f25147b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,16 @@ # Dynamic_RDS - FM Transmitter Plugin for Falcon Player -Created for Falcon Player 6.0+ (FPP) as a plugin to 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 there are plans to add the Si4173 in the future. The chips are controlled via the I2C bus. +> [!NOTE] +> Supports **QN8066** and **Si4713** FM transmitter chips -## Recommended QN8066 transmitter board +Created for Falcon Player 6.0+ (FPP) as a plugin to 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. + +## 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) + +![Si4713 Breakout Board](images/Si4713-transmitter.jpg) + +## QN8066 transmitter board > [!IMPORTANT] > There are other similar looking boards, so double check for the QN8066 chip. For a detailed look at identifying QN8066 boards, check out [Spectraman's video](https://www.youtube.com/watch?v=i8re0nc_FdY&t=1017s). @@ -12,7 +20,7 @@ Created for Falcon Player 6.0+ (FPP) as a plugin to generate RDS (radio data sys ![Radio Board](images/radio_board.jpeg) ![Radio Board Pinout](images/radio_board_pinout.jpeg) -## Antenna +### Antenna The QN8066 transmitter board needs an antenna for safe operations. * Small bench testing option - https://www.amazon.com/gp/product/B07K7DBVX9 @@ -23,11 +31,11 @@ The QN8066 transmitter board needs an antenna for safe operations. (More detail to be added) -## Cables, Connectors, and Shielding +### Cables, Connectors, and Shielding > [!CAUTION] > Do not run the PWM wire along side the I2C wires. During testing this caused failures in the I2C commands as soon as PWM was enabled. -### Connector info +#### Connector info * The connection on the transmitter board is a 5-pin JST-XH type connector, 2.54mm. * The Raspberry Pis use a female Dupont connector and we recommended using a 2 x 6 block connector. * The BeagleBone Blacks (BBB) use a male Dupont connector (recommendation pending BBB support work in progress). @@ -42,7 +50,7 @@ Pre-crimped wires are also an options * JST-XH Pre-crimped wires - https://www.amazon.com/dp/B0BW9TJN21 * Dupont Pre-crimped wires - https://www.amazon.com/dp/B07GD1W1VL -### Cable for a Raspberry Pi +#### Cable for a Raspberry Pi ![Raspberry Pi Connection](images/raspberry_pi_connection.jpeg) ![Raspberry Pi to Radio](images/radio_board_and_pi_pinout.jpeg) @@ -50,15 +58,15 @@ Pre-crimped wires are also an options The green PWM wire runs next to yellow 3.3V and orange GND wire until right before the end to eliminate issue with interference. Keeping the cable as short as possible helps to reduce interference. -### Cable for a BeagleBone Black (BBB) +#### Cable for a BeagleBone Black (BBB) (Support for the BBB is still in progress) -### Shielding and RF interference +#### Shielding and RF interference Given the nature of an FM transmitter, interference is potential problem. This interference commonly shows up as I2C errors which become more frequent as transmitter power increases. Moving the antenna away from the RPi/BBB and the transmitter board can reduce this. A significantly more robust setup it to locate the RPi/BBB and transmitter board inside a grounded, metal case such as was done by @chrkov here: ![Grounded case setup](images/pi_transmitter_setup1.jpg) ![Grounded case setup](images/pi_transmitter_setup2.jpg) -## Using Hardware PWM on Raspberry Pi +### Using Hardware PWM on Raspberry Pi The recommended QN8066 transmitter board can take a PWM signal to increase its power output. Be sure to comply with all applicable laws related to FM broadcasts. > [!CAUTION] From 4c3004eedbbcd85eca0b8c4bf2270c658740b9fe Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Mon, 1 Dec 2025 20:21:31 -0500 Subject: [PATCH 25/50] Lint fix --- Dynamic_RDS_Engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dynamic_RDS_Engine.py b/Dynamic_RDS_Engine.py index a8030b4..1cb670e 100755 --- a/Dynamic_RDS_Engine.py +++ b/Dynamic_RDS_Engine.py @@ -334,4 +334,4 @@ def excessive(msg, *args, **kwargs): if transmitter is None or not transmitter.active: logging.debug('Sleeping...') - sleep(3) + time.sleep(3) From 7f69feb947cc0663dce9b6d0fab3c816df6bf64b Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Mon, 1 Dec 2025 20:43:07 -0500 Subject: [PATCH 26/50] Logging spacing tweaks --- callbacks.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/callbacks.py b/callbacks.py index 1a6a4e4..37b5583 100755 --- a/callbacks.py +++ b/callbacks.py @@ -103,7 +103,7 @@ def logUnhandledException(eType, eValue, eTraceback): # If Engine was started AND the argument isn't --list, INIT must be sent to Engine before the requested argument if engineStarted and argv[1] != '--list': - logging.info('Engine restart detected, sending INIT') + logging.info(' Engine restart detected, sending INIT') fifo.write('INIT\n') if argv[1] == '--list': @@ -126,7 +126,7 @@ def logUnhandledException(eType, eValue, eTraceback): timeout = 5 startTime = time.monotonic() - logging.info('Waiting for Engine to shutdown (timeout: %ss)', timeout) + logging.info(' Waiting for Engine to shutdown (timeout: %ss)', timeout) # Poll the socket lock until it's released while time.monotonic() - startTime < timeout: @@ -137,7 +137,7 @@ def logUnhandledException(eType, eValue, eTraceback): lock_socket.close() # Successfully bound = Engine has shut down elapsed = time.monotonic() - startTime - logging.info('Engine shutdown after %.2fs', elapsed) + logging.info(' Engine shutdown after %.2fs', elapsed) sys.exit() except socket.error: # Lock still held by Engine, continue waiting @@ -190,7 +190,7 @@ def logUnhandledException(eType, eValue, eTraceback): playlist_action = j['Action'] if 'Action' in j else 'stop' - logging.info('Action %s', j['Action']) + logging.info(' Action %s', j['Action']) if playlist_action == 'start': # or playlist_action == 'playing': fifo.write('START\n') From 65c7fef039fa8b46a9a0be489229bf5882c0cf04 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Tue, 2 Dec 2025 22:12:04 -0500 Subject: [PATCH 27/50] Adjusted startup timing to fix cold boot issue --- Si4713.py | 4 +--- basicI2C.py | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Si4713.py b/Si4713.py index b65576b..23f032d 100644 --- a/Si4713.py +++ b/Si4713.py @@ -75,8 +75,6 @@ def startup(self): logging.info('Executing Reset with Pin %s', config['DynRDSSi4713GPIOReset']) with DigitalOutputDevice(int(config['DynRDSSi4713GPIOReset'])) as resetPin: - resetPin.on() - sleep(0.01) resetPin.off() sleep(0.01) resetPin.on() @@ -84,7 +82,7 @@ def startup(self): # Power up in transmit mode (Crystal oscillator and Analog audio input) self.I2C.write(self.CMD_POWER_UP, [0b00010010, 0b01010000], True) - sleep(0.11) # Wait for power up + sleep(0.5) # Wait for power up if not self._wait_for_cts(): logging.error('Si4713 failed to be read after power up') sys.exit(-1) diff --git a/basicI2C.py b/basicI2C.py index dd63635..23dc5f1 100644 --- a/basicI2C.py +++ b/basicI2C.py @@ -23,7 +23,6 @@ def __init__(self, address, bus=1): self.bus = smbus2.SMBus(bus) except Exception: logging.exception('SMBus Init Error') - #sleep(2) # TODO: Is this sleep still needed for the bus to init? def write(self, address, values, isFatal = False): # Simple i2c write - Always takes an list, even for 1 byte From be7ad1b538fa8efa2e1389b2d29fb7a7e72f008c Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Tue, 2 Dec 2025 22:22:25 -0500 Subject: [PATCH 28/50] Update default PS and RT style text --- config.py | 4 ++-- scripts/src_Dynamic_RDS_config.sh | 4 ++-- settings.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/config.py b/config.py index c44bde0..15df1c2 100644 --- a/config.py +++ b/config.py @@ -4,10 +4,10 @@ config = { 'DynRDSEnableRDS': '1', 'DynRDSPSUpdateRate': '4', -'DynRDSPSStyle': 'Merry|Christ-| -mas!|{T}|{A}|[{N} of {C}]', +'DynRDSPSStyle': '{T}|{A}[|{P} of {C}]|Merry|Christ-| -mas!', 'DynRDSRTUpdateRate': '8', 'DynRDSRTSize': '32', -'DynRDSRTStyle': 'Merry Christmas!|{T}[ by {A}]|[Track {N} of {C}]', +'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 75051fb..81f5a0e 100755 --- a/scripts/src_Dynamic_RDS_config.sh +++ b/scripts/src_Dynamic_RDS_config.sh @@ -4,10 +4,10 @@ ############################################################################### # Set the PS text (set to '' or comment out to leave unchanged) -PS='Merry|Christ-| -mas!|{T}|{A}|[{N} of {C}]' +PS='{T}|{A}[|{P} of {C}]|Merry|Christ-| -mas!' # Set the RT text (set to '' or comment out to leave unchanged) -RT='Merry Christmas! {T}[ by {A}]|[Track {N} of {C}]' +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 3e23942..5f97413 100644 --- a/settings.json +++ b/settings.json @@ -330,7 +330,7 @@ "type": "text", "size": 32, "maxlength": 64, - "default": "Merry|Christ-| -mas!|{T}|{A}|[{N} of {C}]" + "default": "{T}|{A}[|{P} of {C}]|Merry|Christ-| -mas!" }, "DynRDSPSUpdateRate": { "name": "DynRDSPSUpdateRate", @@ -354,7 +354,7 @@ "type": "text", "size": 64, "maxlength": 256, - "default": "Merry Christmas!|{T}[ by {A}]|[Track {N} of {C}]" + "default": "{T}[ by {A}][ - Track {P} of {C}]|Merry Christmas!" }, "DynRDSRTUpdateRate": { "name": "DynRDSRTUpdateRate", From d065e59aa4d7e0342b6d2c7c2ae87e4f3e312a3d Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sat, 27 Dec 2025 13:22:19 -0500 Subject: [PATCH 29/50] Sanitize shell commands that use directly --- api.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api.php b/api.php index 342eea2..bf4cf75 100644 --- a/api.php +++ b/api.php @@ -32,7 +32,7 @@ function DynRDSPiBootChange() { if (strcmp($myPluginSettings[$settingName],'1') == 0) { exec("sudo sed -i -e 's/^dtparam=audio=on/#dtparam=audio=on/' /boot/firmware/config.txt"); if (is_numeric(strpos($myPluginSettings['DynRDSAdvPIPWMPin'], ','))) { - exec("sudo sed -i -e '/^#dtparam=audio=on/a dtoverlay=pwm,pin=" . str_replace(",", ",func=", $myPluginSettings['DynRDSAdvPIPWMPin']) . "' /boot/firmware/config.txt"); + exec("sudo sed -i -e '/^#dtparam=audio=on/a dtoverlay=pwm,pin=" . escapeshellarg(str_replace(",", ",func=", $myPluginSettings['DynRDSAdvPIPWMPin'])) . "' /boot/firmware/config.txt"); } } else { exec("sudo sed -i -e '/^dtoverlay=pwm/d' /boot/firmware/config.txt"); @@ -43,7 +43,7 @@ function DynRDSPiBootChange() { case 'DynRDSAdvPIPWMPin': if (is_numeric(strpos($myPluginSettings['DynRDSAdvPIPWMPin'], ','))) { exec("sudo sed -i -e 's/^#dtoverlay=pwm/dtoverlay=pwm/' /boot/firmware/config.txt"); - exec("sudo sed -i -e '/^dtoverlay=pwm/c dtoverlay=pwm,pin=" . str_replace(",", ",func=", $myPluginSettings['DynRDSAdvPIPWMPin']) . "' /boot/firmware/config.txt"); + exec("sudo sed -i -e '/^dtoverlay=pwm/c dtoverlay=pwm,pin=" . escapeshellarg(str_replace(",", ",func=", $myPluginSettings['DynRDSAdvPIPWMPin'])) . "' /boot/firmware/config.txt"); } else { exec("sudo sed -i -e 's/^dtoverlay=pwm/#dtoverlay=pwm/' /boot/firmware/config.txt"); } From 326ebaf164148da3856712bbb27528872d278444 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sat, 27 Dec 2025 14:25:16 -0500 Subject: [PATCH 30/50] Move duplicate code to function --- callbacks.py | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/callbacks.py b/callbacks.py index 37b5583..6a8d3bc 100755 --- a/callbacks.py +++ b/callbacks.py @@ -16,6 +16,14 @@ def logUnhandledException(eType, eValue, eTraceback): logging.error('Unhandled exception', exc_info=(eType, eValue, eTraceback)) sys.excepthook = logUnhandledException +def check_engine_running(): + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock: + sock.bind('\0Dynamic_RDS_Engine') + return False # Not running + except socket.error: + return True # Running + if len(argv) <= 1: print('Usage:') print(' --list | Used by FPPD at startup. Starts Dynamic_RDS_Engine.py') @@ -55,13 +63,11 @@ def logUnhandledException(eType, eValue, eTraceback): updater_path = script_dir + '/Dynamic_RDS_Engine.py' engineStarted = False proc = None -try: - logging.debug('Checking for socket lock by %s', updater_path) - lock_socket = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) - lock_socket.bind('\0Dynamic_RDS_Engine') - lock_socket.close() +logging.debug('Checking for socket lock by %s', updater_path) +if check_engine_running(): + logging.debug('Lock found — %s is already running', updater_path) +else: logging.debug('Lock not found') - # Short circuit if Engine isn't running and command is to shut it down if argv[1] == '--exit' or (argv[1] == '--type' and argv[2] == 'lifecycle' and argv[3] == 'shutdown'): logging.info('Exit, but not running') @@ -77,9 +83,6 @@ def logUnhandledException(eType, eValue, eTraceback): except subprocess.TimeoutExpired: # Timeout means process is STILL RUNNING / success engineStarted = True -except socket.error: - logging.debug('Lock found — %s is already running', updater_path) - engineStarted = False # Always setup FIFO - Expects Engine to be running to open the read side of the FIFO fifo_path = script_dir + '/Dynamic_RDS_FIFO' @@ -130,19 +133,11 @@ def logUnhandledException(eType, eValue, eTraceback): # Poll the socket lock until it's released while time.monotonic() - startTime < timeout: - try: - # Try to acquire the lock - if successful, Engine has released it - lock_socket = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) - lock_socket.bind('\0Dynamic_RDS_Engine') - lock_socket.close() - # Successfully bound = Engine has shut down + if not check_engine_running(): elapsed = time.monotonic() - startTime logging.info(' Engine shutdown after %.2fs', elapsed) sys.exit() - except socket.error: - # Lock still held by Engine, continue waiting - time.sleep(0.05) # Sleep 50ms between attempts - continue + time.sleep(0.05) # Sleep 50ms between attempts logging.warning('Engine shutdown timeout after %ss', timeout) elif argv[1] == '--type' and argv[2] == 'media': From 4a2a01c9f5cc30b0a9079ac5653ef74a9c1fba64 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sat, 27 Dec 2025 15:03:16 -0500 Subject: [PATCH 31/50] Set empty JSON on load failure --- callbacks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/callbacks.py b/callbacks.py index 6a8d3bc..8e8368c 100755 --- a/callbacks.py +++ b/callbacks.py @@ -145,6 +145,7 @@ def check_engine_running(): j = json.loads(argv[4]) except Exception: logging.exception('Media JSON') + j = {} # When default values are sent over fifo, other side more or less ignores them media_type = j['type'] if 'type' in j else 'pause' From 20fabe258240268452bb2200bdbb59bef6bb9a59 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 28 Dec 2025 11:14:51 -0500 Subject: [PATCH 32/50] Ensure smbus2 is present. Flip transmitter missing error in front of engine not running. Adjust frequency range on load --- Dynamic_RDS.php | 17 +++++++++-------- scripts/fpp_install.sh | 3 +++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Dynamic_RDS.php b/Dynamic_RDS.php index b0ad58e..ef6bf33 100644 --- a/Dynamic_RDS.php +++ b/Dynamic_RDS.php @@ -282,7 +282,7 @@ function renderDynamicRDSStatus( // Check dependencies if (!DependencyChecker::isPython3SmbusInstalled()) { - $status->addError('python3-smbus is missing '); + $status->addError('python3-smbus2 is missing '); } // Detect I2C bus @@ -291,12 +291,6 @@ function renderDynamicRDSStatus( $status->addError('Unable to find an I2C bus - On RPi, check /boot/firmware/config.txt for I2C entry'); } - // Check engine status - $engineRunning = DependencyChecker::isEngineRunning(); - if (!$engineRunning) { - $status->addError('Dynamic RDS Engine is not running - Check logs for errors - Restart of FPPD is recommended'); - } - // Detect transmitter $transmitterType = TransmitterType::NONE; if ($i2cBus !== -1) { @@ -306,7 +300,7 @@ function renderDynamicRDSStatus( if ($transmitterType === TransmitterType::NONE) { $status->addError( - 'No transmitter detected on I2C bus ' . $i2cBus . + 'No transmitter detected on I2C bus ' . $i2cBus . ' at addresses 0x21 (QN8066) or 0x63 (Si4713)
      ' . 'Power cycle or reset of transmitter is recommended. ' . 'SSH into FPP and run i2cdetect -y -r ' . $i2cBus . ' to check I2C status', @@ -317,6 +311,12 @@ function renderDynamicRDSStatus( } } + // Check engine status + $engineRunning = DependencyChecker::isEngineRunning(); + if (!$engineRunning) { + $status->addError('Dynamic RDS Engine is not running - Check logs for errors - Restart of FPPD is recommended'); + } + if ($platform === PlatformType::RASPBERRY_PI) { checkRaspberryPiConfiguration($status, $pluginSettings); } @@ -453,6 +453,7 @@ function outputJavaScript(TransmitterType $transmitterType): void { transmitterSelect.value = value, JSON_HEX_TAG | JSON_HEX_AMP); ?>; transmitterSelect.onchange(); } + DynRDSTransmitterFrequencyUpdate(); }; function DynRDSTransmitterFrequencyUpdate() { diff --git a/scripts/fpp_install.sh b/scripts/fpp_install.sh index 9c62171..983bb51 100755 --- a/scripts/fpp_install.sh +++ b/scripts/fpp_install.sh @@ -3,6 +3,9 @@ echo "Copying, if missing, optional config script to FPP scripts directory..." cp -v -n ~/media/plugins/Dynamic_RDS/scripts/src_Dynamic_RDS_config.sh ~/media/scripts/Dynamic_RDS_config.sh +echo -e "\nInstalling python3-smbus2..." +sudo apt-get install -y python3-smbus2 + if test -f /boot/firmware/config.txt; then echo -e "\nInstalling python3-rpi-lgpio..." sudo apt-get install -y python3-rpi-lgpio From d5b5f0cf97517706526c4c6d9331069a4a8a5123 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 28 Dec 2025 11:41:42 -0500 Subject: [PATCH 33/50] Switch to gpiozero fully --- Dynamic_RDS.php | 9 ++++++ basicPWM.py | 59 ++++++++++++++++++++++++++++------------ callbacks.py | 7 ++--- scripts/fpp_install.sh | 4 +-- scripts/fpp_uninstall.sh | 4 --- 5 files changed, 55 insertions(+), 28 deletions(-) diff --git a/Dynamic_RDS.php b/Dynamic_RDS.php index ef6bf33..1bd6fe5 100644 --- a/Dynamic_RDS.php +++ b/Dynamic_RDS.php @@ -154,6 +154,11 @@ public static function isPython3SmbusInstalled(): bool { return !ShellCommandExecutor::isEmpty($output); } + public static function isPython3gpiozeroInstalled(): bool { + $output = ShellCommandExecutor::execute('dpkg -s python3-gpiozero | grep installed'); + return !ShellCommandExecutor::isEmpty($output); + } + public static function isEngineRunning(): bool { $output = ShellCommandExecutor::execute('ps -ef | grep python.*Dynamic_RDS_Engine.py | grep -v grep'); if (!ShellCommandExecutor::isEmpty($output)) @@ -285,6 +290,10 @@ function renderDynamicRDSStatus( $status->addError('python3-smbus2 is missing '); } + if ($platform === PlatformType::RASPBERRY_PI && !DependencyChecker::isPython3gpiozeroInstalled()) { + $status->addError('python3-gpiozero is missing '); + } + // Detect I2C bus $i2cBus = I2CBusDetector::detectBus($platform); if ($i2cBus === -1) { diff --git a/basicPWM.py b/basicPWM.py index 647d373..23e785a 100644 --- a/basicPWM.py +++ b/basicPWM.py @@ -59,35 +59,58 @@ def shutdown(self): class softwarePWM(basicPWM): def __init__(self, pinToUse=7): - logging.info('Initializing software PWM on pin %s', pinToUse) - global GPIO - from RPi import GPIO + logging.info('Initializing software PWM on GPIO pin %s (board pin %s)', self._board_to_bcm(pinToUse), pinToUse) + global PWMLED + from gpiozero import PWMLED + # Convert board pin to BCM GPIO number + bcm_pin = self._board_to_bcm(pinToUse) self.pinToUse = pinToUse + self.bcm_pin = bcm_pin self.pwm = None - # TODO: Ponder if import RPi.GPIO as GPIO is a good idea - # TODO: Look at switching to gpiozero - GPIO.setmode(GPIO.BOARD) - GPIO.setup(self.pinToUse, GPIO.OUT) - GPIO.output(self.pinToUse,0) + + # Create PWMLED device (starts at 0% duty cycle, off) + self.pwm = PWMLED(bcm_pin, initial_value=0) super().__init__() + def _board_to_bcm(self, board_pin): + """Convert board pin number to BCM GPIO number.""" + # Mapping for 40-pin Raspberry Pi header (board -> BCM) + board_to_bcm_map = { + 7: 4, 8: 14, 10: 15, 11: 17, 12: 18, 13: 27, + 15: 22, 16: 23, 18: 24, 19: 10, 21: 9, 22: 25, + 23: 11, 24: 8, 26: 7, 27: 0, 28: 1, 29: 5, + 31: 6, 32: 12, 33: 13, 35: 19, 36: 16, 37: 26, + 38: 20, 40: 21 + } + + if board_pin not in board_to_bcm_map: + raise ValueError(f'Invalid board pin number: {board_pin}') + + return board_to_bcm_map[board_pin] + def startup(self, period=10000, dutyCycle=0): - logging.debug('Starting software PWM on pin %s with period of %s', self.pinToUse, period) - self.pwm = GPIO.PWM(self.pinToUse, period) - logging.info('Updating software PWM on pin %s initial duty cycle to %s', self.pinToUse, round(dutyCycle/3,2)) - self.pwm.start(dutyCycle/3) + # gpiozero uses frequency in Hz, convert from period in microseconds + # frequency = 1 / (period / 1,000,000) + frequency = 1_000_000 / period + logging.debug('Starting software PWM on GPIO %s (board pin %s) with frequency %.2f Hz',self.bcm_pin, self.pinToUse, frequency) + self.pwm.frequency = frequency + initial_value = (dutyCycle / 3) / 100 + logging.info('Setting software PWM on GPIO %s initial duty cycle to %.2f%%', self.bcm_pin, dutyCycle / 3) + self.pwm.value = initial_value super().startup() def update(self, dutyCycle=0): - logging.info('Updating software PWM on pin %s duty cycle to %s', self.pinToUse, round(dutyCycle/3,2)) - self.pwm.ChangeDutyCycle(dutyCycle/3) + value = (dutyCycle / 3) / 100 + logging.info('Updating software PWM on GPIO %s duty cycle to %.2f%%', self.bcm_pin, dutyCycle / 3) + self.pwm.value = value super().update() def shutdown(self): - logging.debug('Shutting down software PWM on pin %s', self.pinToUse) - self.pwm.stop() - logging.info('Cleaning up software PWM on pin %s', self.pinToUse) - GPIO.cleanup() + logging.debug('Shutting down software PWM on GPIO %s (board pin %s)', self.bcm_pin, self.pinToUse) + self.pwm.off() + logging.info('Cleaning up software PWM on GPIO %s', self.bcm_pin) + # gpiozero handles cleanup automatically, but explicitly close + self.pwm.close() super().shutdown() class hardwareBBBPWM(basicPWM): diff --git a/callbacks.py b/callbacks.py index 8e8368c..c84244d 100755 --- a/callbacks.py +++ b/callbacks.py @@ -47,13 +47,12 @@ def check_engine_running(): logging.debug('---') logging.debug('Args %s', argv[1:]) -# RPi.GPIO is used for software PWM on the RPi, fail if it is missing -# TODO: Look at switching to gpiozero +# gpiozero is used for software PWM on the RPi, fail if it is missing if os.getenv('FPPPLATFORM', '') == 'Raspberry Pi' and config['DynRDSTransmitter'] == "QN8066": try: - import RPi.GPIO + import gpiozero except ImportError as impErr: - logging.error("Failed to import RPi.GPIO %s", impErr.args[0]) + logging.error("Failed to import gpiozero %s", impErr.args[0]) sys.exit(1) # Environ has a few useful items when FPPD runs callbacks.py, but logging it all the time, even at debug, is too much diff --git a/scripts/fpp_install.sh b/scripts/fpp_install.sh index 983bb51..8dec135 100755 --- a/scripts/fpp_install.sh +++ b/scripts/fpp_install.sh @@ -7,8 +7,8 @@ echo -e "\nInstalling python3-smbus2..." sudo apt-get install -y python3-smbus2 if test -f /boot/firmware/config.txt; then - echo -e "\nInstalling python3-rpi-lgpio..." - sudo apt-get install -y python3-rpi-lgpio + echo -e "\nInstalling python3-gpiozero..." + sudo apt-get install -y python3-gpiozero fi echo -e "\nRestarting FPP..." diff --git a/scripts/fpp_uninstall.sh b/scripts/fpp_uninstall.sh index 32d4464..3f9c9b5 100755 --- a/scripts/fpp_uninstall.sh +++ b/scripts/fpp_uninstall.sh @@ -10,7 +10,3 @@ else echo -e "\nLeaving modified optional config script" fi -if test -f /boot/firmware/config.txt; then - echo -e "\nYou can manually uninstall python3-rpi-lgpio if nothing else uses it." - echo "Command is: sudo apt-get remove -y python3-rpi-lgpio" -fi From 94ba2862160187c94423c47516ddcf8b09246175 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 28 Dec 2025 12:11:22 -0500 Subject: [PATCH 34/50] Set {P} to empty when it and {C} are both 1 to prevent Track 1 of 1 messages --- Dynamic_RDS.php | 3 ++- Dynamic_RDS_Engine.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Dynamic_RDS.php b/Dynamic_RDS.php index 1bd6fe5..178d595 100644 --- a/Dynamic_RDS.php +++ b/Dynamic_RDS.php @@ -563,7 +563,8 @@ function getRDSStyleGuideHTML(): string {
    • {L} = Track Length as 0:00
    Main Playlist Section Values
    • {C} = Item count in Main Playlist section
    • -
    • {P} = Item position or number in Main Playlist section
    +
  • {P} = Item position or number in Main Playlist section
  • +
    • Note: {P} is set empty when it and {C} are both 1 to prevent "Track 1 of 1" messages
    Any static text can be used
    | (pipe) will split between RDS groups, like a line break
    [ ] creates a subgroup such that if ANY substitution in the subgroup is empty, the entire subgroup is omitted
    diff --git a/Dynamic_RDS_Engine.py b/Dynamic_RDS_Engine.py index 1cb670e..5b40eaa 100755 --- a/Dynamic_RDS_Engine.py +++ b/Dynamic_RDS_Engine.py @@ -294,6 +294,8 @@ def excessive(msg, *args, **kwargs): elif line[0] == 'P': logging.debug('Processing playlist position') rdsValues['{P}'] = line[1:] + if rdsValues['{P}'] == '1' && rdsValues['{C}'] == '1': + rdsValues['{P}'] = '' pendingPlaylistUpdate = True lastUpdateTime = time.monotonic() From ce278ad0626134c70a8a32cc3f7455ba6effa456 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 28 Dec 2025 13:10:12 -0500 Subject: [PATCH 35/50] Fix && vs and in engine. Hide Audio Settings when Si4713 is selected --- Dynamic_RDS.php | 20 ++++++++++++++++++++ Dynamic_RDS_Engine.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Dynamic_RDS.php b/Dynamic_RDS.php index 178d595..079b7ee 100644 --- a/Dynamic_RDS.php +++ b/Dynamic_RDS.php @@ -463,8 +463,25 @@ function outputJavaScript(TransmitterType $transmitterType): void { transmitterSelect.onchange(); } DynRDSTransmitterFrequencyUpdate(); + + if (transmitterSelect) { + transmitterSelect.addEventListener('change', function() { + updateAudioSettingsVisibility(); + }); + // IMPORTANT: Run immediately on page load to set initial state + updateAudioSettingsVisibility(); + } }; +function updateAudioSettingsVisibility() { + var transmitterSelect = document.getElementById("DynRDSTransmitter"); + var audioWrapper = document.getElementById("DynRDSAudioSettingsWrapper"); + + if (audioWrapper && transmitterSelect) { + audioWrapper.style.display = (transmitterSelect.value === 'Si4713') ? 'none' : ''; + } +} + function DynRDSTransmitterFrequencyUpdate() { var iconHTML = " "; var transmitterSelect = document.getElementById("DynRDSTransmitter"); @@ -519,9 +536,12 @@ function displaySettingsGroups(array $settings): void { PrintSettingGroup("DynRDSTransmitterSettings", "", "", 1, "Dynamic_RDS", "DynRDSTransmitterFrequencyUpdate"); + // Wrap Audio in div id to make it easy to hide/show for Si4713 + echo '
    '; PrintSettingGroup("DynRDSAudioSettings", "", "indicates a live change to transmitter, no FPP restart required", 1, "Dynamic_RDS", "DynRDSFastUpdate"); + echo '
    '; PrintSettingGroup("DynRDSPowerSettings", "", "", 1, "Dynamic_RDS", "DynRDSPiBootUpdate"); diff --git a/Dynamic_RDS_Engine.py b/Dynamic_RDS_Engine.py index 5b40eaa..de414c6 100755 --- a/Dynamic_RDS_Engine.py +++ b/Dynamic_RDS_Engine.py @@ -294,7 +294,7 @@ def excessive(msg, *args, **kwargs): elif line[0] == 'P': logging.debug('Processing playlist position') rdsValues['{P}'] = line[1:] - if rdsValues['{P}'] == '1' && rdsValues['{C}'] == '1': + if rdsValues['{P}'] == '1' and rdsValues['{C}'] == '1': rdsValues['{P}'] = '' pendingPlaylistUpdate = True lastUpdateTime = time.monotonic() From b05591abab61bb31b3451e4c683293188f1f0a27 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 28 Dec 2025 13:36:16 -0500 Subject: [PATCH 36/50] Hide RDS timing settings for Si4713. Update readme with reason --- README.md | 3 +++ settings.json | 2 ++ 2 files changed, 5 insertions(+) diff --git a/README.md b/README.md index f25147b..634a431 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ Originally, the Si4713 breakout board was available from [AdaFruit](https://www. ![Si4713 Breakout Board](images/Si4713-transmitter.jpg) +> [!NOTE] +> To reduce system load, the on-board buffers of the Si4713 are used by this plugin. The trade off is not being able to directly control the timing of each RDS message. + ## QN8066 transmitter board > [!IMPORTANT] > There are other similar looking boards, so double check for the QN8066 chip. For a detailed look at identifying QN8066 boards, check out [Spectraman's video](https://www.youtube.com/watch?v=i8re0nc_FdY&t=1017s). diff --git a/settings.json b/settings.json index 5f97413..f200633 100644 --- a/settings.json +++ b/settings.json @@ -92,6 +92,8 @@ "default": "None", "children": { "QN8066": [ + "DynRDSPSUpdateRate", + "DynRDSRTUpdateRate", "DynRDSQN8066Gain", "DynRDSQN8066SoftClipping", "DynRDSQN8066AGC", From bec72b79a72490841886a34edfd239c62e514e7a Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 28 Dec 2025 13:36:58 -0500 Subject: [PATCH 37/50] Readme tweak --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 634a431..ec5f377 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ Created for Falcon Player 6.0+ (FPP) as a plugin to generate RDS (radio data sys ## 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) -![Si4713 Breakout Board](images/Si4713-transmitter.jpg) - > [!NOTE] > To reduce system load, the on-board buffers of the Si4713 are used by this plugin. The trade off is not being able to directly control the timing of each RDS message. +![Si4713 Breakout Board](images/Si4713-transmitter.jpg) + ## QN8066 transmitter board > [!IMPORTANT] > There are other similar looking boards, so double check for the QN8066 chip. For a detailed look at identifying QN8066 boards, check out [Spectraman's video](https://www.youtube.com/watch?v=i8re0nc_FdY&t=1017s). From 7f0cc655ed5d4263b7c965dd9586f30ebce17fad Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 28 Dec 2025 14:11:16 -0500 Subject: [PATCH 38/50] Si4713 enable/disable RDS handled. Added callback when Enable RDS toggled --- Dynamic_RDS.php | 5 ++--- Si4713.py | 7 +++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Dynamic_RDS.php b/Dynamic_RDS.php index 079b7ee..6d479eb 100644 --- a/Dynamic_RDS.php +++ b/Dynamic_RDS.php @@ -468,7 +468,6 @@ function outputJavaScript(TransmitterType $transmitterType): void { transmitterSelect.addEventListener('change', function() { updateAudioSettingsVisibility(); }); - // IMPORTANT: Run immediately on page load to set initial state updateAudioSettingsVisibility(); } }; @@ -476,7 +475,7 @@ function outputJavaScript(TransmitterType $transmitterType): void { function updateAudioSettingsVisibility() { var transmitterSelect = document.getElementById("DynRDSTransmitter"); var audioWrapper = document.getElementById("DynRDSAudioSettingsWrapper"); - + if (audioWrapper && transmitterSelect) { audioWrapper.style.display = (transmitterSelect.value === 'Si4713') ? 'none' : ''; } @@ -532,7 +531,7 @@ function ScriptStreamProgressDialogDone() { * Display all settings groups */ function displaySettingsGroups(array $settings): void { - PrintSettingGroup("DynRDSRDSSettings", getRDSStyleGuideHTML(), "", 1, "Dynamic_RDS"); + PrintSettingGroup("DynRDSRDSSettings", getRDSStyleGuideHTML(), "", 1, "Dynamic_RDS", "UpdateDynRDSTransmitterChildren"); PrintSettingGroup("DynRDSTransmitterSettings", "", "", 1, "Dynamic_RDS", "DynRDSTransmitterFrequencyUpdate"); diff --git a/Si4713.py b/Si4713.py index 23f032d..139d587 100644 --- a/Si4713.py +++ b/Si4713.py @@ -104,8 +104,11 @@ def startup(self): rdsBuffData[5], rdsBuffData[4] + rdsBuffData[5]) self.totalCircularBuffers = rdsBuffData[2] + rdsBuffData[3] - # Enable stereo, pilot, and RDS - self._set_property(self.PROP_TX_COMPONENT_ENABLE, 0x0007) + # Enable pilot, stereo, and RDS (if enabled) + if config['DynRDSEnableRDS'] == "1": + self._set_property(self.PROP_TX_COMPONENT_ENABLE, 0x0007) + else: + self._set_property(self.PROP_TX_COMPONENT_ENABLE, 0x0003) # Set pre-emphasis if config['DynRDSPreemphasis'] == "50us": From 0033267fab02fd8d1a09bf0f5343847176ea16c4 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 28 Dec 2025 14:16:05 -0500 Subject: [PATCH 39/50] Added restart to Si4713 chip power change --- settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.json b/settings.json index f200633..4279dcb 100644 --- a/settings.json +++ b/settings.json @@ -188,7 +188,7 @@ "description": "Chip Power (88-120)", "tip": "Adjust the power output from the transmitter chip. Voltage accuracy above 115dBμV is not guaranteed.", "suffix": "dBμV", - "restart": 0, + "restart": 1, "reboot": 0, "type": "number", "min": 88, From d6d6addcb145ad3849c49398d94cf02cf0214974 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 28 Dec 2025 16:51:16 -0500 Subject: [PATCH 40/50] Various pylint cleanup and changes --- .pylintrc | 8 +++++++- Dynamic_RDS_Engine.py | 2 +- basicMQTT.py | 2 +- basicPWM.py | 2 +- callbacks.py | 7 +++++-- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.pylintrc b/.pylintrc index cce80d2..55511d6 100644 --- a/.pylintrc +++ b/.pylintrc @@ -3,4 +3,10 @@ indent-string=' ' max-line-length = 250 [MESSAGES CONTROL] -disable = missing-docstring, invalid-name, fixme, bare-except, broad-exception-caught, import-error +# disable = missing-docstring, invalid-name, fixme, bare-except, broad-exception-caught, import-error +disable = import-outside-toplevel, global-statement + +[DESIGN] +max-branches=15 +max-boolean-expressions=6 +max-attributes=10 diff --git a/Dynamic_RDS_Engine.py b/Dynamic_RDS_Engine.py index de414c6..b48c22e 100755 --- a/Dynamic_RDS_Engine.py +++ b/Dynamic_RDS_Engine.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 import logging import json diff --git a/basicMQTT.py b/basicMQTT.py index 63daa50..0b993d5 100644 --- a/basicMQTT.py +++ b/basicMQTT.py @@ -79,7 +79,7 @@ def disconnect(self): def status(self): pass - def on_connect(self, client, userdata, flags, rc): + def on_connect(self, _client, _userdata, _flags, _rc): logging.info('Connected to broker with pahoMQTT') # TODO: Deal with rc for failures super().connect() diff --git a/basicPWM.py b/basicPWM.py index 23e785a..773959e 100644 --- a/basicPWM.py +++ b/basicPWM.py @@ -5,7 +5,7 @@ class basicPWM: def __init__(self): self.active = False - def startup(self, period=10000, dutyCycle=0): + def startup(self, _period=10000, _dutyCycle=0): self.active = True def update(self, dutyCycle=0): diff --git a/callbacks.py b/callbacks.py index c84244d..b8b938f 100755 --- a/callbacks.py +++ b/callbacks.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 import logging import json @@ -73,7 +73,10 @@ def check_engine_running(): sys.exit() logging.info('Starting %s', updater_path) with open(os.devnull, 'w', encoding='UTF-8') as devnull: - proc = subprocess.Popen(['python3', updater_path], stdin=devnull, stdout=devnull, stderr=subprocess.PIPE, text=True, close_fds=True) + # Start Engine process in background - intentionally not using 'with' + # statement as we need the process to continue running after this script exits + proc = subprocess.Popen( # pylint: disable=consider-using-with + ['python3', updater_path], stdin=devnull, stdout=devnull, stderr=subprocess.PIPE, text=True, close_fds=True) try: # Wait up to 1 second to see if the process exits proc.wait(timeout=1) From 4c810d706a7b686fb7d6e8b0bf0aaf0e0204841b Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 28 Dec 2025 16:55:07 -0500 Subject: [PATCH 41/50] Readjust pylint settings --- .pylintrc | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.pylintrc b/.pylintrc index 55511d6..0af4339 100644 --- a/.pylintrc +++ b/.pylintrc @@ -3,10 +3,9 @@ indent-string=' ' max-line-length = 250 [MESSAGES CONTROL] -# disable = missing-docstring, invalid-name, fixme, bare-except, broad-exception-caught, import-error -disable = import-outside-toplevel, global-statement +disable = import-outside-toplevel, global-statement, missing-docstring, invalid-name, fixme, bare-except, broad-exception-caught [DESIGN] max-branches=15 -max-boolean-expressions=6 +max-bool-expr=6 max-attributes=10 From 1748cc5e948384feeb0bc6387126d8554a700d7c Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 28 Dec 2025 17:03:48 -0500 Subject: [PATCH 42/50] More tweaks for pylint --- .pylintrc | 2 +- basicMQTT.py | 2 +- callbacks.py | 12 +++--------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.pylintrc b/.pylintrc index 0af4339..66d4ef6 100644 --- a/.pylintrc +++ b/.pylintrc @@ -3,7 +3,7 @@ indent-string=' ' max-line-length = 250 [MESSAGES CONTROL] -disable = import-outside-toplevel, global-statement, missing-docstring, invalid-name, fixme, bare-except, broad-exception-caught +disable = import-outside-toplevel, global-statement, missing-docstring, invalid-name, fixme, bare-except, broad-exception-caught, import-error [DESIGN] max-branches=15 diff --git a/basicMQTT.py b/basicMQTT.py index 0b993d5..d3833f8 100644 --- a/basicMQTT.py +++ b/basicMQTT.py @@ -37,7 +37,7 @@ def __init__(self): if self.MQTTSettings['MQTTHost'] == '': logging.warning('MQTT Broker Host is not set. Check FPP Settings -> MQTT -> Broker Host value') - raise Exception('Missing MQTT Host') + raise Exception('Missing MQTT Host') # pylint: disable=broad-exception-raised for setting in mqttInfo['children']['*']: settingInfo = self.readAPISetting(setting) diff --git a/callbacks.py b/callbacks.py index b8b938f..bad84e8 100755 --- a/callbacks.py +++ b/callbacks.py @@ -47,14 +47,6 @@ def check_engine_running(): logging.debug('---') logging.debug('Args %s', argv[1:]) -# gpiozero is used for software PWM on the RPi, fail if it is missing -if os.getenv('FPPPLATFORM', '') == 'Raspberry Pi' and config['DynRDSTransmitter'] == "QN8066": - try: - import gpiozero - except ImportError as impErr: - logging.error("Failed to import gpiozero %s", impErr.args[0]) - sys.exit(1) - # Environ has a few useful items when FPPD runs callbacks.py, but logging it all the time, even at debug, is too much #logging.debug('Environ %s', os.environ) @@ -73,7 +65,7 @@ def check_engine_running(): sys.exit() logging.info('Starting %s', updater_path) with open(os.devnull, 'w', encoding='UTF-8') as devnull: - # Start Engine process in background - intentionally not using 'with' + # Start Engine process in background - intentionally not using 'with' # statement as we need the process to continue running after this script exits proc = subprocess.Popen( # pylint: disable=consider-using-with ['python3', updater_path], stdin=devnull, stdout=devnull, stderr=subprocess.PIPE, text=True, close_fds=True) @@ -88,6 +80,7 @@ def check_engine_running(): # Always setup FIFO - Expects Engine to be running to open the read side of the FIFO fifo_path = script_dir + '/Dynamic_RDS_FIFO' +# pylint: disable=duplicate-code try: logging.debug('Creating fifo %s', fifo_path) os.mkfifo(fifo_path) @@ -95,6 +88,7 @@ def check_engine_running(): if oe.errno != errno.EEXIST: raise logging.debug('Fifo already exists') +# pylint: enable=duplicate-code if proc is not None and proc.poll() is not None: logging.error('%s failed to stay running - %s', updater_path, proc.stderr.read()) From 0aa8e58c585e8627d54135072c1e42ed38201b4d Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Sun, 28 Dec 2025 17:38:08 -0500 Subject: [PATCH 43/50] Adjust default PS and RT style text. Tweaks some tooltips --- config.py | 4 ++-- settings.json | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/config.py b/config.py index 15df1c2..d76f672 100644 --- a/config.py +++ b/config.py @@ -4,10 +4,10 @@ config = { 'DynRDSEnableRDS': '1', 'DynRDSPSUpdateRate': '4', -'DynRDSPSStyle': '{T}|{A}[|{P} of {C}]|Merry|Christ-| -mas!', +'DynRDSPSStyle': '{T}|{A}[|{P} of {C}]|Merry|Christ-| -mas!', 'DynRDSRTUpdateRate': '8', '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/settings.json b/settings.json index 4279dcb..e1604ba 100644 --- a/settings.json +++ b/settings.json @@ -332,7 +332,7 @@ "type": "text", "size": 32, "maxlength": 64, - "default": "{T}|{A}[|{P} of {C}]|Merry|Christ-| -mas!" + "default": "{T}|{A}[|{P} of {C}]|Merry|Christ-| -mas!" }, "DynRDSPSUpdateRate": { "name": "DynRDSPSUpdateRate", @@ -350,18 +350,18 @@ "DynRDSRTStyle": { "name": "DynRDSRTStyle", "description": "RT Style Text", - "tip": "Sent up to 64 characters at a time. Radio Text is intended for longer message with a slower update rate.", + "tip": "Recommended to send 32 characters at a time, but can send up to 64 characters. Radio Text is intended for longer message with a slower update rate.", "restart": 1, "reboot": 0, "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", "description": "RT Update Rate", - "tip": "Interval between updating the 64 characters being sent. It takes ~4 seconds to send the 64 characters and some radios only display the text after receiving the full group twice.", + "tip": "Interval between updating the 32-64 characters being sent. It takes ~4 seconds to send the 64 characters and some radios only display the text after receiving the full group twice.", "suffix": "seconds", "restart": 1, "reboot": 0, @@ -374,7 +374,7 @@ "DynRDSRTSize": { "name": "DynRDSRTSize", "description": "RT Update Size", - "tip": "While RadioText (RT) can be up to 64 characters at a time, not all radios will display everything at the same time. A smaller setting is recommended.", + "tip": "While RadioText (RT) can be up to 64 characters at a time, not all radios will display everything at the same time. A setting of 32 characters is recommended.", "restart": 1, "reboot": 0, "type": "number", From 9acf90ed48226696e7c377dfb5032a637fdf9c07 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Wed, 31 Dec 2025 13:29:28 -0500 Subject: [PATCH 44/50] In script, update spacing in PS and RT text variables --- scripts/src_Dynamic_RDS_config.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/src_Dynamic_RDS_config.sh b/scripts/src_Dynamic_RDS_config.sh index 81f5a0e..112a0a5 100755 --- a/scripts/src_Dynamic_RDS_config.sh +++ b/scripts/src_Dynamic_RDS_config.sh @@ -4,10 +4,10 @@ ############################################################################### # Set the PS text (set to '' or comment out to leave unchanged) -PS='{T}|{A}[|{P} of {C}]|Merry|Christ-| -mas!' +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 From dd8f7035f94210530989150eee8c31282f5ec034 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Thu, 1 Jan 2026 16:34:07 -0500 Subject: [PATCH 45/50] Additional work to fully fix PWM on Raspberry Pi 5. See Issue #54 --- QN8066.py | 24 +++-------------- basicI2C.py | 4 +-- basicPWM.py | 76 +++++++++++++++++++++++++++++++++++++++++++++-------- config.py | 2 +- 4 files changed, 71 insertions(+), 35 deletions(-) diff --git a/QN8066.py b/QN8066.py index 0ce763f..745addf 100644 --- a/QN8066.py +++ b/QN8066.py @@ -6,7 +6,7 @@ from config import config from basicI2C import basicI2C -from basicPWM import basicPWM, hardwarePWM, softwarePWM, hardwareBBBPWM +from basicPWM import createPWM, basicPWM, hardwarePWM, softwarePWM, hardwareBBBPWM from Transmitter import Transmitter class QN8066(Transmitter): @@ -16,7 +16,7 @@ def __init__(self): self.I2C = basicI2C(0x21) self.PS = self.PSBuffer(self, ' ', int(config['DynRDSPSUpdateRate'])) self.RT = self.RTBuffer(self, ' ', int(config['DynRDSRTUpdateRate'])) - self.basicPWM = basicPWM() + self.basicPWM = createPWM() def startup(self): logging.info('Starting QN8066 transmitter') @@ -61,25 +61,7 @@ def startup(self): self.update() super().startup() - # With everything started up, select and enable needed PWM type - if os.getenv('FPPPLATFORM', '') == 'Raspberry Pi': - if config['DynRDSQN8066PIPWM'] == '1': - if config['DynRDSAdvPIPWMPin'] in {'18,2' , '12,4'}: - self.basicPWM = hardwarePWM(0) - self.basicPWM.startup(18300, int(config['DynRDSQN8066AmpPower'])) - elif config['DynRDSAdvPIPWMPin'] in {'13,4' , '19,2'}: - self.basicPWM = hardwarePWM(1) - self.basicPWM.startup(18300, int(config['DynRDSQN8066AmpPower'])) - else: - self.basicPWM = softwarePWM(int(config['DynRDSAdvPIPWMPin'])) - self.basicPWM.startup(10000, int(config['DynRDSQN8066AmpPower'])) - #else: - #self.basicPWM.startup() - elif os.getenv('FPPPLATFORM', '') == 'BeagleBone Black': - self.basicPWM = hardwareBBBPWM(config['DynRDSAdvBBBPWMPin']) - self.basicPWM.startup(18300, int(config['DynRDSQN8066AmpPower'])) - #else: - #self.basicPWM.startup() + self.basicPWM.startup(dutyCycle=int(config['DynRDSQN8066AmpPower'])) def update(self): # Try without 0x25 0b01111101 - TX Freq Dev of 86.25KHz diff --git a/basicI2C.py b/basicI2C.py index 23dc5f1..7cd54a1 100644 --- a/basicI2C.py +++ b/basicI2C.py @@ -1,8 +1,8 @@ import logging import os +import smbus2 import sys from time import sleep -import smbus2 # =============== # Basic I2C Class @@ -22,7 +22,7 @@ def __init__(self, address, bus=1): try: self.bus = smbus2.SMBus(bus) except Exception: - logging.exception('SMBus Init Error') + logging.exception('SMBus2 Init Error') def write(self, address, values, isFatal = False): # Simple i2c write - Always takes an list, even for 1 byte diff --git a/basicPWM.py b/basicPWM.py index 773959e..5a422d6 100644 --- a/basicPWM.py +++ b/basicPWM.py @@ -1,5 +1,36 @@ -import os +from config import config import logging +import os +import re +import subprocess +import sys +from typing import Optional + +PWM_FULL_RE = re.compile( + r"(PWM\d+)(?:_CHAN(\d+)|_(\d+))", + re.IGNORECASE +) + +def createPWM() -> 'basicPWM': + # Check if PWM is enabled + if config['DynRDSQN8066PIPWM'] != '1': + return basicPWM() + + platform = os.getenv('FPPPLATFORM', '') + match platform: + case 'Raspberry Pi': + if ',' in config['DynRDSAdvPIPWMPin']: + logging.info('Using hardware PWM config: %s', config['DynRDSAdvPIPWMPin']) + return hardwarePWM(int(config['DynRDSAdvPIPWMPin'].split(',', 1)[0])) + else: + logging.info('Using software PWM pin: %s', config['DynRDSAdvPIPWMPin']) + return softwarePWM(int(config['DynRDSAdvPIPWMPin'])) + case 'BeagleBone Black': + logging.info('Using BBB hardware PWM config: %s', config['DynRDSAdvBBBPWMPin']) + return hardwareBBBPWM(config['DynRDSAdvBBBPWMPin']) + case _: + logging.warning('Unknown platform: %s, PWM disabled', platform) + return basicPWM() class basicPWM: def __init__(self): @@ -19,32 +50,55 @@ def status(self): pass class hardwarePWM(basicPWM): - def __init__(self, pwmToUse=0): - self.pwmToUse = pwmToUse + def __init__(self, pwmGPIOPin=18): + pwmInfo = self._getPWMInfoFromPinctrl(pwmGPIOPin) + if pwmInfo is None: + logging.error('Unable to determine PWM channel for GPIO%s', pwmGPIOPin) + sys.exit(-1) + + self.pwmToUse = pwmInfo if os.path.isdir('/sys/class/pwm/pwmchip0') and os.access('/sys/class/pwm/pwmchip0/export', os.W_OK): - logging.info('Initializing hardware PWM%s', self.pwmToUse) + logging.info('Initializing hardware PWM channel %s on GPIO%s', self.pwmToUse, pwmGPIOPin) else: - raise RuntimeError('Unable to access /sys/class/pwm/pwmchip0') + raise RuntimeError('Unable to access /sys/class/pwm/pwmchip0 or export') if not os.path.isdir(f'/sys/class/pwm/pwmchip0/pwm{self.pwmToUse}'): - logging.debug('Exporting hardware PWM%s', pwmToUse) + logging.debug('Exporting hardware PWM channel %s', self.pwmToUse) with open('/sys/class/pwm/pwmchip0/export', 'w', encoding='UTF-8') as p: - p.write(f'{pwmToUse}\n') + p.write(f'{self.pwmToUse}\n') super().__init__() + def _getPWMInfoFromPinctrl(eslf, gpioPin=18): + try: + result = subprocess.run( + ["pinctrl", "get", str(gpioPin)], + capture_output=True, + text=True, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return None + + m = PWM_FULL_RE.search(result.stdout) + if not m: + return None + + return m.group(2) or m.group(3) + # "pwm": m.group(1).lower() + def startup(self, period=18300, dutyCycle=0): - logging.debug('Starting hardware PWM%s with period of %s', self.pwmToUse, period) + logging.debug('Starting hardware PWM channel %s with period of %s', self.pwmToUse, period) with open(f'/sys/class/pwm/pwmchip0/pwm{self.pwmToUse}/period', 'w', encoding='UTF-8') as p: p.write(f'{period}\n') self.update(dutyCycle) - logging.info('Enabling hardware PWM%s', self.pwmToUse) + logging.info('Enabling hardware PWM channel %s', self.pwmToUse) with open(f'/sys/class/pwm/pwmchip0/pwm{self.pwmToUse}/enable', 'w', encoding='UTF-8') as p: p.write('1\n') super().startup() def update(self, dutyCycle=0): - logging.info('Updating hardware PWM%s duty cycle to %s', self.pwmToUse, dutyCycle*61) + logging.info('Updating hardware PWM channel %s duty cycle to %s', self.pwmToUse, dutyCycle*61) with open(f'/sys/class/pwm/pwmchip0/pwm{self.pwmToUse}/duty_cycle', 'w', encoding='UTF-8') as p: p.write(f'{dutyCycle*61}\n') super().update() @@ -52,7 +106,7 @@ def update(self, dutyCycle=0): def shutdown(self): logging.debug('Shutting down hardware PWM%s', self.pwmToUse) self.update() #Duty Cycle to 0 - logging.info('Disabling hardware PWM%s', self.pwmToUse) + logging.info('Disabling hardware PWM channel %s', self.pwmToUse) with open(f'/sys/class/pwm/pwmchip0/pwm{self.pwmToUse}/enable', 'w', encoding='UTF-8') as p: p.write('0\n') super().shutdown() diff --git a/config.py b/config.py index d76f672..68fb7ae 100644 --- a/config.py +++ b/config.py @@ -18,7 +18,7 @@ 'DynRDSQN8066SoftClipping': '0', 'DynRDSQN8066AGC': '0', 'DynRDSQN8066ChipPower': '122', -'DynRDSQN8066PIPWM': 0, +'DynRDSQN8066PIPWM': '0', 'DynRDSQN8066AmpPower': '0', 'DynRDSStart': 'FPPDStart', From 3376bf613877041ffae1151a1b94eedb18c69d38 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Thu, 1 Jan 2026 16:39:20 -0500 Subject: [PATCH 46/50] Lint cleanup --- QN8066.py | 3 +-- basicI2C.py | 3 ++- basicPWM.py | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/QN8066.py b/QN8066.py index 745addf..910ce7b 100644 --- a/QN8066.py +++ b/QN8066.py @@ -1,12 +1,11 @@ import logging import sys -import os from time import sleep from datetime import datetime from config import config from basicI2C import basicI2C -from basicPWM import createPWM, basicPWM, hardwarePWM, softwarePWM, hardwareBBBPWM +from basicPWM import createPWM from Transmitter import Transmitter class QN8066(Transmitter): diff --git a/basicI2C.py b/basicI2C.py index 7cd54a1..9ed1898 100644 --- a/basicI2C.py +++ b/basicI2C.py @@ -1,9 +1,10 @@ import logging import os -import smbus2 import sys from time import sleep +import smbus2 + # =============== # Basic I2C Class # =============== diff --git a/basicPWM.py b/basicPWM.py index 5a422d6..f0774ae 100644 --- a/basicPWM.py +++ b/basicPWM.py @@ -1,10 +1,11 @@ -from config import config import logging import os import re import subprocess import sys -from typing import Optional + +from config import config + PWM_FULL_RE = re.compile( r"(PWM\d+)(?:_CHAN(\d+)|_(\d+))", @@ -22,12 +23,11 @@ def createPWM() -> 'basicPWM': if ',' in config['DynRDSAdvPIPWMPin']: logging.info('Using hardware PWM config: %s', config['DynRDSAdvPIPWMPin']) return hardwarePWM(int(config['DynRDSAdvPIPWMPin'].split(',', 1)[0])) - else: - logging.info('Using software PWM pin: %s', config['DynRDSAdvPIPWMPin']) - return softwarePWM(int(config['DynRDSAdvPIPWMPin'])) + logging.info('Using software PWM pin: %s', config['DynRDSAdvPIPWMPin']) + return softwarePWM(int(config['DynRDSAdvPIPWMPin'])) case 'BeagleBone Black': - logging.info('Using BBB hardware PWM config: %s', config['DynRDSAdvBBBPWMPin']) - return hardwareBBBPWM(config['DynRDSAdvBBBPWMPin']) + logging.info('Using BBB hardware PWM config: %s', config['DynRDSAdvBBBPWMPin']) + return hardwareBBBPWM(config['DynRDSAdvBBBPWMPin']) case _: logging.warning('Unknown platform: %s, PWM disabled', platform) return basicPWM() @@ -69,7 +69,7 @@ def __init__(self, pwmGPIOPin=18): super().__init__() - def _getPWMInfoFromPinctrl(eslf, gpioPin=18): + def _getPWMInfoFromPinctrl(self, gpioPin=18): try: result = subprocess.run( ["pinctrl", "get", str(gpioPin)], From c2b93cc0e111ae44f1e84c34435584b3c2e31073 Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Thu, 1 Jan 2026 16:42:20 -0500 Subject: [PATCH 47/50] Updating dutyCycle since it is now used --- basicPWM.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basicPWM.py b/basicPWM.py index f0774ae..b149dea 100644 --- a/basicPWM.py +++ b/basicPWM.py @@ -36,7 +36,7 @@ class basicPWM: def __init__(self): self.active = False - def startup(self, _period=10000, _dutyCycle=0): + def startup(self, _period=10000, dutyCycle=0): self.active = True def update(self, dutyCycle=0): From a79c43aaf045227e1e5ca836d456680e65de0d0d Mon Sep 17 00:00:00 2001 From: ShadowLight8 Date: Thu, 1 Jan 2026 16:44:24 -0500 Subject: [PATCH 48/50] Pylint tweak --- basicPWM.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basicPWM.py b/basicPWM.py index b149dea..f725e88 100644 --- a/basicPWM.py +++ b/basicPWM.py @@ -36,7 +36,7 @@ class basicPWM: def __init__(self): self.active = False - def startup(self, _period=10000, dutyCycle=0): + def startup(self, _period=10000, dutyCycle=0): # pylint: disable=unused-argument self.active = True def update(self, dutyCycle=0): From b84ac6e5f1d7d0a0b69f57219001971ad97dee15 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Thu, 1 Jan 2026 20:33:28 -0500 Subject: [PATCH 49/50] Fix config.txt path in Raspberry Pi PWM instructions Updated the path for the configuration file in the README. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ec5f377..0eb498d 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ On the Raspberry Pi, in order to use the hardware PWM, the built-in analog audio From the Dynamic RDS configuration page, under the Power Settings, enable PWM. -This will automatically modify the /boot/config.txt: +This will automatically modify the /boot/firmware/config.txt: 1. Comment out all ```dtparm=audio=on``` lines with a # 2. Add the line ```dtoverlay=pwm,pin=18,func=2``` by default Under the Advanced Options at the bottom of the configuration page, the output pin can be selected. This is also where Software PWM can be selected on most other pins. From ad9d9f7dae18529e9d97edbc7e29db83e13cb55e Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Thu, 1 Jan 2026 20:51:26 -0500 Subject: [PATCH 50/50] Refine README for Dynamic_RDS plugin details Updated README.md for clarity and consistency in terminology, including changes to plugin name formatting and additional details on transmitter functionality. --- README.md | 39 +++++++++++++++------------------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 0eb498d..8cf4416 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # Dynamic_RDS - FM Transmitter Plugin for Falcon Player > [!NOTE] -> Supports **QN8066** and **Si4713** FM transmitter chips +> Dynamic_RDS supports the **QN8066** and **Si4713** FM transmitter chips -Created for Falcon Player 6.0+ (FPP) as a plugin to 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 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. ## 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) > [!NOTE] -> To reduce system load, the on-board buffers of the Si4713 are used by this plugin. The trade off is not being able to directly control the timing of each RDS message. +> To reduce system load, the on-board buffers of the Si4713 are used by this plugin. The trade off is not being able to directly control the timing of each RDS message and a limitation of total message length based on how many buffers are available. ![Si4713 Breakout Board](images/Si4713-transmitter.jpg) @@ -24,7 +24,7 @@ Originally, the Si4713 breakout board was available from [AdaFruit](https://www. ![Radio Board Pinout](images/radio_board_pinout.jpeg) ### Antenna -The QN8066 transmitter board needs an antenna for safe operations. +The QN8066 transmitter board requires an antenna for safe operations. Below are some examples of antennas. * Small bench testing option - https://www.amazon.com/gp/product/B07K7DBVX9 * 1/4 wave ground plane antenna calculator - https://m0ukd.com/calculators/quarter-wave-ground-plane-antenna-calculator/ @@ -32,8 +32,6 @@ The QN8066 transmitter board needs an antenna for safe operations. * Inexpensive 1/4 wave option - https://www.aliexpress.us/item/2251832695723994.html * BNC to BNC cable - https://www.amazon.com/gp/product/B0BVVVRYZL/) -(More detail to be added) - ### Cables, Connectors, and Shielding > [!CAUTION] > Do not run the PWM wire along side the I2C wires. During testing this caused failures in the I2C commands as soon as PWM was enabled. @@ -41,7 +39,7 @@ The QN8066 transmitter board needs an antenna for safe operations. #### Connector info * The connection on the transmitter board is a 5-pin JST-XH type connector, 2.54mm. * The Raspberry Pis use a female Dupont connector and we recommended using a 2 x 6 block connector. -* The BeagleBone Blacks (BBB) use a male Dupont connector (recommendation pending BBB support work in progress). +* The BeagleBone Black (BBB) use a male Dupont connector. If you are comfortable with crimping and making connectors, here are examples of what to use * JST-XH connectors - https://www.amazon.com/dp/B015Y6JOUG @@ -62,7 +60,7 @@ Pre-crimped wires are also an options The green PWM wire runs next to yellow 3.3V and orange GND wire until right before the end to eliminate issue with interference. Keeping the cable as short as possible helps to reduce interference. #### Cable for a BeagleBone Black (BBB) -(Support for the BBB is still in progress) +(Cable details for the BBB are still in progress) #### Shielding and RF interference Given the nature of an FM transmitter, interference is potential problem. This interference commonly shows up as I2C errors which become more frequent as transmitter power increases. Moving the antenna away from the RPi/BBB and the transmitter board can reduce this. A significantly more robust setup it to locate the RPi/BBB and transmitter board inside a grounded, metal case such as was done by @chrkov here: @@ -77,32 +75,25 @@ The recommended QN8066 transmitter board can take a PWM signal to increase its p On the Raspberry Pi, in order to use the hardware PWM, the built-in analog audio must be disabled and an external USB sound card or DAC is required. The built-in audio uses both hardware PWM channels to generate the audio, so PWM cannot be used for other purposes when enabled. Software PWM is also an option, but at an increased CPU cost and a decrease in duty cycle accuracy. -From the Dynamic RDS configuration page, under the Power Settings, enable PWM. +From the Dynamic_RDS configuration page, under the Power Settings, enable PWM. -This will automatically modify the /boot/firmware/config.txt: -1. Comment out all ```dtparm=audio=on``` lines with a # -2. Add the line ```dtoverlay=pwm,pin=18,func=2``` by default +This will automatically modify the `/boot/firmware/config.txt`: +1. Comment out all `dtparm=audio=on` lines with a `#` +2. Add the line `dtoverlay=pwm,pin=18,func=2` by default Under the Advanced Options at the bottom of the configuration page, the output pin can be selected. This is also where Software PWM can be selected on most other pins. > [!TIP] > Don't forget to change the Audio Output Device in the FPP Settings to use the USB sound card or DAC ## Integration with FPP After Hours Music Plugin -The Dynamic RDS plugin has the ability to work in conjunction with the [FPP After Hours Music Plugin](https://github.com/jcrossbdn/fpp-after-hours) to provide RDS Data from an internet stream of music. The information from the stream is populated in the Title field. +The Dynamic_RDS plugin has the ability to work in conjunction with the [FPP After Hours Music Plugin](https://github.com/jcrossbdn/fpp-after-hours) to provide RDS Data from an internet stream of music. The information from the stream is populated in the Title field. -Once the After Hours Music Plugin is installed, the integration can be enabled on the Dynamic RDS configuration pages in the MPC / After Hours Music section. +Once the After Hours Music Plugin is installed, the integration can be enabled on the Dynamic_RDS configuration pages in the MPC / After Hours Music section. ![MPC-After-Hours](https://user-images.githubusercontent.com/23623446/201971100-7a213ef5-a22d-4e76-a545-8c8c9724a9e0.JPG) ## Scripting Plugin Changes -It is an option to use scripts to change Dynamic RDS option value. As an example, this could be used to change the PS and/or RT style text to be different during the show verses after. The following is a bash script that can update the style text and have the plugin start using it without restarting FPP. -``` -#!/bin/bash -curl -d 'Merry|Christ-| -mas!|{T}|{A}|[{N} of {C}]' -X POST http://localhost/api/plugin/Dynamic_RDS/settings/DynRDSPSStyle -curl -d 'Merry Christmas! {T}[ by {A}]|[Track {N} of {C}]' -X POST http://localhost/api/plugin/Dynamic_RDS/settings/DynRDSRTStyle -curl http://localhost/api/plugin/Dynamic_RDS/FastUpdate -``` -The single quotes around the style text in the script are important so the Linux shell (bash) won't try to interpret what is in there. This example could be saved as a file in the media/scripts folder and then use it with the scheduler (via Command -> Run Script) or playlists. +During the plugin install, an example script is copied to the FPP `media/scripts` directory showing how to change the RDS style text. As an example, this could be used to change the PS and/or RT style text to be different during the show verses after. The script is located in [scripts/src_Dynamic_RDS_config.sh](scripts/src_Dynamic_RDS_config.sh) and the changes are made without having to restart FPP. The single quotes around the style text in the script are important so the Linux shell (bash) won't try to interpret what is in there. Use the script in the `media/scripts` folder and then use it with the scheduler (via Command -> Run Script) or playlists. ## Troubleshooting ### Transmitter not working (for the recommended QN8066 board) @@ -119,8 +110,8 @@ The single quotes around the style text in the script are important so the Linux - Power up the RPi/BBB - Transmitter will power up from power supplied by RPi/BBB (Do NOT connect 12v power yet) - Verify the transmitter shows up on the I2C bus at 0x21 - - Either from the Dynamic RDS config page OR - - SSH into the RPi ```i2cdetect -y 1``` and run or on BBB run ```i2cdetect -r -y 2``` + - Either from the Dynamic_RDS config page OR + - SSH into the RPi `i2cdetect -y 1` and run or on BBB run `i2cdetect -r -y 2` - If transmitter does not show up - Double check each wire is connectioned correctly 3v3, GND, SDA, and SCL - No really, go double check! It can happen to anyone! :)