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 diff --git a/.pylintrc b/.pylintrc index cce80d2..66d4ef6 100644 --- a/.pylintrc +++ b/.pylintrc @@ -3,4 +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, missing-docstring, invalid-name, fixme, bare-except, broad-exception-caught, import-error + +[DESIGN] +max-branches=15 +max-bool-expr=6 +max-attributes=10 diff --git a/Dynamic_RDS.php b/Dynamic_RDS.php index 6dda86d..6d479eb 100644 --- a/Dynamic_RDS.php +++ b/Dynamic_RDS.php @@ -1,164 +1,522 @@ - -
-

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-smbus2 | grep installed'); + 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)) + 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-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) { + $status->addError('Unable to find an I2C bus - On RPi, check /boot/firmware/config.txt for I2C entry'); + } + + // 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')); + } + } + + // 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); + } + + // 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 { + ?> + '; + PrintSettingGroup("DynRDSAudioSettings", "", + "indicates a live change to transmitter, no FPP restart required", + 1, "Dynamic_RDS", "DynRDSFastUpdate"); + echo '
'; + + 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"); -

RDS Style Text Guide

Values from File Tags or Track Info @@ -180,66 +582,89 @@ function ScriptStreamProgressDialogDone() {
  • {L} = Track Length as 0:00
  • Main Playlist Section Values +
  • {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.

    +

    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:

    + - diff --git a/Dynamic_RDS_Engine.py b/Dynamic_RDS_Engine.py index 6d76f9b..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 @@ -8,14 +8,16 @@ 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 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): @@ -68,10 +70,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': @@ -90,7 +92,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: @@ -130,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:%(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 @@ -181,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) @@ -210,7 +224,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.') @@ -280,7 +294,10 @@ 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 + if rdsValues['{P}'] == '1' and rdsValues['{C}'] == '1': + rdsValues['{P}'] = '' + pendingPlaylistUpdate = True + lastUpdateTime = time.monotonic() # rdsValues that need additional parsing elif line[0] == 'L': @@ -294,8 +311,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 @@ -319,4 +336,4 @@ def excessive(msg, *args, **kwargs): if transmitter is None or not transmitter.active: logging.debug('Sleeping...') - sleep(3) + time.sleep(3) diff --git a/QN8066.py b/QN8066.py index d69057c..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 basicPWM, hardwarePWM, softwarePWM, hardwareBBBPWM +from basicPWM import createPWM from Transmitter import Transmitter class QN8066(Transmitter): @@ -16,7 +15,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 +60,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 @@ -147,7 +128,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{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 diff --git a/README.md b/README.md index 2c593d2..8cf4416 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,19 @@ # 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] +> Dynamic_RDS supports the **QN8066** and **Si4713** FM transmitter chips -## Recommended QN8066 transmitter board +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 and a limitation of total message length based on how many buffers are available. + +![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,8 +23,8 @@ 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 -The QN8066 transmitter board needs an antenna for safe operations. +### Antenna +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/ @@ -21,16 +32,14 @@ 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 +### 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). +* 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 @@ -42,7 +51,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 +59,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) -(Support for the BBB is still in progress) +#### Cable for a BeagleBone Black (BBB) +(Cable details for the BBB are 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] @@ -66,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/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) @@ -108,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! :) diff --git a/Si4713.py b/Si4713.py new file mode 100644 index 0000000..139d587 --- /dev/null +++ b/Si4713.py @@ -0,0 +1,264 @@ +import logging +import sys +from threading import Timer +from time import sleep +from gpiozero import DigitalOutputDevice + +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.totalCircularBuffers = 0 + + # 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_TX_RDS_PS_MESSAGE_COUNT = 0x2C05 + PROP_REFCLK_FREQ = 0x0201 + + # Status bits + STATUS_CTS = 0x80 + + def _wait_for_cts(self, timeout=100): + 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, isFatal = False): + args = args or [] + self.I2C.write(cmd, args, isFatal) + 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') + + logging.info('Executing Reset with Pin %s', config['DynRDSSi4713GPIOReset']) + with DigitalOutputDevice(int(config['DynRDSSi4713GPIOReset'])) as resetPin: + 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.5) # Wait for power up + if not self._wait_for_cts(): + logging.error('Si4713 failed to be read after power up') + sys.exit(-1) + + # Verify chip by getting revision + self._send_command(self.CMD_GET_REV, [], True) + revData = self.I2C.read(0x00, 9, True) + 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]) + 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.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] + + # 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": + 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, 0x05) # Mix mode + 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 + 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.1) # Wait for tune + + # Set transmission power + power = int(config['DynRDSSi4713ChipPower']) + antcap = int(config['DynRDSSi4713TuningCap']) + + args = [ + 0x00, # Reserved + 0x00, # Reserved + power & 0xFF, + antcap & 0xFF # Antenna cap (0 = auto) + ] + self._send_command(self.CMD_TX_TUNE_POWER, args) + sleep(0.02) + + # Set TX_RDS_PI + self._set_property(self.PROP_TX_RDS_PI, int(config['DynRDSPICode'], 16)) + + self.update() + super().startup() + self.updateRDSData(self.PStext, self.RTtext) + + 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): + # 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) + + 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) + if self.active: + self._updatePS(PSdata) + self._updateRT(RTdata) + # Initial burst of RT groups to get it displayed quickly + logging.debug('RT group burst') + self._set_property(self.PROP_TX_RDS_PS_MIX, 0x02) # Mix mode + 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.debug('Si4713 _updatePS') + if len(psText) > 96: + 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) + + 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.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.debug('RT length: %d, Abs Max Length: %d', len(rtText), rtMaxLength) + + if len(rtText) > rtMaxLength: + rtText = 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('RT \'%s\'', 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 = True + 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'))) + # 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) + segmentOffset += 1 + logging.info('Circular Buffer: %d/%d', rdsBuffData[3], rdsBuffData[2] + rdsBuffData[3]) + + def sendNextRDSGroup(self): + logging.excessive('Si4713 sendNextRDSGroup') + sleep(0.25) 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/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"); } diff --git a/basicI2C.py b/basicI2C.py index 14d08c2..9ed1898 100644 --- a/basicI2C.py +++ b/basicI2C.py @@ -2,7 +2,8 @@ import os import sys from time import sleep -import smbus + +import smbus2 # =============== # Basic I2C Class @@ -20,26 +21,25 @@ 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? + logging.exception('SMBus2 Init Error') 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/basicMQTT.py b/basicMQTT.py index 63daa50..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) @@ -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 4c7a8df..f725e88 100644 --- a/basicPWM.py +++ b/basicPWM.py @@ -1,11 +1,42 @@ -import os import logging +import os +import re +import subprocess +import sys + +from config import config + + +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])) + 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): 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): @@ -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(self, 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,41 +106,65 @@ 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() 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 - 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 d3fd06f..bad84e8 100755 --- a/callbacks.py +++ b/callbacks.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 import logging import json @@ -8,14 +8,22 @@ import socket import sys import time -from sys import argv +from sys import argv 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 +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') @@ -36,53 +44,43 @@ def logUnhandledException(eType, eValue, eTraceback): logging.getLogger().setLevel(config['DynRDSCallbackLogLevel']) -logging.info('---') -logging.debug('Arguments %s', argv[1:]) - -# If smbus is missing, don't try to start up the Engine as it will fail -try: - import smbus -except ImportError as impErr: - logging.error("Failed to import smbus %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) +logging.debug('---') +logging.debug('Args %s', argv[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) -# 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 -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') 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 -except socket.error: - logging.debug('Lock found - %s is running', updater_path) + # 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) + logging.error('%s exited early - %s', updater_path, proc.returncode) + engineStarted = False + except subprocess.TimeoutExpired: + # Timeout means process is STILL RUNNING / success + engineStarted = True # 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) @@ -90,20 +88,21 @@ def logUnhandledException(eType, eValue, eTraceback): 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().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: 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': - logging.info('Engine restart detected, sending INIT') + logging.info(' Engine restart detected, sending INIT') fifo.write('INIT\n') if argv[1] == '--list': @@ -122,13 +121,27 @@ 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: + if not check_engine_running(): + elapsed = time.monotonic() - startTime + logging.info(' Engine shutdown after %.2fs', elapsed) + sys.exit() + time.sleep(0.05) # Sleep 50ms between attempts + logging.warning('Engine shutdown timeout after %ss', timeout) elif argv[1] == '--type' and argv[2] == 'media': - logging.debug('Type media') try: 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' @@ -162,8 +175,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: @@ -171,13 +182,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']) diff --git a/config.py b/config.py index 89684e7..68fb7ae 100644 --- a/config.py +++ b/config.py @@ -4,21 +4,23 @@ 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', 'DynRDSFrequency': '100.1', 'DynRDSPreemphasis': '75us', + 'DynRDSQN8066Gain': '0', 'DynRDSQN8066SoftClipping': '0', 'DynRDSQN8066AGC': '0', 'DynRDSQN8066ChipPower': '122', -'DynRDSQN8066PIPWM': 0, +'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(): diff --git a/images/Si4713-transmitter.jpg b/images/Si4713-transmitter.jpg new file mode 100644 index 0000000..3cfcf07 Binary files /dev/null and b/images/Si4713-transmitter.jpg differ 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']); } } 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 3fe2c5b..8dec135 100755 --- a/scripts/fpp_install.sh +++ b/scripts/fpp_install.sh @@ -3,12 +3,12 @@ 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-smbus2..." +sudo apt-get install -y python3-smbus2 -if test -f /boot/config.txt; then - echo -e "\nInstalling python3-rpi-lgpio..." - sudo apt-get install -y python3-rpi-lgpio +if test -f /boot/firmware/config.txt; then + 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 c02f93a..3f9c9b5 100755 --- a/scripts/fpp_uninstall.sh +++ b/scripts/fpp_uninstall.sh @@ -10,5 +10,3 @@ 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" diff --git a/scripts/src_Dynamic_RDS_config.sh b/scripts/src_Dynamic_RDS_config.sh index b3f4758..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='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 @@ -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 diff --git a/settings.json b/settings.json index b668cc4..e1604ba 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": { @@ -84,11 +87,13 @@ "options": { "SELECT TRANSMITTER": "None", "QN8066": "QN8066", - "Si4713 (planned for future release)": "zzSi4713" + "Si4713": "Si4713" }, "default": "None", "children": { "QN8066": [ + "DynRDSPSUpdateRate", + "DynRDSRTUpdateRate", "DynRDSQN8066Gain", "DynRDSQN8066SoftClipping", "DynRDSQN8066AGC", @@ -97,7 +102,10 @@ "DynRDSQN8066AmpPower" ], "Si4713": [ - "DynRDSSi4713TestAudio" + "DynRDSSi4713TestAudio", + "DynRDSSi4713GPIOReset", + "DynRDSSi4713TuningCap", + "DynRDSSi4713ChipPower" ] } }, @@ -175,6 +183,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": 1, + "reboot": 0, + "type": "number", + "min": 88, + "max": 120, + "step": 1, + "default": 115 + }, "DynRDSFrequency": { "name": "DynRDSFrequency", "description": "Frequency (60.00-108.00)", @@ -201,6 +222,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)", @@ -271,7 +332,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", @@ -289,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": "Merry Christmas!|{T}[ by {A}]|[Track {N} of {C}]" + "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, @@ -313,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",