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
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
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
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
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
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
Values from File Tags or Track Info
@@ -180,66 +582,89 @@ function ScriptStreamProgressDialogDone() {
{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 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 '
Logs - Dynamic_RDS_callbacks.log and Dynamic_RDS_Engine.log
-
Config - plugin.Dynamic_RDS
-
Version from git rev-parse --short HEAD
-
Pi/BBB boot config - /boot/firmware/config.txt or /boot/uEnv.txt
+
Logs - Dynamic_RDS_callbacks.log and Dynamic_RDS_Engine.log
+
Config - plugin.Dynamic_RDS
+
Version from git rev-parse --short HEAD
+
Pi/BBB boot config - /boot/firmware/config.txt or /boot/uEnv.txt
+
-
diff --git a/Dynamic_RDS_Engine.py b/Dynamic_RDS_Engine.py
index 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.
+
+
+
+## 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


-## 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


@@ -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:


-## 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.

## 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("