diff --git a/documentation/builders/components/mqtt/mqtt-integration.md b/documentation/builders/components/mqtt/mqtt-integration.md
new file mode 100644
index 000000000..3147e28a1
--- /dev/null
+++ b/documentation/builders/components/mqtt/mqtt-integration.md
@@ -0,0 +1,39 @@
+# MQTT Integration
+
+The MQTT integration allows you to control your Phoniebox via the MQTT protocol. This feature enables not only MQTT
+control but also integration with home automation systems like Home Assistant.
+
+## Configuration
+
+Set the corresponding setting in `shared\settings\jukebox.yaml` to activate this feature.
+
+``` yaml
+modules:
+ named:
+ ...
+ mqtt: mqtt
+...
+mqtt:
+ enable: true
+ # The prefix for the mqtt topic. /{base_topic}/{topic}
+ base_topic: phoniebox-dev
+ # Enable support for legacy commands. Only needed for compatiblity to previous phoniebox mqtt integration.
+ enable_legacy: false
+ # The client id used in communication with the MQTT broker and identification of the phoniebox
+ client_id: phoniebox_dev
+ # The username to authenticate against the broker
+ username: phoniebox-dev
+ # The password to authenticate against the broker
+ password: phoniebox-dev
+ # The host name or IP address of your mqtt broker
+ host: 127.0.0.1
+ # The port number of the mqtt broker. The default is 1883
+ port: 1883
+```
+
+## Usage in Home Assistant
+
+Home Assistant does not have a native MQTT Media Player integration. To integrate Phoniebox into Home Assistant, you
+can use the Universal Media Player configuration in combination with the Home Assistant MQTT service.
+
+There is also an HACS addon adding Phoniebox as Media Player [Hass Phoniebox](https://github.com/c0un7-z3r0/hass-phoniebox).
diff --git a/documentation/developers/docstring/README.md b/documentation/developers/docstring/README.md
index c48c34a41..fd3655524 100644
--- a/documentation/developers/docstring/README.md
+++ b/documentation/developers/docstring/README.md
@@ -3,61 +3,32 @@
## Table of Contents
* [run\_jukebox](#run_jukebox)
-* [\_\_init\_\_](#__init__)
-* [run\_register\_rfid\_reader](#run_register_rfid_reader)
* [run\_rpc\_tool](#run_rpc_tool)
* [get\_common\_beginning](#run_rpc_tool.get_common_beginning)
* [runcmd](#run_rpc_tool.runcmd)
-* [run\_configure\_audio](#run_configure_audio)
+* [run\_register\_rfid\_reader](#run_register_rfid_reader)
* [run\_publicity\_sniffer](#run_publicity_sniffer)
-* [misc](#misc)
- * [recursive\_chmod](#misc.recursive_chmod)
- * [flatten](#misc.flatten)
- * [getattr\_hierarchical](#misc.getattr_hierarchical)
-* [misc.inputminus](#misc.inputminus)
- * [input\_int](#misc.inputminus.input_int)
- * [input\_yesno](#misc.inputminus.input_yesno)
-* [misc.loggingext](#misc.loggingext)
- * [ColorFilter](#misc.loggingext.ColorFilter)
- * [\_\_init\_\_](#misc.loggingext.ColorFilter.__init__)
- * [PubStream](#misc.loggingext.PubStream)
- * [PubStreamHandler](#misc.loggingext.PubStreamHandler)
-* [misc.simplecolors](#misc.simplecolors)
- * [Colors](#misc.simplecolors.Colors)
- * [resolve](#misc.simplecolors.resolve)
- * [print](#misc.simplecolors.print)
+* [\_\_init\_\_](#__init__)
+* [run\_configure\_audio](#run_configure_audio)
* [components](#components)
-* [components.playermpd.playcontentcallback](#components.playermpd.playcontentcallback)
- * [PlayContentCallbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks)
- * [register](#components.playermpd.playcontentcallback.PlayContentCallbacks.register)
- * [run\_callbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks.run_callbacks)
-* [components.playermpd](#components.playermpd)
- * [PlayerMPD](#components.playermpd.PlayerMPD)
- * [mpd\_retry\_with\_mutex](#components.playermpd.PlayerMPD.mpd_retry_with_mutex)
- * [pause](#components.playermpd.PlayerMPD.pause)
- * [next](#components.playermpd.PlayerMPD.next)
- * [rewind](#components.playermpd.PlayerMPD.rewind)
- * [replay](#components.playermpd.PlayerMPD.replay)
- * [toggle](#components.playermpd.PlayerMPD.toggle)
- * [replay\_if\_stopped](#components.playermpd.PlayerMPD.replay_if_stopped)
- * [play\_card](#components.playermpd.PlayerMPD.play_card)
- * [get\_single\_coverart](#components.playermpd.PlayerMPD.get_single_coverart)
- * [get\_folder\_content](#components.playermpd.PlayerMPD.get_folder_content)
- * [play\_folder](#components.playermpd.PlayerMPD.play_folder)
- * [play\_album](#components.playermpd.PlayerMPD.play_album)
- * [get\_volume](#components.playermpd.PlayerMPD.get_volume)
- * [set\_volume](#components.playermpd.PlayerMPD.set_volume)
- * [play\_card\_callbacks](#components.playermpd.play_card_callbacks)
-* [components.playermpd.coverart\_cache\_manager](#components.playermpd.coverart_cache_manager)
-* [components.rpc\_command\_alias](#components.rpc_command_alias)
-* [components.synchronisation.rfidcards](#components.synchronisation.rfidcards)
- * [SyncRfidcards](#components.synchronisation.rfidcards.SyncRfidcards)
- * [sync\_change\_on\_rfid\_scan](#components.synchronisation.rfidcards.SyncRfidcards.sync_change_on_rfid_scan)
- * [sync\_all](#components.synchronisation.rfidcards.SyncRfidcards.sync_all)
- * [sync\_card\_database](#components.synchronisation.rfidcards.SyncRfidcards.sync_card_database)
- * [sync\_folder](#components.synchronisation.rfidcards.SyncRfidcards.sync_folder)
-* [components.synchronisation](#components.synchronisation)
-* [components.synchronisation.syncutils](#components.synchronisation.syncutils)
+* [components.mqtt.utils](#components.mqtt.utils)
+ * [play\_folder\_recursive\_args](#components.mqtt.utils.play_folder_recursive_args)
+ * [parse\_repeat\_mode](#components.mqtt.utils.parse_repeat_mode)
+ * [get\_args](#components.mqtt.utils.get_args)
+ * [get\_rpc\_command](#components.mqtt.utils.get_rpc_command)
+ * [get\_kwargs](#components.mqtt.utils.get_kwargs)
+ * [get\_current\_time\_milli](#components.mqtt.utils.get_current_time_milli)
+ * [split\_topic](#components.mqtt.utils.split_topic)
+ * [map\_repeat\_mode](#components.mqtt.utils.map_repeat_mode)
+* [components.mqtt.mqtt\_command\_alias](#components.mqtt.mqtt_command_alias)
+ * [get\_mute](#components.mqtt.mqtt_command_alias.get_mute)
+* [components.mqtt.mqtt\_const](#components.mqtt.mqtt_const)
+* [components.mqtt](#components.mqtt)
+ * [MQTT](#components.mqtt.MQTT)
+ * [run](#components.mqtt.MQTT.run)
+ * [stop](#components.mqtt.MQTT.stop)
+ * [on\_connect](#components.mqtt.on_connect)
+ * [initialize](#components.mqtt.initialize)
* [components.volume](#components.volume)
* [PulseMonitor](#components.volume.PulseMonitor)
* [SoundCardConnectCallbacks](#components.volume.PulseMonitor.SoundCardConnectCallbacks)
@@ -81,69 +52,20 @@
* [set\_soft\_max\_volume](#components.volume.PulseVolumeControl.set_soft_max_volume)
* [get\_soft\_max\_volume](#components.volume.PulseVolumeControl.get_soft_max_volume)
* [card\_list](#components.volume.PulseVolumeControl.card_list)
-* [components.rfid](#components.rfid)
-* [components.rfid.reader](#components.rfid.reader)
- * [RfidCardDetectCallbacks](#components.rfid.reader.RfidCardDetectCallbacks)
- * [register](#components.rfid.reader.RfidCardDetectCallbacks.register)
- * [run\_callbacks](#components.rfid.reader.RfidCardDetectCallbacks.run_callbacks)
- * [rfid\_card\_detect\_callbacks](#components.rfid.reader.rfid_card_detect_callbacks)
- * [CardRemovalTimerClass](#components.rfid.reader.CardRemovalTimerClass)
- * [\_\_init\_\_](#components.rfid.reader.CardRemovalTimerClass.__init__)
-* [components.rfid.configure](#components.rfid.configure)
- * [reader\_install\_dependencies](#components.rfid.configure.reader_install_dependencies)
- * [reader\_load\_module](#components.rfid.configure.reader_load_module)
- * [query\_user\_for\_reader](#components.rfid.configure.query_user_for_reader)
- * [write\_config](#components.rfid.configure.write_config)
-* [components.rfid.hardware.fake\_reader\_gui.fake\_reader\_gui](#components.rfid.hardware.fake_reader_gui.fake_reader_gui)
-* [components.rfid.hardware.fake\_reader\_gui.description](#components.rfid.hardware.fake_reader_gui.description)
-* [components.rfid.hardware.fake\_reader\_gui.gpioz\_gui\_addon](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon)
- * [create\_inputs](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.create_inputs)
- * [set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.set_state)
- * [que\_set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.que_set_state)
- * [fix\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.fix_state)
- * [pbox\_set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.pbox_set_state)
- * [que\_set\_pbox](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.que_set_pbox)
- * [create\_outputs](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.create_outputs)
-* [components.rfid.hardware.generic\_nfcpy.description](#components.rfid.hardware.generic_nfcpy.description)
-* [components.rfid.hardware.generic\_nfcpy.generic\_nfcpy](#components.rfid.hardware.generic_nfcpy.generic_nfcpy)
- * [ReaderClass](#components.rfid.hardware.generic_nfcpy.generic_nfcpy.ReaderClass)
- * [cleanup](#components.rfid.hardware.generic_nfcpy.generic_nfcpy.ReaderClass.cleanup)
- * [stop](#components.rfid.hardware.generic_nfcpy.generic_nfcpy.ReaderClass.stop)
- * [read\_card](#components.rfid.hardware.generic_nfcpy.generic_nfcpy.ReaderClass.read_card)
-* [components.rfid.hardware.generic\_usb.description](#components.rfid.hardware.generic_usb.description)
-* [components.rfid.hardware.generic\_usb.generic\_usb](#components.rfid.hardware.generic_usb.generic_usb)
-* [components.rfid.hardware.rc522\_spi.description](#components.rfid.hardware.rc522_spi.description)
-* [components.rfid.hardware.rc522\_spi.rc522\_spi](#components.rfid.hardware.rc522_spi.rc522_spi)
-* [components.rfid.hardware.pn532\_i2c\_py532.description](#components.rfid.hardware.pn532_i2c_py532.description)
-* [components.rfid.hardware.pn532\_i2c\_py532.pn532\_i2c\_py532](#components.rfid.hardware.pn532_i2c_py532.pn532_i2c_py532)
-* [components.rfid.hardware.rdm6300\_serial.rdm6300\_serial](#components.rfid.hardware.rdm6300_serial.rdm6300_serial)
- * [decode](#components.rfid.hardware.rdm6300_serial.rdm6300_serial.decode)
-* [components.rfid.hardware.rdm6300\_serial.description](#components.rfid.hardware.rdm6300_serial.description)
-* [components.rfid.hardware.template\_new\_reader.description](#components.rfid.hardware.template_new_reader.description)
-* [components.rfid.hardware.template\_new\_reader.template\_new\_reader](#components.rfid.hardware.template_new_reader.template_new_reader)
- * [query\_customization](#components.rfid.hardware.template_new_reader.template_new_reader.query_customization)
- * [ReaderClass](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass)
- * [\_\_init\_\_](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.__init__)
- * [cleanup](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.cleanup)
- * [stop](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.stop)
- * [read\_card](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.read_card)
-* [components.rfid.readerbase](#components.rfid.readerbase)
- * [ReaderBaseClass](#components.rfid.readerbase.ReaderBaseClass)
-* [components.rfid.cards](#components.rfid.cards)
- * [list\_cards](#components.rfid.cards.list_cards)
- * [delete\_card](#components.rfid.cards.delete_card)
- * [register\_card](#components.rfid.cards.register_card)
- * [register\_card\_custom](#components.rfid.cards.register_card_custom)
- * [save\_card\_database](#components.rfid.cards.save_card_database)
-* [components.rfid.cardutils](#components.rfid.cardutils)
- * [decode\_card\_command](#components.rfid.cardutils.decode_card_command)
- * [card\_command\_to\_str](#components.rfid.cardutils.card_command_to_str)
- * [card\_to\_str](#components.rfid.cardutils.card_to_str)
-* [components.publishing](#components.publishing)
- * [republish](#components.publishing.republish)
-* [components.player](#components.player)
- * [MusicLibPath](#components.player.MusicLibPath)
- * [get\_music\_library\_path](#components.player.get_music_library_path)
+* [components.rpc\_command\_alias](#components.rpc_command_alias)
+* [components.synchronisation](#components.synchronisation)
+* [components.synchronisation.syncutils](#components.synchronisation.syncutils)
+* [components.synchronisation.rfidcards](#components.synchronisation.rfidcards)
+ * [SyncRfidcards](#components.synchronisation.rfidcards.SyncRfidcards)
+ * [sync\_change\_on\_rfid\_scan](#components.synchronisation.rfidcards.SyncRfidcards.sync_change_on_rfid_scan)
+ * [sync\_all](#components.synchronisation.rfidcards.SyncRfidcards.sync_all)
+ * [sync\_card\_database](#components.synchronisation.rfidcards.SyncRfidcards.sync_card_database)
+ * [sync\_folder](#components.synchronisation.rfidcards.SyncRfidcards.sync_folder)
+* [components.jingle.jinglemp3](#components.jingle.jinglemp3)
+ * [JingleMp3Play](#components.jingle.jinglemp3.JingleMp3Play)
+ * [play](#components.jingle.jinglemp3.JingleMp3Play.play)
+ * [JingleMp3PlayBuilder](#components.jingle.jinglemp3.JingleMp3PlayBuilder)
+ * [\_\_init\_\_](#components.jingle.jinglemp3.JingleMp3PlayBuilder.__init__)
* [components.jingle](#components.jingle)
* [JingleFactory](#components.jingle.JingleFactory)
* [list](#components.jingle.JingleFactory.list)
@@ -155,11 +77,6 @@
* [play](#components.jingle.alsawave.AlsaWave.play)
* [AlsaWaveBuilder](#components.jingle.alsawave.AlsaWaveBuilder)
* [\_\_init\_\_](#components.jingle.alsawave.AlsaWaveBuilder.__init__)
-* [components.jingle.jinglemp3](#components.jingle.jinglemp3)
- * [JingleMp3Play](#components.jingle.jinglemp3.JingleMp3Play)
- * [play](#components.jingle.jinglemp3.JingleMp3Play.play)
- * [JingleMp3PlayBuilder](#components.jingle.jinglemp3.JingleMp3PlayBuilder)
- * [\_\_init\_\_](#components.jingle.jinglemp3.JingleMp3PlayBuilder.__init__)
* [components.hostif.linux](#components.hostif.linux)
* [shutdown](#components.hostif.linux.shutdown)
* [reboot](#components.hostif.linux.reboot)
@@ -173,60 +90,29 @@
* [get\_autohotspot\_status](#components.hostif.linux.get_autohotspot_status)
* [stop\_autohotspot](#components.hostif.linux.stop_autohotspot)
* [start\_autohotspot](#components.hostif.linux.start_autohotspot)
-* [components.misc](#components.misc)
- * [rpc\_cmd\_help](#components.misc.rpc_cmd_help)
- * [get\_all\_loaded\_packages](#components.misc.get_all_loaded_packages)
- * [get\_all\_failed\_packages](#components.misc.get_all_failed_packages)
- * [get\_start\_time](#components.misc.get_start_time)
- * [get\_log](#components.misc.get_log)
- * [get\_log\_debug](#components.misc.get_log_debug)
- * [get\_log\_error](#components.misc.get_log_error)
- * [get\_git\_state](#components.misc.get_git_state)
- * [empty\_rpc\_call](#components.misc.empty_rpc_call)
-* [components.controls](#components.controls)
-* [components.controls.bluetooth\_audio\_buttons](#components.controls.bluetooth_audio_buttons)
-* [components.controls.common.evdev\_listener](#components.controls.common.evdev_listener)
- * [find\_device](#components.controls.common.evdev_listener.find_device)
- * [EvDevKeyListener](#components.controls.common.evdev_listener.EvDevKeyListener)
- * [\_\_init\_\_](#components.controls.common.evdev_listener.EvDevKeyListener.__init__)
- * [run](#components.controls.common.evdev_listener.EvDevKeyListener.run)
- * [start](#components.controls.common.evdev_listener.EvDevKeyListener.start)
-* [components.battery\_monitor](#components.battery_monitor)
-* [components.battery\_monitor.BatteryMonitorBase](#components.battery_monitor.BatteryMonitorBase)
- * [pt1\_frac](#components.battery_monitor.BatteryMonitorBase.pt1_frac)
- * [BattmonBase](#components.battery_monitor.BatteryMonitorBase.BattmonBase)
-* [components.battery\_monitor.batt\_mon\_simulator](#components.battery_monitor.batt_mon_simulator)
- * [battmon\_simulator](#components.battery_monitor.batt_mon_simulator.battmon_simulator)
-* [components.battery\_monitor.batt\_mon\_i2c\_ads1015](#components.battery_monitor.batt_mon_i2c_ads1015)
- * [battmon\_ads1015](#components.battery_monitor.batt_mon_i2c_ads1015.battmon_ads1015)
-* [components.gpio.gpioz.plugin](#components.gpio.gpioz.plugin)
- * [output\_devices](#components.gpio.gpioz.plugin.output_devices)
- * [input\_devices](#components.gpio.gpioz.plugin.input_devices)
- * [factory](#components.gpio.gpioz.plugin.factory)
- * [IS\_ENABLED](#components.gpio.gpioz.plugin.IS_ENABLED)
- * [IS\_MOCKED](#components.gpio.gpioz.plugin.IS_MOCKED)
- * [CONFIG\_FILE](#components.gpio.gpioz.plugin.CONFIG_FILE)
- * [ServiceIsRunningCallbacks](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks)
- * [register](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks.register)
- * [run\_callbacks](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks.run_callbacks)
- * [service\_is\_running\_callbacks](#components.gpio.gpioz.plugin.service_is_running_callbacks)
- * [build\_output\_device](#components.gpio.gpioz.plugin.build_output_device)
- * [build\_input\_device](#components.gpio.gpioz.plugin.build_input_device)
- * [get\_output](#components.gpio.gpioz.plugin.get_output)
- * [on](#components.gpio.gpioz.plugin.on)
- * [off](#components.gpio.gpioz.plugin.off)
- * [set\_value](#components.gpio.gpioz.plugin.set_value)
- * [flash](#components.gpio.gpioz.plugin.flash)
-* [components.gpio.gpioz.plugin.connectivity](#components.gpio.gpioz.plugin.connectivity)
- * [BUZZ\_TONE](#components.gpio.gpioz.plugin.connectivity.BUZZ_TONE)
- * [register\_rfid\_callback](#components.gpio.gpioz.plugin.connectivity.register_rfid_callback)
- * [register\_status\_led\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_led_callback)
- * [register\_status\_buzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_buzzer_callback)
- * [register\_status\_tonalbuzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_tonalbuzzer_callback)
- * [register\_audio\_sink\_change\_callback](#components.gpio.gpioz.plugin.connectivity.register_audio_sink_change_callback)
- * [register\_volume\_led\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_led_callback)
- * [register\_volume\_buzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_buzzer_callback)
- * [register\_volume\_rgbled\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_rgbled_callback)
+* [components.timers](#components.timers)
+* [components.playermpd.coverart\_cache\_manager](#components.playermpd.coverart_cache_manager)
+* [components.playermpd.playcontentcallback](#components.playermpd.playcontentcallback)
+ * [PlayContentCallbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks)
+ * [register](#components.playermpd.playcontentcallback.PlayContentCallbacks.register)
+ * [run\_callbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks.run_callbacks)
+* [components.playermpd](#components.playermpd)
+ * [PlayerMPD](#components.playermpd.PlayerMPD)
+ * [mpd\_retry\_with\_mutex](#components.playermpd.PlayerMPD.mpd_retry_with_mutex)
+ * [pause](#components.playermpd.PlayerMPD.pause)
+ * [next](#components.playermpd.PlayerMPD.next)
+ * [rewind](#components.playermpd.PlayerMPD.rewind)
+ * [replay](#components.playermpd.PlayerMPD.replay)
+ * [toggle](#components.playermpd.PlayerMPD.toggle)
+ * [replay\_if\_stopped](#components.playermpd.PlayerMPD.replay_if_stopped)
+ * [play\_card](#components.playermpd.PlayerMPD.play_card)
+ * [flush\_coverart\_cache](#components.playermpd.PlayerMPD.flush_coverart_cache)
+ * [get\_folder\_content](#components.playermpd.PlayerMPD.get_folder_content)
+ * [play\_folder](#components.playermpd.PlayerMPD.play_folder)
+ * [play\_album](#components.playermpd.PlayerMPD.play_album)
+ * [get\_volume](#components.playermpd.PlayerMPD.get_volume)
+ * [set\_volume](#components.playermpd.PlayerMPD.set_volume)
+ * [play\_card\_callbacks](#components.playermpd.play_card_callbacks)
* [components.gpio.gpioz.core.converter](#components.gpio.gpioz.core.converter)
* [ColorProperty](#components.gpio.gpioz.core.converter.ColorProperty)
* [VolumeToRGB](#components.gpio.gpioz.core.converter.VolumeToRGB)
@@ -234,6 +120,18 @@
* [luminize](#components.gpio.gpioz.core.converter.VolumeToRGB.luminize)
* [components.gpio.gpioz.core.mock](#components.gpio.gpioz.core.mock)
* [patch\_mock\_outputs\_with\_callback](#components.gpio.gpioz.core.mock.patch_mock_outputs_with_callback)
+* [components.gpio.gpioz.core.output\_devices](#components.gpio.gpioz.core.output_devices)
+ * [LED](#components.gpio.gpioz.core.output_devices.LED)
+ * [flash](#components.gpio.gpioz.core.output_devices.LED.flash)
+ * [Buzzer](#components.gpio.gpioz.core.output_devices.Buzzer)
+ * [flash](#components.gpio.gpioz.core.output_devices.Buzzer.flash)
+ * [PWMLED](#components.gpio.gpioz.core.output_devices.PWMLED)
+ * [flash](#components.gpio.gpioz.core.output_devices.PWMLED.flash)
+ * [RGBLED](#components.gpio.gpioz.core.output_devices.RGBLED)
+ * [flash](#components.gpio.gpioz.core.output_devices.RGBLED.flash)
+ * [TonalBuzzer](#components.gpio.gpioz.core.output_devices.TonalBuzzer)
+ * [flash](#components.gpio.gpioz.core.output_devices.TonalBuzzer.flash)
+ * [melody](#components.gpio.gpioz.core.output_devices.TonalBuzzer.melody)
* [components.gpio.gpioz.core.input\_devices](#components.gpio.gpioz.core.input_devices)
* [NameMixin](#components.gpio.gpioz.core.input_devices.NameMixin)
* [set\_rpc\_actions](#components.gpio.gpioz.core.input_devices.NameMixin.set_rpc_actions)
@@ -259,70 +157,139 @@
* [close](#components.gpio.gpioz.core.input_devices.TwinButton.close)
* [value](#components.gpio.gpioz.core.input_devices.TwinButton.value)
* [is\_active](#components.gpio.gpioz.core.input_devices.TwinButton.is_active)
-* [components.gpio.gpioz.core.output\_devices](#components.gpio.gpioz.core.output_devices)
- * [LED](#components.gpio.gpioz.core.output_devices.LED)
- * [flash](#components.gpio.gpioz.core.output_devices.LED.flash)
- * [Buzzer](#components.gpio.gpioz.core.output_devices.Buzzer)
- * [flash](#components.gpio.gpioz.core.output_devices.Buzzer.flash)
- * [PWMLED](#components.gpio.gpioz.core.output_devices.PWMLED)
- * [flash](#components.gpio.gpioz.core.output_devices.PWMLED.flash)
- * [RGBLED](#components.gpio.gpioz.core.output_devices.RGBLED)
- * [flash](#components.gpio.gpioz.core.output_devices.RGBLED.flash)
- * [TonalBuzzer](#components.gpio.gpioz.core.output_devices.TonalBuzzer)
- * [flash](#components.gpio.gpioz.core.output_devices.TonalBuzzer.flash)
- * [melody](#components.gpio.gpioz.core.output_devices.TonalBuzzer.melody)
-* [components.timers](#components.timers)
-* [jukebox](#jukebox)
-* [jukebox.callingback](#jukebox.callingback)
- * [CallbackHandler](#jukebox.callingback.CallbackHandler)
- * [register](#jukebox.callingback.CallbackHandler.register)
- * [run\_callbacks](#jukebox.callingback.CallbackHandler.run_callbacks)
- * [has\_callbacks](#jukebox.callingback.CallbackHandler.has_callbacks)
-* [jukebox.version](#jukebox.version)
- * [version](#jukebox.version.version)
- * [version\_info](#jukebox.version.version_info)
-* [jukebox.cfghandler](#jukebox.cfghandler)
- * [ConfigHandler](#jukebox.cfghandler.ConfigHandler)
- * [loaded\_from](#jukebox.cfghandler.ConfigHandler.loaded_from)
- * [get](#jukebox.cfghandler.ConfigHandler.get)
- * [setdefault](#jukebox.cfghandler.ConfigHandler.setdefault)
- * [getn](#jukebox.cfghandler.ConfigHandler.getn)
- * [setn](#jukebox.cfghandler.ConfigHandler.setn)
- * [setndefault](#jukebox.cfghandler.ConfigHandler.setndefault)
- * [config\_dict](#jukebox.cfghandler.ConfigHandler.config_dict)
- * [is\_modified](#jukebox.cfghandler.ConfigHandler.is_modified)
- * [clear\_modified](#jukebox.cfghandler.ConfigHandler.clear_modified)
- * [save](#jukebox.cfghandler.ConfigHandler.save)
- * [load](#jukebox.cfghandler.ConfigHandler.load)
- * [get\_handler](#jukebox.cfghandler.get_handler)
- * [load\_yaml](#jukebox.cfghandler.load_yaml)
- * [write\_yaml](#jukebox.cfghandler.write_yaml)
-* [jukebox.playlistgenerator](#jukebox.playlistgenerator)
- * [TYPE\_DECODE](#jukebox.playlistgenerator.TYPE_DECODE)
- * [PlaylistCollector](#jukebox.playlistgenerator.PlaylistCollector)
- * [\_\_init\_\_](#jukebox.playlistgenerator.PlaylistCollector.__init__)
- * [set\_exclusion\_endings](#jukebox.playlistgenerator.PlaylistCollector.set_exclusion_endings)
- * [get\_directory\_content](#jukebox.playlistgenerator.PlaylistCollector.get_directory_content)
- * [parse](#jukebox.playlistgenerator.PlaylistCollector.parse)
-* [jukebox.NvManager](#jukebox.NvManager)
-* [jukebox.publishing](#jukebox.publishing)
- * [get\_publisher](#jukebox.publishing.get_publisher)
-* [jukebox.publishing.subscriber](#jukebox.publishing.subscriber)
-* [jukebox.publishing.server](#jukebox.publishing.server)
- * [PublishServer](#jukebox.publishing.server.PublishServer)
- * [run](#jukebox.publishing.server.PublishServer.run)
- * [handle\_message](#jukebox.publishing.server.PublishServer.handle_message)
- * [handle\_subscription](#jukebox.publishing.server.PublishServer.handle_subscription)
- * [Publisher](#jukebox.publishing.server.Publisher)
- * [\_\_init\_\_](#jukebox.publishing.server.Publisher.__init__)
- * [send](#jukebox.publishing.server.Publisher.send)
- * [revoke](#jukebox.publishing.server.Publisher.revoke)
- * [resend](#jukebox.publishing.server.Publisher.resend)
- * [close\_server](#jukebox.publishing.server.Publisher.close_server)
-* [jukebox.daemon](#jukebox.daemon)
- * [log\_active\_threads](#jukebox.daemon.log_active_threads)
- * [JukeBox](#jukebox.daemon.JukeBox)
- * [signal\_handler](#jukebox.daemon.JukeBox.signal_handler)
+* [components.gpio.gpioz.plugin.connectivity](#components.gpio.gpioz.plugin.connectivity)
+ * [BUZZ\_TONE](#components.gpio.gpioz.plugin.connectivity.BUZZ_TONE)
+ * [register\_rfid\_callback](#components.gpio.gpioz.plugin.connectivity.register_rfid_callback)
+ * [register\_status\_led\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_led_callback)
+ * [register\_status\_buzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_buzzer_callback)
+ * [register\_status\_tonalbuzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_tonalbuzzer_callback)
+ * [register\_audio\_sink\_change\_callback](#components.gpio.gpioz.plugin.connectivity.register_audio_sink_change_callback)
+ * [register\_volume\_led\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_led_callback)
+ * [register\_volume\_buzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_buzzer_callback)
+ * [register\_volume\_rgbled\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_rgbled_callback)
+* [components.gpio.gpioz.plugin](#components.gpio.gpioz.plugin)
+ * [output\_devices](#components.gpio.gpioz.plugin.output_devices)
+ * [input\_devices](#components.gpio.gpioz.plugin.input_devices)
+ * [factory](#components.gpio.gpioz.plugin.factory)
+ * [IS\_ENABLED](#components.gpio.gpioz.plugin.IS_ENABLED)
+ * [IS\_MOCKED](#components.gpio.gpioz.plugin.IS_MOCKED)
+ * [CONFIG\_FILE](#components.gpio.gpioz.plugin.CONFIG_FILE)
+ * [ServiceIsRunningCallbacks](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks)
+ * [register](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks.register)
+ * [run\_callbacks](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks.run_callbacks)
+ * [service\_is\_running\_callbacks](#components.gpio.gpioz.plugin.service_is_running_callbacks)
+ * [build\_output\_device](#components.gpio.gpioz.plugin.build_output_device)
+ * [build\_input\_device](#components.gpio.gpioz.plugin.build_input_device)
+ * [get\_output](#components.gpio.gpioz.plugin.get_output)
+ * [on](#components.gpio.gpioz.plugin.on)
+ * [off](#components.gpio.gpioz.plugin.off)
+ * [set\_value](#components.gpio.gpioz.plugin.set_value)
+ * [flash](#components.gpio.gpioz.plugin.flash)
+* [components.rfid.configure](#components.rfid.configure)
+ * [reader\_install\_dependencies](#components.rfid.configure.reader_install_dependencies)
+ * [reader\_load\_module](#components.rfid.configure.reader_load_module)
+ * [query\_user\_for\_reader](#components.rfid.configure.query_user_for_reader)
+ * [write\_config](#components.rfid.configure.write_config)
+* [components.rfid](#components.rfid)
+* [components.rfid.cardutils](#components.rfid.cardutils)
+ * [decode\_card\_command](#components.rfid.cardutils.decode_card_command)
+ * [card\_command\_to\_str](#components.rfid.cardutils.card_command_to_str)
+ * [card\_to\_str](#components.rfid.cardutils.card_to_str)
+* [components.rfid.cards](#components.rfid.cards)
+ * [list\_cards](#components.rfid.cards.list_cards)
+ * [delete\_card](#components.rfid.cards.delete_card)
+ * [register\_card](#components.rfid.cards.register_card)
+ * [register\_card\_custom](#components.rfid.cards.register_card_custom)
+ * [save\_card\_database](#components.rfid.cards.save_card_database)
+* [components.rfid.readerbase](#components.rfid.readerbase)
+ * [ReaderBaseClass](#components.rfid.readerbase.ReaderBaseClass)
+* [components.rfid.reader](#components.rfid.reader)
+ * [RfidCardDetectCallbacks](#components.rfid.reader.RfidCardDetectCallbacks)
+ * [register](#components.rfid.reader.RfidCardDetectCallbacks.register)
+ * [run\_callbacks](#components.rfid.reader.RfidCardDetectCallbacks.run_callbacks)
+ * [rfid\_card\_detect\_callbacks](#components.rfid.reader.rfid_card_detect_callbacks)
+ * [CardRemovalTimerClass](#components.rfid.reader.CardRemovalTimerClass)
+ * [\_\_init\_\_](#components.rfid.reader.CardRemovalTimerClass.__init__)
+* [components.rfid.hardware.rdm6300\_serial.description](#components.rfid.hardware.rdm6300_serial.description)
+* [components.rfid.hardware.rdm6300\_serial.rdm6300\_serial](#components.rfid.hardware.rdm6300_serial.rdm6300_serial)
+ * [decode](#components.rfid.hardware.rdm6300_serial.rdm6300_serial.decode)
+* [components.rfid.hardware.template\_new\_reader.description](#components.rfid.hardware.template_new_reader.description)
+* [components.rfid.hardware.template\_new\_reader.template\_new\_reader](#components.rfid.hardware.template_new_reader.template_new_reader)
+ * [query\_customization](#components.rfid.hardware.template_new_reader.template_new_reader.query_customization)
+ * [ReaderClass](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass)
+ * [\_\_init\_\_](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.__init__)
+ * [cleanup](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.cleanup)
+ * [stop](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.stop)
+ * [read\_card](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.read_card)
+* [components.rfid.hardware.generic\_nfcpy.description](#components.rfid.hardware.generic_nfcpy.description)
+* [components.rfid.hardware.generic\_nfcpy.generic\_nfcpy](#components.rfid.hardware.generic_nfcpy.generic_nfcpy)
+ * [ReaderClass](#components.rfid.hardware.generic_nfcpy.generic_nfcpy.ReaderClass)
+ * [cleanup](#components.rfid.hardware.generic_nfcpy.generic_nfcpy.ReaderClass.cleanup)
+ * [stop](#components.rfid.hardware.generic_nfcpy.generic_nfcpy.ReaderClass.stop)
+ * [read\_card](#components.rfid.hardware.generic_nfcpy.generic_nfcpy.ReaderClass.read_card)
+* [components.rfid.hardware.generic\_usb.description](#components.rfid.hardware.generic_usb.description)
+* [components.rfid.hardware.generic\_usb.generic\_usb](#components.rfid.hardware.generic_usb.generic_usb)
+* [components.rfid.hardware.fake\_reader\_gui.fake\_reader\_gui](#components.rfid.hardware.fake_reader_gui.fake_reader_gui)
+* [components.rfid.hardware.fake\_reader\_gui.description](#components.rfid.hardware.fake_reader_gui.description)
+* [components.rfid.hardware.fake\_reader\_gui.gpioz\_gui\_addon](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon)
+ * [create\_inputs](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.create_inputs)
+ * [set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.set_state)
+ * [que\_set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.que_set_state)
+ * [fix\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.fix_state)
+ * [pbox\_set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.pbox_set_state)
+ * [que\_set\_pbox](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.que_set_pbox)
+ * [create\_outputs](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.create_outputs)
+* [components.rfid.hardware.pn532\_i2c\_py532.pn532\_i2c\_py532](#components.rfid.hardware.pn532_i2c_py532.pn532_i2c_py532)
+* [components.rfid.hardware.pn532\_i2c\_py532.description](#components.rfid.hardware.pn532_i2c_py532.description)
+* [components.rfid.hardware.rc522\_spi.rc522\_spi](#components.rfid.hardware.rc522_spi.rc522_spi)
+* [components.rfid.hardware.rc522\_spi.description](#components.rfid.hardware.rc522_spi.description)
+* [components.player](#components.player)
+ * [MusicLibPath](#components.player.MusicLibPath)
+ * [get\_music\_library\_path](#components.player.get_music_library_path)
+* [components.battery\_monitor.batt\_mon\_i2c\_ina219](#components.battery_monitor.batt_mon_i2c_ina219)
+ * [battmon\_ina219](#components.battery_monitor.batt_mon_i2c_ina219.battmon_ina219)
+* [components.battery\_monitor.batt\_mon\_i2c\_ads1015](#components.battery_monitor.batt_mon_i2c_ads1015)
+ * [battmon\_ads1015](#components.battery_monitor.batt_mon_i2c_ads1015.battmon_ads1015)
+* [components.battery\_monitor.batt\_mon\_simulator](#components.battery_monitor.batt_mon_simulator)
+ * [battmon\_simulator](#components.battery_monitor.batt_mon_simulator.battmon_simulator)
+* [components.battery\_monitor.BatteryMonitorBase](#components.battery_monitor.BatteryMonitorBase)
+ * [pt1\_frac](#components.battery_monitor.BatteryMonitorBase.pt1_frac)
+ * [BattmonBase](#components.battery_monitor.BatteryMonitorBase.BattmonBase)
+* [components.battery\_monitor](#components.battery_monitor)
+* [components.controls.bluetooth\_audio\_buttons](#components.controls.bluetooth_audio_buttons)
+* [components.controls.event\_devices](#components.controls.event_devices)
+ * [IS\_ENABLED](#components.controls.event_devices.IS_ENABLED)
+ * [CONFIG\_FILE](#components.controls.event_devices.CONFIG_FILE)
+ * [activate](#components.controls.event_devices.activate)
+ * [initialize](#components.controls.event_devices.initialize)
+ * [parse\_device\_config](#components.controls.event_devices.parse_device_config)
+* [components.controls.common.evdev\_listener](#components.controls.common.evdev_listener)
+ * [find\_device](#components.controls.common.evdev_listener.find_device)
+ * [EvDevKeyListener](#components.controls.common.evdev_listener.EvDevKeyListener)
+ * [\_\_init\_\_](#components.controls.common.evdev_listener.EvDevKeyListener.__init__)
+ * [run](#components.controls.common.evdev_listener.EvDevKeyListener.run)
+ * [start](#components.controls.common.evdev_listener.EvDevKeyListener.start)
+* [components.controls](#components.controls)
+* [components.misc](#components.misc)
+ * [rpc\_cmd\_help](#components.misc.rpc_cmd_help)
+ * [get\_all\_loaded\_packages](#components.misc.get_all_loaded_packages)
+ * [get\_all\_failed\_packages](#components.misc.get_all_failed_packages)
+ * [get\_start\_time](#components.misc.get_start_time)
+ * [get\_log](#components.misc.get_log)
+ * [get\_log\_debug](#components.misc.get_log_debug)
+ * [get\_log\_error](#components.misc.get_log_error)
+ * [get\_git\_state](#components.misc.get_git_state)
+ * [empty\_rpc\_call](#components.misc.empty_rpc_call)
+ * [get\_app\_settings](#components.misc.get_app_settings)
+ * [set\_app\_settings](#components.misc.set_app_settings)
+* [components.publishing](#components.publishing)
+ * [republish](#components.publishing.republish)
+* [jukebox](#jukebox)
+* [jukebox.callingback](#jukebox.callingback)
+ * [CallbackHandler](#jukebox.callingback.CallbackHandler)
+ * [register](#jukebox.callingback.CallbackHandler.register)
+ * [run\_callbacks](#jukebox.callingback.CallbackHandler.run_callbacks)
+ * [has\_callbacks](#jukebox.callingback.CallbackHandler.has_callbacks)
* [jukebox.plugs](#jukebox.plugs)
* [PluginPackageClass](#jukebox.plugs.PluginPackageClass)
* [register](#jukebox.plugs.register)
@@ -351,7 +318,43 @@
* [generate\_help\_rst](#jukebox.plugs.generate_help_rst)
* [get\_all\_loaded\_packages](#jukebox.plugs.get_all_loaded_packages)
* [get\_all\_failed\_packages](#jukebox.plugs.get_all_failed_packages)
+* [jukebox.cfghandler](#jukebox.cfghandler)
+ * [ConfigHandler](#jukebox.cfghandler.ConfigHandler)
+ * [loaded\_from](#jukebox.cfghandler.ConfigHandler.loaded_from)
+ * [get](#jukebox.cfghandler.ConfigHandler.get)
+ * [setdefault](#jukebox.cfghandler.ConfigHandler.setdefault)
+ * [getn](#jukebox.cfghandler.ConfigHandler.getn)
+ * [setn](#jukebox.cfghandler.ConfigHandler.setn)
+ * [setndefault](#jukebox.cfghandler.ConfigHandler.setndefault)
+ * [config\_dict](#jukebox.cfghandler.ConfigHandler.config_dict)
+ * [is\_modified](#jukebox.cfghandler.ConfigHandler.is_modified)
+ * [clear\_modified](#jukebox.cfghandler.ConfigHandler.clear_modified)
+ * [save](#jukebox.cfghandler.ConfigHandler.save)
+ * [load](#jukebox.cfghandler.ConfigHandler.load)
+ * [get\_handler](#jukebox.cfghandler.get_handler)
+ * [load\_yaml](#jukebox.cfghandler.load_yaml)
+ * [write\_yaml](#jukebox.cfghandler.write_yaml)
* [jukebox.speaking\_text](#jukebox.speaking_text)
+* [jukebox.utils](#jukebox.utils)
+ * [decode\_rpc\_call](#jukebox.utils.decode_rpc_call)
+ * [decode\_rpc\_command](#jukebox.utils.decode_rpc_command)
+ * [decode\_and\_call\_rpc\_command](#jukebox.utils.decode_and_call_rpc_command)
+ * [bind\_rpc\_command](#jukebox.utils.bind_rpc_command)
+ * [rpc\_call\_to\_str](#jukebox.utils.rpc_call_to_str)
+ * [get\_config\_action](#jukebox.utils.get_config_action)
+ * [generate\_cmd\_alias\_rst](#jukebox.utils.generate_cmd_alias_rst)
+ * [generate\_cmd\_alias\_reference](#jukebox.utils.generate_cmd_alias_reference)
+ * [get\_git\_state](#jukebox.utils.get_git_state)
+* [jukebox.version](#jukebox.version)
+ * [version](#jukebox.version.version)
+ * [version\_info](#jukebox.version.version_info)
+* [jukebox.playlistgenerator](#jukebox.playlistgenerator)
+ * [TYPE\_DECODE](#jukebox.playlistgenerator.TYPE_DECODE)
+ * [PlaylistCollector](#jukebox.playlistgenerator.PlaylistCollector)
+ * [\_\_init\_\_](#jukebox.playlistgenerator.PlaylistCollector.__init__)
+ * [set\_exclusion\_endings](#jukebox.playlistgenerator.PlaylistCollector.set_exclusion_endings)
+ * [get\_directory\_content](#jukebox.playlistgenerator.PlaylistCollector.get_directory_content)
+ * [parse](#jukebox.playlistgenerator.PlaylistCollector.parse)
* [jukebox.multitimer](#jukebox.multitimer)
* [MultiTimer](#jukebox.multitimer.MultiTimer)
* [cancel](#jukebox.multitimer.MultiTimer.cancel)
@@ -370,21 +373,47 @@
* [GenericMultiTimerClass](#jukebox.multitimer.GenericMultiTimerClass)
* [\_\_init\_\_](#jukebox.multitimer.GenericMultiTimerClass.__init__)
* [start](#jukebox.multitimer.GenericMultiTimerClass.start)
-* [jukebox.utils](#jukebox.utils)
- * [decode\_rpc\_call](#jukebox.utils.decode_rpc_call)
- * [decode\_rpc\_command](#jukebox.utils.decode_rpc_command)
- * [decode\_and\_call\_rpc\_command](#jukebox.utils.decode_and_call_rpc_command)
- * [bind\_rpc\_command](#jukebox.utils.bind_rpc_command)
- * [rpc\_call\_to\_str](#jukebox.utils.rpc_call_to_str)
- * [generate\_cmd\_alias\_rst](#jukebox.utils.generate_cmd_alias_rst)
- * [generate\_cmd\_alias\_reference](#jukebox.utils.generate_cmd_alias_reference)
- * [get\_git\_state](#jukebox.utils.get_git_state)
-* [jukebox.rpc](#jukebox.rpc)
+* [jukebox.NvManager](#jukebox.NvManager)
+* [jukebox.publishing.subscriber](#jukebox.publishing.subscriber)
+* [jukebox.publishing](#jukebox.publishing)
+ * [get\_publisher](#jukebox.publishing.get_publisher)
+* [jukebox.publishing.server](#jukebox.publishing.server)
+ * [PublishServer](#jukebox.publishing.server.PublishServer)
+ * [run](#jukebox.publishing.server.PublishServer.run)
+ * [handle\_message](#jukebox.publishing.server.PublishServer.handle_message)
+ * [handle\_subscription](#jukebox.publishing.server.PublishServer.handle_subscription)
+ * [Publisher](#jukebox.publishing.server.Publisher)
+ * [\_\_init\_\_](#jukebox.publishing.server.Publisher.__init__)
+ * [send](#jukebox.publishing.server.Publisher.send)
+ * [revoke](#jukebox.publishing.server.Publisher.revoke)
+ * [resend](#jukebox.publishing.server.Publisher.resend)
+ * [close\_server](#jukebox.publishing.server.Publisher.close_server)
+* [jukebox.daemon](#jukebox.daemon)
+ * [log\_active\_threads](#jukebox.daemon.log_active_threads)
+ * [JukeBox](#jukebox.daemon.JukeBox)
+ * [signal\_handler](#jukebox.daemon.JukeBox.signal_handler)
* [jukebox.rpc.client](#jukebox.rpc.client)
+* [jukebox.rpc](#jukebox.rpc)
* [jukebox.rpc.server](#jukebox.rpc.server)
* [RpcServer](#jukebox.rpc.server.RpcServer)
* [\_\_init\_\_](#jukebox.rpc.server.RpcServer.__init__)
* [run](#jukebox.rpc.server.RpcServer.run)
+* [misc](#misc)
+ * [recursive\_chmod](#misc.recursive_chmod)
+ * [flatten](#misc.flatten)
+ * [getattr\_hierarchical](#misc.getattr_hierarchical)
+* [misc.simplecolors](#misc.simplecolors)
+ * [Colors](#misc.simplecolors.Colors)
+ * [resolve](#misc.simplecolors.resolve)
+ * [print](#misc.simplecolors.print)
+* [misc.inputminus](#misc.inputminus)
+ * [input\_int](#misc.inputminus.input_int)
+ * [input\_yesno](#misc.inputminus.input_yesno)
+* [misc.loggingext](#misc.loggingext)
+ * [ColorFilter](#misc.loggingext.ColorFilter)
+ * [\_\_init\_\_](#misc.loggingext.ColorFilter.__init__)
+ * [PubStream](#misc.loggingext.PubStream)
+ * [PubStreamHandler](#misc.loggingext.PubStreamHandler)
@@ -402,24 +431,6 @@ as service. This gives direct logging info in the console and allows changing co
See [Troubleshooting](../../builders/troubleshooting.md).
-
-
-# \_\_init\_\_
-
-
-
-# run\_register\_rfid\_reader
-
-Setup tool to configure the RFID Readers.
-
-Run this once to register and configure the RFID readers with the Jukebox. Can be re-run at any time to change
-the settings. For more information see [RFID Readers](../rfid/README.md).
-
-> [!NOTE]
-> This tool will always write a new configurations file. Thus, overwrite the old one (after checking with the user).
-> Any manual modifications to the settings will have to be re-applied
-
-
# run\_rpc\_tool
@@ -463,16 +474,18 @@ Right now duplicates more or less main()
:todo remove duplication of code
-
+
-# run\_configure\_audio
+# run\_register\_rfid\_reader
-Setup tool to register the PulseAudio sinks as primary and secondary audio outputs.
+Setup tool to configure the RFID Readers.
-Will also setup equalizer and mono down mixer in the pulseaudio config file.
+Run this once to register and configure the RFID readers with the Jukebox. Can be re-run at any time to change
+the settings. For more information see [RFID Readers](../rfid/README.md).
-Run this once after installation. Can be re-run at any time to change the settings.
-For more information see [Audio Configuration](../../builders/audio.md#audio-configuration).
+> [!NOTE]
+> This tool will always write a new configurations file. Thus, overwrite the old one (after checking with the user).
+> Any manual modifications to the settings will have to be re-applied
@@ -485,625 +498,652 @@ Jukebox via the publishing interface. Received messages are printed in the cons
Mainly used for debugging.
-
+
-# misc
+# \_\_init\_\_
-
+
-#### recursive\_chmod
+# run\_configure\_audio
-```python
-def recursive_chmod(path, mode_files, mode_dirs)
-```
+Setup tool to register the PulseAudio sinks as primary and secondary audio outputs.
-Recursively change folder and file permissions
+Will also setup equalizer and mono down mixer in the pulseaudio config file.
-mode_files/mode dirs can be given in octal notation e.g. 0o777
-flags from the stats module.
+Run this once after installation. Can be re-run at any time to change the settings.
+For more information see [Audio Configuration](../../builders/audio.md#audio-configuration).
-Reference: https://docs.python.org/3/library/os.html#os.chmod
+
-
+# components
-#### flatten
+
+
+# components.mqtt.utils
+
+
+
+#### play\_folder\_recursive\_args
```python
-def flatten(iterable)
+def play_folder_recursive_args(payload: str) -> dict
```
-Flatten all levels of hierarchy in nested iterables
+Create arguments for playing a folder recursively.
-
+
-#### getattr\_hierarchical
+#### parse\_repeat\_mode
```python
-def getattr_hierarchical(obj: Any, name: str) -> Any
+def parse_repeat_mode(payload: str) -> Optional[str]
```
-Like the builtin getattr, but descends though the hierarchy levels
+Parse a repeat mode command based on the given payload.
-
+
-# misc.inputminus
+#### get\_args
-Zero 3rd-party dependency module for user prompting
+```python
+def get_args(config: dict, payload: dict) -> Optional[dict]
+```
-Yes, there are modules out there to do the same and they have more features.
-However, this is low-complexity and has zero dependencies
+Retrieve arguments based on the configuration and payload.
-
+
-#### input\_int
+#### get\_rpc\_command
```python
-def input_int(prompt,
- blank=None,
- min=None,
- max=None,
- prompt_color=None,
- prompt_hint=False) -> int
+def get_rpc_command(config: dict) -> Optional[dict]
```
-Request an integer input from user
+Retrieve the RPC command based on the configuration.
-**Arguments**:
-- `prompt`: The prompt to display
-- `blank`: Value to return when user just hits enter. Leave at None, if blank is invalid
-- `min`: Minimum valid integer value (None disables this check)
-- `max`: Maximum valid integer value (None disables this check)
-- `prompt_color`: Color of the prompt. Color will be reset at end of prompt
-- `prompt_hint`: Append a 'hint' with [min...max, default=xx] to end of prompt
+
-**Returns**:
+#### get\_kwargs
-integer value read from user input
+```python
+def get_kwargs(config: dict, payload: dict) -> Optional[dict]
+```
-
+Retrieve keyword arguments based on the configuration and payload.
-#### input\_yesno
+
+
+
+#### get\_current\_time\_milli
```python
-def input_yesno(prompt,
- blank=None,
- prompt_color=None,
- prompt_hint=False) -> bool
+def get_current_time_milli() -> int
```
-Request a yes / no choice from user
-
-Accepts multiple input for true/false and is case insensitive
+Get the current time in milliseconds.
-**Arguments**:
-- `prompt`: The prompt to display
-- `blank`: Value to return when user just hits enter. Leave at None, if blank is invalid
-- `prompt_color`: Color of the prompt. Color will be reset at end of prompt
-- `prompt_hint`: Append a 'hint' with [y/n] to end of prompt. Default choice will be capitalized
+
-**Returns**:
+#### split\_topic
-boolean value read from user input
+```python
+def split_topic(topic: str) -> str
+```
-
+Split an MQTT topic and return a part of it.
-# misc.loggingext
-## Logger
+
-We use a hierarchical Logger structure based on pythons logging module. It can be finely configured with a yaml file.
+#### map\_repeat\_mode
-The top-level logger is called 'jb' (to make it short). In any module you may simple create a child-logger at any hierarchy
-level below 'jb'. It will inherit settings from it's parent logger unless otherwise configured in the yaml file.
-Hierarchy separator is the '.'. If the logger already exits, getLogger will return a reference to the same, else it will be
-created on the spot.
+```python
+def map_repeat_mode(repeat_active: bool, single_active: bool) -> str
+```
-Example: How to get logger and log away at your heart's content:
+Map boolean flags to repeat mode constants.
- >>> import logging
- >>> logger = logging.getLogger('jb.awesome_module')
- >>> logger.info('Started general awesomeness aura')
-Example: YAML snippet, setting WARNING as default level everywhere and DEBUG for jb.awesome_module:
+
- loggers:
- jb:
- level: WARNING
- handlers: [console, debug_file_handler, error_file_handler]
- propagate: no
- jb.awesome_module:
- level: DEBUG
+# components.mqtt.mqtt\_command\_alias
+This file provides definitions for MQTT to RPC command aliases
-> [!NOTE]
-> The name (and hierarchy path) of the logger can be arbitrary and must not necessarily match the module name (still makes
-> sense).
-> There can be multiple loggers per module, e.g. for special classes, to further control the amount of log output
+See []
+See [RPC Commands](../../builders/rpc-commands.md)
-
+
-## ColorFilter Objects
+#### get\_mute
```python
-class ColorFilter(logging.Filter)
+def get_mute(payload) -> bool
```
-This filter adds colors to the logger
+Helper to toggle mute in legacy support.
-It adds all colors from simplecolors by using the color name as new keyword,
-i.e. use %(colorname)c or {colorname} in the formatter string
-It also adds the keyword {levelnameColored} which is an auto-colored drop-in replacement
-for the levelname depending on severity.
+
-Don't forget to {reset} the color settings at the end of the string.
+# components.mqtt.mqtt\_const
+
-
+# components.mqtt
-#### \_\_init\_\_
+
+
+## MQTT Objects
```python
-def __init__(enable=True, color_levelname=True)
+class MQTT(threading.Thread)
```
-**Arguments**:
+A thread for monitoring events and publishing interesting events via MQTT.
-- `enable`: Enable the coloring
-- `color_levelname`: Enable auto-coloring when using the levelname keyword
-
+
-## PubStream Objects
+#### run
```python
-class PubStream()
+def run() -> None
```
-Stream handler wrapper around the publisher for logging.StreamHandler
-
-Allows logging to send all log information (based on logging configuration)
-to the Publisher.
-
-> [!CAUTION]
-> This can lead to recursions!
-> Recursions come up when
-> * Publish.send / PublishServer.send also emits logs, which cause a another send, which emits a log,
-> which causes a send, .....
-> * Publisher initialization emits logs, which need a Publisher instance to send logs
-
-> [!IMPORTANT]
-> To avoid endless recursions: The creation of a Publisher MUST NOT generate any log messages! Nor any of the
-> functions in the send-function stack!
+Main loop of the MQTT thread.
-
+
-## PubStreamHandler Objects
+#### stop
```python
-class PubStreamHandler(logging.StreamHandler)
+def stop()
```
-Wrapper for logging.StreamHandler with stream = PubStream
-
-This serves one purpose: In logger.yaml custom handlers
-can be configured (which are automatically instantiated).
-Using this Handler, we can output to PubStream whithout
-support code to instantiate PubStream keeping this file generic
+Stop the MQTT thread.
-
+
-# misc.simplecolors
+#### on\_connect
-Zero 3rd-party dependency module to add colors to unix terminal output
+```python
+def on_connect(client, userdata, flags, rc)
+```
-Yes, there are modules out there to do the same and they have more features.
-However, this is low-complexity and has zero dependencies
+Start thread on successful MQTT connection.
-
+
-## Colors Objects
+#### initialize
```python
-class Colors()
+@plugs.initialize
+def initialize()
```
-Container class for all the colors as constants
+Setup connection and trigger the MQTT loop.
-
+
-#### resolve
+# components.volume
-```python
-def resolve(color_name: str)
-```
+PulseAudio Volume Control Plugin Package
-Resolve a color name into the respective color constant
+## Features
-**Arguments**:
+* Volume Control
+* Two outputs
+* Watcher thread on volume / output change
-- `color_name`: Name of the color
+## Publishes
-**Returns**:
+* volume.level
+* volume.sink
-color constant
+## PulseAudio References
-
+
-#### print
+Check fallback device (on device de-connect):
-```python
-def print(color: Colors,
- *values,
- sep=' ',
- end='\n',
- file=sys.stdout,
- flush=False)
-```
+ $ pacmd list-sinks | grep -e 'name:' -e 'index'
-Drop-in replacement for print with color choice and auto color reset for convenience
-Use just as a regular print function, but with first parameter as color
+## Integration
+Pulse Audio runs as a user process. Processes who want to communicate / stream to it
+must also run as a user process.
-
+This means must also run as user process, as described in
+[Music Player Daemon](../../builders/system.md#music-player-daemon-mpd).
-# components
+## Misc
-
+PulseAudio may switch the sink automatically to a connecting bluetooth device depending on the loaded module
+with name module-switch-on-connect. On Raspberry Pi OS Bullseye, this module is not part of the default configuration
+in ``/usr/pulse/default.pa``. So, we don't need to worry about it.
+If the module gets loaded it conflicts with the toggle on connect and the selected primary / secondary outputs
+from the Jukebox. Remove it from the configuration!
-# components.playermpd.playcontentcallback
+ ### Use hot-plugged devices like Bluetooth or USB automatically (LP: `1702794`)
+ ### not available on PI?
+ .ifexists module-switch-on-connect.so
+ load-module module-switch-on-connect
+ .endif
-
+## Why PulseAudio?
-## PlayContentCallbacks Objects
+The audio configuration of the system is one of those topics,
+which has a myriad of options and possibilities. Every system is different and PulseAudio unifies these and
+makes our life easier. Besides, it is only option to support Bluetooth at the moment.
-```python
-class PlayContentCallbacks(Generic[STATE], CallbackHandler)
-```
+## Callbacks:
-Callbacks are executed in various play functions
+The following callbacks are provided. Register callbacks with these adder functions (see their documentation for details):
+1. :func:`add_on_connect_callback`
+2. :func:`add_on_output_change_callbacks`
+3. :func:`add_on_volume_change_callback`
-
-#### register
+
+
+## PulseMonitor Objects
```python
-def register(func: Callable[[str, STATE], None])
+class PulseMonitor(threading.Thread)
```
-Add a new callback function :attr:`func`.
+A thread for monitoring and interacting with the Pulse Lib via pulsectrl
-Callback signature is
-
-.. py:function:: func(folder: str, state: STATE)
- :noindex:
+Whenever we want to access pulsectl, we need to exit the event listen loop.
+This is handled by the context manager. It stops the event loop and returns
+the pulsectl instance to be used (it does no return the monitor thread itself!)
-**Arguments**:
+The context manager also locks the module to ensure proper thread sequencing,
+as only a single thread may work with pulsectl at any time. Currently, an RLock is
+used, even if it may not be necessary
-- `folder`: relativ path to folder to play
-- `state`: indicator of the state inside the calling
-
+
-#### run\_callbacks
+## SoundCardConnectCallbacks Objects
```python
-def run_callbacks(folder: str, state: STATE)
+class SoundCardConnectCallbacks(CallbackHandler)
```
+Callbacks are executed when
+* new sound card gets connected
-
-
-# components.playermpd
-
-Package for interfacing with the MPD Music Player Daemon
-Status information in three topics
-1) Player Status: published only on change
- This is a subset of the MPD status (and not the full MPD status) ??
- - folder
- - song
- - volume (volume is published only via player status, and not separatly to avoid too many Threads)
- - ...
-2) Elapsed time: published every 250 ms, unless constant
- - elapsed
-3) Folder Config: published only on change
- This belongs to the folder being played
- Publish:
- - random, resume, single, loop
- On save store this information:
- Contains the information for resume functionality of each folder
- - random, resume, single, loop
- - if resume:
- - current song, elapsed
- - what is PLAYSTATUS for?
- When to save
- - on stop
- Angstsave:
- - on pause (only if box get turned off without proper shutdown - else stop gets implicitly called)
- - on status change of random, resume, single, loop (for resume omit current status if currently playing- this has now meaning)
- Load checks:
- - if resume, but no song, elapsed -> log error and start from the beginning
+
-Status storing:
- - Folder config for each folder (see above)
- - Information to restart last folder playback, which is:
- - last_folder -> folder_on_close
- - song, elapsed
- - random, resume, single, loop
- - if resume is enabled, after start we need to set last_played_folder, such that card swipe is detected as second swipe?!
- on the other hand: if resume is enabled, this is also saved to folder.config -> and that is checked by play card
+#### register
-Internal status
- - last played folder: Needed to detect second swipe
+```python
+def register(func: Callable[[str, str], None])
+```
+Add a new callback function :attr:`func`.
-Saving {'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'},
-'audio_folder_status':
-{'TraumfaengerStarkeLieder': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'stop', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'},
-'Giraffenaffen': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'play', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}}}
+Callback signature is
-References:
-https://github.com/Mic92/python-mpd2
-https://python-mpd2.readthedocs.io/en/latest/topics/commands.html
-https://mpd.readthedocs.io/en/latest/protocol.html
+.. py:function:: func(card_driver: str, device_name: str)
+ :noindex:
-sudo -u mpd speaker-test -t wav -c 2
+**Arguments**:
+- `card_driver`: The PulseAudio card driver module,
+e.g. :data:`module-bluez5-device.c` or :data:`module-alsa-card.c`
+- `device_name`: The sound card device name as reported
+in device properties
-
+
-## PlayerMPD Objects
+#### run\_callbacks
```python
-class PlayerMPD()
+def run_callbacks(sink_name, alias, sink_index, error_state)
```
-Interface to MPD Music Player Daemon
-
+
-#### mpd\_retry\_with\_mutex
+#### toggle\_on\_connect
```python
-def mpd_retry_with_mutex(mpd_cmd, *args)
+@property
+def toggle_on_connect()
```
-This method adds thread saftey for acceses to mpd via a mutex lock,
+Returns :data:`True` if the sound card shall be changed when a new card connects/disconnects. Setting this
-it shall be used for each access to mpd to ensure thread safety
-In case of a communication error the connection will be reestablished and the pending command will be repeated 2 times
+property changes the behavior.
-I think this should be refactored to a decorator
+> [!NOTE]
+> A new card is always assumed to be the secondary device from the audio configuration.
+> At the moment there is no check it actually is the configured device. This means any new
+> device connection will initiate the toggle. This, however, is no real issue as the RPi's audio
+> system will be relatively stable once setup
-
+
-#### pause
+#### toggle\_on\_connect
```python
-@plugs.tag
-def pause(state: int = 1)
+@toggle_on_connect.setter
+def toggle_on_connect(state=True)
```
-Enforce pause to state (1: pause, 0: resume)
-
-This is what you want as card removal action: pause the playback, so it can be resumed when card is placed
-on the reader again. What happens on re-placement depends on configured second swipe option
+Toggle Doc 2
-
+
-#### next
+#### stop
```python
-@plugs.tag
-def next()
+def stop()
```
-Play next track in current playlist
+Stop the pulse monitor thread
-
+
-#### rewind
+#### run
```python
-@plugs.tag
-def rewind()
+def run() -> None
```
-Re-start current playlist from first track
-
-Note: Will not re-read folder config, but leave settings untouched
+Starts the pulse monitor thread
-
+
-#### replay
+## PulseVolumeControl Objects
```python
-@plugs.tag
-def replay()
+class PulseVolumeControl()
```
-Re-start playing the last-played folder
-
-Will reset settings to folder config
-
+Volume control manager for PulseAudio
-
+When accessing the pulse library, it needs to be put into a special
+state. Which is ensured by the context manager
-#### toggle
+ with pulse_monitor as pulse ...
-```python
-@plugs.tag
-def toggle()
-```
-Toggle pause state, i.e. do a pause / resume depending on current state
+All private functions starting with `_function_name` assume that this is ensured by
+the calling function. All user functions acquire proper context!
-
+
-#### replay\_if\_stopped
+## OutputChangeCallbackHandler Objects
```python
-@plugs.tag
-def replay_if_stopped()
+class OutputChangeCallbackHandler(CallbackHandler)
```
-Re-start playing the last-played folder unless playlist is still playing
+Callbacks are executed when
-> [!NOTE]
-> To me this seems much like the behaviour of play,
-> but we keep it as it is specifically implemented in box 2.X
+* audio sink is changed
-
+
-#### play\_card
+#### register
```python
-@plugs.tag
-def play_card(folder: str, recursive: bool = False)
+def register(func: Callable[[str, str, int, int], None])
```
-Main entry point for trigger music playing from RFID reader. Decodes second swipe options before playing folder content
+Add a new callback function :attr:`func`.
-Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action
-accordingly.
+Parameters always give the valid audio sink. That means, if an error
+occurred, all parameters are valid.
+
+Callback signature is
+
+.. py:function:: func(sink_name: str, alias: str, sink_index: int, error_state: int)
+ :noindex:
**Arguments**:
-- `folder`: Folder path relative to music library path
-- `recursive`: Add folder recursively
+- `sink_name`: PulseAudio's sink name
+- `alias`: The alias for :attr:`sink_name`
+- `sink_index`: The index of the sink in the configuration list
+- `error_state`: 1 if there was an attempt to change the output
+but an error occurred. Above parameters always give the now valid sink!
+If a sink change is successful, it is 0.
-
+
-#### get\_single\_coverart
+#### run\_callbacks
```python
-@plugs.tag
-def get_single_coverart(song_url)
+def run_callbacks(sink_name, alias, sink_index, error_state)
```
-Saves the album art image to a cache and returns the filename.
-
+
-#### get\_folder\_content
+## OutputVolumeCallbackHandler Objects
```python
-@plugs.tag
-def get_folder_content(folder: str)
+class OutputVolumeCallbackHandler(CallbackHandler)
```
-Get the folder content as content list with meta-information. Depth is always 1.
-
-Call repeatedly to descend in hierarchy
+Callbacks are executed when
-**Arguments**:
+* audio volume level is changed
-- `folder`: Folder path relative to music library path
-
+
-#### play\_folder
+#### register
```python
-@plugs.tag
-def play_folder(folder: str, recursive: bool = False) -> None
+def register(func: Callable[[int, bool, bool], None])
```
-Playback a music folder.
+Add a new callback function :attr:`func`.
-Folder content is added to the playlist as described by :mod:`jukebox.playlistgenerator`.
-The playlist is cleared first.
+Callback signature is
+
+.. py:function:: func(volume: int, is_min: bool, is_max: bool)
+ :noindex:
**Arguments**:
-- `folder`: Folder path relative to music library path
-- `recursive`: Add folder recursively
+- `volume`: Volume level
+- `is_min`: 1, if volume level is minimum, else 0
+- `is_max`: 1, if volume level is maximum, else 0
-
+
-#### play\_album
+#### run\_callbacks
```python
-@plugs.tag
-def play_album(albumartist: str, album: str)
+def run_callbacks(sink_name, alias, sink_index, error_state)
```
-Playback a album found in MPD database.
-All album songs are added to the playlist
-The playlist is cleared first.
-**Arguments**:
+
-- `albumartist`: Artist of the Album provided by MPD database
-- `album`: Album name provided by MPD database
+#### toggle\_output
-
+```python
+@plugin.tag
+def toggle_output()
+```
+
+Toggle the audio output sink
+
+
+
+
+#### get\_outputs
+
+```python
+@plugin.tag
+def get_outputs()
+```
+
+Get current output and list of outputs
+
+
+
+
+#### publish\_volume
+
+```python
+@plugin.tag
+def publish_volume()
+```
+
+Publish (volume, mute)
+
+
+
+
+#### publish\_outputs
+
+```python
+@plugin.tag
+def publish_outputs()
+```
+
+Publish current output and list of outputs
+
+
+
+
+#### set\_volume
+
+```python
+@plugin.tag
+def set_volume(volume: int)
+```
+
+Set the volume (0-100) for the currently active output
+
+
+
#### get\_volume
```python
+@plugin.tag
def get_volume()
```
-Get the current volume
+Get the volume
-For volume control do not use directly, but use through the plugin 'volume',
-as the user may have configured a volume control manager other than MPD
+
-
+#### change\_volume
-#### set\_volume
+```python
+@plugin.tag
+def change_volume(step: int)
+```
+
+Increase/decrease the volume by step for the currently active output
+
+
+
+
+#### get\_mute
```python
-def set_volume(volume)
+@plugin.tag
+def get_mute()
```
-Set the volume
+Return mute status for the currently active output
-For volume control do not use directly, but use through the plugin 'volume',
-as the user may have configured a volume control manager other than MPD
+
-
+#### mute
-#### play\_card\_callbacks
+```python
+@plugin.tag
+def mute(mute=True)
+```
-Callback handler instance for play_card events.
+Set mute status for the currently active output
-- is executed when play_card function is called
-States:
-- See :class:`PlayCardState`
-See :class:`PlayContentCallbacks`
+
-
+#### set\_output
+
+```python
+@plugin.tag
+def set_output(sink_index: int)
+```
+
+Set the active output (sink_index = 0: primary, 1: secondary)
+
+
+
+
+#### set\_soft\_max\_volume
+
+```python
+@plugin.tag
+def set_soft_max_volume(max_volume: int)
+```
+
+Limit the maximum volume to max_volume for the currently active output
+
+
+
+
+#### get\_soft\_max\_volume
+
+```python
+@plugin.tag
+def get_soft_max_volume()
+```
+
+Return the maximum volume limit for the currently active output
+
+
+
+
+#### card\_list
+
+```python
+def card_list() -> List[pulsectl.PulseCardInfo]
+```
+
+Return the list of present sound card
-# components.playermpd.coverart\_cache\_manager
@@ -1114,6 +1154,14 @@ This file provides definitions for RPC command aliases
See [RPC Commands](../../builders/rpc-commands.md)
+
+
+# components.synchronisation
+
+
+
+# components.synchronisation.syncutils
+
# components.synchronisation.rfidcards
@@ -1206,2862 +1254,2875 @@ Sync the folder from the remote server, if existing
- `folder`: Folder path relative to music library path
-
-
-# components.synchronisation
+
-
+# components.jingle.jinglemp3
-# components.synchronisation.syncutils
+Generic MP3 jingle Service for jingle.JingleFactory
-
-# components.volume
+
-PulseAudio Volume Control Plugin Package
+## JingleMp3Play Objects
-## Features
+```python
+@plugin.register(auto_tag=True)
+class JingleMp3Play()
+```
-* Volume Control
-* Two outputs
-* Watcher thread on volume / output change
+Jingle Service for playing MP3 files
-## Publishes
-* volume.level
-* volume.sink
+
-## PulseAudio References
+#### play
-
+```python
+def play(filename)
+```
-Check fallback device (on device de-connect):
+Play the MP3 file
- $ pacmd list-sinks | grep -e 'name:' -e 'index'
+
-## Integration
+## JingleMp3PlayBuilder Objects
-Pulse Audio runs as a user process. Processes who want to communicate / stream to it
-must also run as a user process.
+```python
+class JingleMp3PlayBuilder()
+```
-This means must also run as user process, as described in
-[Music Player Daemon](../../builders/system.md#music-player-daemon-mpd).
+
-## Misc
+#### \_\_init\_\_
-PulseAudio may switch the sink automatically to a connecting bluetooth device depending on the loaded module
-with name module-switch-on-connect. On Raspberry Pi OS Bullseye, this module is not part of the default configuration
-in ``/usr/pulse/default.pa``. So, we don't need to worry about it.
-If the module gets loaded it conflicts with the toggle on connect and the selected primary / secondary outputs
-from the Jukebox. Remove it from the configuration!
+```python
+def __init__()
+```
- ### Use hot-plugged devices like Bluetooth or USB automatically (LP: `1702794`)
- ### not available on PI?
- .ifexists module-switch-on-connect.so
- load-module module-switch-on-connect
- .endif
+Builder instantiates JingleMp3Play during init and not during first call because
-## Why PulseAudio?
+we want JingleMp3Play registers as plugin function in any case if this plugin is loaded
+(and not only on first use!)
-The audio configuration of the system is one of those topics,
-which has a myriad of options and possibilities. Every system is different and PulseAudio unifies these and
-makes our life easier. Besides, it is only option to support Bluetooth at the moment.
-## Callbacks:
+
-The following callbacks are provided. Register callbacks with these adder functions (see their documentation for details):
+# components.jingle
-1. :func:`add_on_connect_callback`
-2. :func:`add_on_output_change_callbacks`
-3. :func:`add_on_volume_change_callback`
+Jingle Playback Factory for extensible run-time support of various file types
-
+
-## PulseMonitor Objects
+## JingleFactory Objects
```python
-class PulseMonitor(threading.Thread)
+class JingleFactory()
```
-A thread for monitoring and interacting with the Pulse Lib via pulsectrl
-
-Whenever we want to access pulsectl, we need to exit the event listen loop.
-This is handled by the context manager. It stops the event loop and returns
-the pulsectl instance to be used (it does no return the monitor thread itself!)
-
-The context manager also locks the module to ensure proper thread sequencing,
-as only a single thread may work with pulsectl at any time. Currently, an RLock is
-used, even if it may not be necessary
+Jingle Factory
-
+
-## SoundCardConnectCallbacks Objects
+#### list
```python
-class SoundCardConnectCallbacks(CallbackHandler)
+def list()
```
-Callbacks are executed when
-
-* new sound card gets connected
+List the available volume services
-
+
-#### register
+#### play
```python
-def register(func: Callable[[str, str], None])
+@plugin.register
+def play(filename)
```
-Add a new callback function :attr:`func`.
+Play the jingle using the configured jingle service
-Callback signature is
+> [!NOTE]
+> This runs in a separate thread. And this may cause troubles
+> when changing the volume level before
+> and after the sound playback: There is nothing to prevent another
+> thread from changing the volume and sink while playback happens
+> and afterwards we change the volume back to where it was before!
-.. py:function:: func(card_driver: str, device_name: str)
- :noindex:
+There is no way around this dilemma except for not running the jingle as a
+separate thread. Currently (as thread) even the RPC is started before the sound
+is finished and the volume is reset to normal...
-**Arguments**:
+However: Volume plugin is loaded before jingle and sets the default
+volume. No interference here. It can now only happen
+if (a) through the RPC or (b) some other plugin the volume is changed. Okay, now
+(a) let's hope that there is enough delay in the user requesting a volume change
+(b) let's hope no other plugin wants to do that
+(c) no bluetooth device connects during this time (and pulseaudio control is set to toggle_on_connect)
+and take our changes with the threaded approach.
-- `card_driver`: The PulseAudio card driver module,
-e.g. :data:`module-bluez5-device.c` or :data:`module-alsa-card.c`
-- `device_name`: The sound card device name as reported
-in device properties
-
+
-#### run\_callbacks
+#### play\_startup
```python
-def run_callbacks(sink_name, alias, sink_index, error_state)
+@plugin.register
+def play_startup()
```
+Play the startup sound (using jingle.play)
-
+
-#### toggle\_on\_connect
+#### play\_shutdown
```python
-@property
-def toggle_on_connect()
+@plugin.register
+def play_shutdown()
```
-Returns :data:`True` if the sound card shall be changed when a new card connects/disconnects. Setting this
+Play the shutdown sound (using jingle.play)
-property changes the behavior.
-> [!NOTE]
-> A new card is always assumed to be the secondary device from the audio configuration.
-> At the moment there is no check it actually is the configured device. This means any new
-> device connection will initiate the toggle. This, however, is no real issue as the RPi's audio
-> system will be relatively stable once setup
+
+# components.jingle.alsawave
-
+ALSA wave jingle Service for jingle.JingleFactory
-#### toggle\_on\_connect
+
+
+
+## AlsaWave Objects
```python
-@toggle_on_connect.setter
-def toggle_on_connect(state=True)
+@plugin.register
+class AlsaWave()
```
-Toggle Doc 2
+Jingle Service for playing wave files directly from Python through ALSA
-
+
-#### stop
+#### play
```python
-def stop()
+@plugin.tag
+def play(filename)
```
-Stop the pulse monitor thread
+Play the wave file
-
+
-#### run
+## AlsaWaveBuilder Objects
```python
-def run() -> None
+class AlsaWaveBuilder()
```
-Starts the pulse monitor thread
-
-
-
+
-## PulseVolumeControl Objects
+#### \_\_init\_\_
```python
-class PulseVolumeControl()
+def __init__()
```
-Volume control manager for PulseAudio
-
-When accessing the pulse library, it needs to be put into a special
-state. Which is ensured by the context manager
+Builder instantiates AlsaWave during init and not during first call because
- with pulse_monitor as pulse ...
+we want AlsaWave registers as plugin function in any case if this plugin is loaded
+(and not only on first use!)
-All private functions starting with `_function_name` assume that this is ensured by
-the calling function. All user functions acquire proper context!
+
+# components.hostif.linux
-
+
-## OutputChangeCallbackHandler Objects
+#### shutdown
```python
-class OutputChangeCallbackHandler(CallbackHandler)
+@plugin.register
+def shutdown()
```
-Callbacks are executed when
-
-* audio sink is changed
+Shutdown the host machine
-
+
-#### register
+#### reboot
```python
-def register(func: Callable[[str, str, int, int], None])
+@plugin.register
+def reboot()
```
-Add a new callback function :attr:`func`.
-
-Parameters always give the valid audio sink. That means, if an error
-occurred, all parameters are valid.
-
-Callback signature is
-
-.. py:function:: func(sink_name: str, alias: str, sink_index: int, error_state: int)
- :noindex:
-
-**Arguments**:
+Reboot the host machine
-- `sink_name`: PulseAudio's sink name
-- `alias`: The alias for :attr:`sink_name`
-- `sink_index`: The index of the sink in the configuration list
-- `error_state`: 1 if there was an attempt to change the output
-but an error occurred. Above parameters always give the now valid sink!
-If a sink change is successful, it is 0.
-
+
-#### run\_callbacks
+#### jukebox\_is\_service
```python
-def run_callbacks(sink_name, alias, sink_index, error_state)
+@plugin.register
+def jukebox_is_service()
```
+Check if current Jukebox process is running as a service
-
+
-## OutputVolumeCallbackHandler Objects
+#### is\_any\_jukebox\_service\_active
```python
-class OutputVolumeCallbackHandler(CallbackHandler)
+@plugin.register
+def is_any_jukebox_service_active()
```
-Callbacks are executed when
+Check if a Jukebox service is running
-* audio volume level is changed
+> [!NOTE]
+> Does not have the be the current app, that is running as a service!
-
+
-#### register
+#### restart\_service
```python
-def register(func: Callable[[int, bool, bool], None])
+@plugin.register
+def restart_service()
```
-Add a new callback function :attr:`func`.
-
-Callback signature is
-
-.. py:function:: func(volume: int, is_min: bool, is_max: bool)
- :noindex:
-
-**Arguments**:
+Restart Jukebox App if running as a service
-- `volume`: Volume level
-- `is_min`: 1, if volume level is minimum, else 0
-- `is_max`: 1, if volume level is maximum, else 0
-
+
-#### run\_callbacks
+#### get\_disk\_usage
```python
-def run_callbacks(sink_name, alias, sink_index, error_state)
+@plugin.register()
+def get_disk_usage(path='/')
```
+Return the disk usage in Megabytes as dictionary for RPC export
-
+
-#### toggle\_output
+#### get\_cpu\_temperature
```python
-@plugin.tag
-def toggle_output()
+@plugin.register
+def get_cpu_temperature()
```
-Toggle the audio output sink
+Get the CPU temperature with single decimal point
+
+No error handling: this is expected to take place up-level!
-
+
-#### get\_outputs
+#### get\_ip\_address
```python
-@plugin.tag
-def get_outputs()
+@plugin.register
+def get_ip_address()
```
-Get current output and list of outputs
+Get the IP address
-
+
-#### publish\_volume
+#### wlan\_disable\_power\_down
```python
-@plugin.tag
-def publish_volume()
+@plugin.register()
+def wlan_disable_power_down(card=None)
```
-Publish (volume, mute)
+Turn off power management of wlan. Keep RPi reachable via WLAN
+This must be done after every reboot
+card=None takes card from configuration file
-
-#### publish\_outputs
+
+
+#### get\_autohotspot\_status
```python
-@plugin.tag
-def publish_outputs()
+@plugin.register
+def get_autohotspot_status()
```
-Publish current output and list of outputs
+Get the status of the auto hotspot feature
-
+
-#### set\_volume
+#### stop\_autohotspot
```python
-@plugin.tag
-def set_volume(volume: int)
+@plugin.register()
+def stop_autohotspot()
```
-Set the volume (0-100) for the currently active output
+Stop auto hotspot functionality
+Stopping and disabling the timer and running the service one last time manually
-
-#### get\_volume
+
+
+#### start\_autohotspot
```python
-@plugin.tag
-def get_volume()
+@plugin.register()
+def start_autohotspot()
```
-Get the volume
-
-
-
-
-#### change\_volume
+Start auto hotspot functionality
-```python
-@plugin.tag
-def change_volume(step: int)
-```
+Enabling and starting the timer (timer will start the service)
-Increase/decrease the volume by step for the currently active output
+
-
+# components.timers
-#### get\_mute
+
-```python
-@plugin.tag
-def get_mute()
-```
+# components.playermpd.coverart\_cache\_manager
-Return mute status for the currently active output
+
+# components.playermpd.playcontentcallback
-
+
-#### mute
+## PlayContentCallbacks Objects
```python
-@plugin.tag
-def mute(mute=True)
+class PlayContentCallbacks(Generic[STATE], CallbackHandler)
```
-Set mute status for the currently active output
+Callbacks are executed in various play functions
-
+
-#### set\_output
+#### register
```python
-@plugin.tag
-def set_output(sink_index: int)
+def register(func: Callable[[str, STATE], None])
```
-Set the active output (sink_index = 0: primary, 1: secondary)
-
-
-
+Add a new callback function :attr:`func`.
-#### set\_soft\_max\_volume
+Callback signature is
-```python
-@plugin.tag
-def set_soft_max_volume(max_volume: int)
-```
+.. py:function:: func(folder: str, state: STATE)
+ :noindex:
-Limit the maximum volume to max_volume for the currently active output
+**Arguments**:
+- `folder`: relativ path to folder to play
+- `state`: indicator of the state inside the calling
-
+
-#### get\_soft\_max\_volume
+#### run\_callbacks
```python
-@plugin.tag
-def get_soft_max_volume()
+def run_callbacks(folder: str, state: STATE)
```
-Return the maximum volume limit for the currently active output
-
+
-#### card\_list
+# components.playermpd
-```python
-def card_list() -> List[pulsectl.PulseCardInfo]
-```
+Package for interfacing with the MPD Music Player Daemon
-Return the list of present sound card
+Status information in three topics
+1) Player Status: published only on change
+ This is a subset of the MPD status (and not the full MPD status) ??
+ - folder
+ - song
+ - volume (volume is published only via player status, and not separatly to avoid too many Threads)
+ - ...
+2) Elapsed time: published every 250 ms, unless constant
+ - elapsed
+3) Folder Config: published only on change
+ This belongs to the folder being played
+ Publish:
+ - random, resume, single, loop
+ On save store this information:
+ Contains the information for resume functionality of each folder
+ - random, resume, single, loop
+ - if resume:
+ - current song, elapsed
+ - what is PLAYSTATUS for?
+ When to save
+ - on stop
+ Angstsave:
+ - on pause (only if box get turned off without proper shutdown - else stop gets implicitly called)
+ - on status change of random, resume, single, loop (for resume omit current status if currently playing- this has now meaning)
+ Load checks:
+ - if resume, but no song, elapsed -> log error and start from the beginning
+Status storing:
+ - Folder config for each folder (see above)
+ - Information to restart last folder playback, which is:
+ - last_folder -> folder_on_close
+ - song, elapsed
+ - random, resume, single, loop
+ - if resume is enabled, after start we need to set last_played_folder, such that card swipe is detected as second swipe?!
+ on the other hand: if resume is enabled, this is also saved to folder.config -> and that is checked by play card
-
+Internal status
+ - last played folder: Needed to detect second swipe
-# components.rfid
-
+Saving {'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'},
+'audio_folder_status':
+{'TraumfaengerStarkeLieder': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'stop', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'},
+'Giraffenaffen': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'play', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}}}
-# components.rfid.reader
+References:
+https://github.com/Mic92/python-mpd2
+https://python-mpd2.readthedocs.io/en/latest/topics/commands.html
+https://mpd.readthedocs.io/en/latest/protocol.html
-
+sudo -u mpd speaker-test -t wav -c 2
-## RfidCardDetectCallbacks Objects
+
+
+
+## PlayerMPD Objects
```python
-class RfidCardDetectCallbacks(CallbackHandler)
+class PlayerMPD()
```
-Callbacks are executed if rfid card is detected
+Interface to MPD Music Player Daemon
-
+
-#### register
+#### mpd\_retry\_with\_mutex
```python
-def register(func: Callable[[str, RfidCardDetectState], None])
+def mpd_retry_with_mutex(mpd_cmd, *args)
```
-Add a new callback function :attr:`func`.
-
-Callback signature is
+This method adds thread saftey for acceses to mpd via a mutex lock,
-.. py:function:: func(card_id: str, state: int)
- :noindex:
+it shall be used for each access to mpd to ensure thread safety
+In case of a communication error the connection will be reestablished and the pending command will be repeated 2 times
-**Arguments**:
+I think this should be refactored to a decorator
-- `card_id`: Card ID
-- `state`: See `RfidCardDetectState`
-
+
-#### run\_callbacks
+#### pause
```python
-def run_callbacks(card_id: str, state: RfidCardDetectState)
+@plugs.tag
+def pause(state: int = 1)
```
+Enforce pause to state (1: pause, 0: resume)
+This is what you want as card removal action: pause the playback, so it can be resumed when card is placed
+on the reader again. What happens on re-placement depends on configured second swipe option
-
-#### rfid\_card\_detect\_callbacks
+
-Callback handler instance for rfid_card_detect_callbacks events.
+#### next
-See [`RfidCardDetectCallbacks`](#components.rfid.reader.RfidCardDetectCallbacks)
+```python
+@plugs.tag
+def next()
+```
+
+Play next track in current playlist
-
+
-## CardRemovalTimerClass Objects
+#### rewind
```python
-class CardRemovalTimerClass(threading.Thread)
+@plugs.tag
+def rewind()
```
-A timer watchdog thread that calls timeout_action on time-out
+Re-start current playlist from first track
+Note: Will not re-read folder config, but leave settings untouched
-
-#### \_\_init\_\_
+
+
+#### replay
```python
-def __init__(on_timeout_callback, logger: logging.Logger = None)
+@plugs.tag
+def replay()
```
-**Arguments**:
+Re-start playing the last-played folder
-- `on_timeout_callback`: The function to execute on time-out
+Will reset settings to folder config
-
-# components.rfid.configure
+
-
+#### toggle
-#### reader\_install\_dependencies
+```python
+@plugs.tag
+def toggle()
+```
+
+Toggle pause state, i.e. do a pause / resume depending on current state
+
+
+
+
+#### replay\_if\_stopped
```python
-def reader_install_dependencies(reader_path: str,
- dependency_install: str) -> None
+@plugs.tag
+def replay_if_stopped()
```
-Install dependencies for the selected reader module
+Re-start playing the last-played folder unless playlist is still playing
-**Arguments**:
+> [!NOTE]
+> To me this seems much like the behaviour of play,
+> but we keep it as it is specifically implemented in box 2.X
-- `reader_path`: Path to the reader module
-- `dependency_install`: how to handle installing of dependencies
-'query': query user (default)
-'auto': automatically
-'no': don't install dependencies
-
+
-#### reader\_load\_module
+#### play\_card
```python
-def reader_load_module(reader_name)
+@plugs.tag
+def play_card(folder: str, recursive: bool = False)
```
-Load the module for the reader_name
+Main entry point for trigger music playing from RFID reader. Decodes second swipe options before playing folder content
-A ModuleNotFoundError is unrecoverable, but we at least want to give some hint how to resolve that to the user
-All other errors will NOT be handled. Modules that do not load due to compile errors have other problems
+Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action
+accordingly.
**Arguments**:
-- `reader_name`: Name of the reader to load the module for
-
-**Returns**:
-
-module
+- `folder`: Folder path relative to music library path
+- `recursive`: Add folder recursively
-
+
-#### query\_user\_for\_reader
+#### flush\_coverart\_cache
```python
-def query_user_for_reader(dependency_install='query') -> dict
+@plugs.tag
+def flush_coverart_cache()
```
-Ask the user to select a RFID reader and prompt for the reader's configuration
+Deletes the Cover Art Cache
-This function performs the following steps, to find and present all available readers to the user
-
-- search for available reader subpackages
-- dynamically load the description module for each reader subpackage
-- queries user for selection
-- if no_dep_install=False, install dependencies as given by requirements.txt and execute setup.inc.sh of subpackage
-- dynamically load the actual reader module from the reader subpackage
-- if selected reader has customization options query user for that now
-- return configuration
-
-There are checks to make sure we have the right reader modules and they are what we expect.
-The are as few requirements towards the reader module as possible and everything else is optional
-(see reader_template for these requirements)
-However, there is no error handling w.r.t to user input and reader's query_config. Firstly, in this script
-we cannot gracefully handle an exception that occurs on reader level, and secondly the exception will simply
-exit the script w/o writing the config to file. No harm done.
-This script expects to reside in the directory with all the reader subpackages, i.e it is part of the rfid-reader package.
-Otherwise you'll need to adjust sys.path
-
-**Arguments**:
-
-- `dependency_install`: how to handle installing of dependencies
-'query': query user (default)
-'auto': automatically
-'no': don't install dependencies
-
-**Returns**:
-
-`dict as {section: {parameter: value}}`: nested dict with entire configuration that can be read into ConfigParser
-
-
+
-#### write\_config
+#### get\_folder\_content
```python
-def write_config(config_file: str,
- config_dict: dict,
- force_overwrite=False) -> None
+@plugs.tag
+def get_folder_content(folder: str)
```
-Write configuration to config_file
+Get the folder content as content list with meta-information. Depth is always 1.
-**Arguments**:
+Call repeatedly to descend in hierarchy
-- `config_file`: relative or absolute path to config file
-- `config_dict`: nested dict with configuration parameters for ConfigParser consumption
-- `force_overwrite`: overwrite existing configuration file without asking
+**Arguments**:
-
+- `folder`: Folder path relative to music library path
-# components.rfid.hardware.fake\_reader\_gui.fake\_reader\_gui
+
-
+#### play\_folder
-# components.rfid.hardware.fake\_reader\_gui.description
+```python
+@plugs.tag
+def play_folder(folder: str, recursive: bool = False) -> None
+```
-
+Playback a music folder.
-# components.rfid.hardware.fake\_reader\_gui.gpioz\_gui\_addon
+Folder content is added to the playlist as described by :mod:`jukebox.playlistgenerator`.
+The playlist is cleared first.
-Add GPIO input devices and output devices to the RFID Mock Reader GUI
+**Arguments**:
+- `folder`: Folder path relative to music library path
+- `recursive`: Add folder recursively
-
+
-#### create\_inputs
+#### play\_album
```python
-def create_inputs(frame, default_btn_width, default_padx, default_pady)
+@plugs.tag
+def play_album(albumartist: str, album: str)
```
-Add all input devies to the GUI
-
-**Arguments**:
+Playback a album found in MPD database.
-- `frame`: The TK frame (e.g. LabelFrame) in the main GUI to add the buttons to
+All album songs are added to the playlist
+The playlist is cleared first.
-**Returns**:
+**Arguments**:
-List of all added GUI buttons
+- `albumartist`: Artist of the Album provided by MPD database
+- `album`: Album name provided by MPD database
-
+
-#### set\_state
+#### get\_volume
```python
-def set_state(value, box_state_var)
+def get_volume()
```
-Change the value of a checkbox state variable
+Get the current volume
+
+For volume control do not use directly, but use through the plugin 'volume',
+as the user may have configured a volume control manager other than MPD
-
+
-#### que\_set\_state
+#### set\_volume
```python
-def que_set_state(value, box_state_var)
+def set_volume(volume)
```
-Queue the action to change a checkbox state variable to the TK GUI main thread
+Set the volume
+For volume control do not use directly, but use through the plugin 'volume',
+as the user may have configured a volume control manager other than MPD
-
-#### fix\_state
+
-```python
-def fix_state(box_state_var)
-```
+#### play\_card\_callbacks
-Prevent a checkbox state variable to change on checkbox mouse press
+Callback handler instance for play_card events.
+- is executed when play_card function is called
+States:
+- See :class:`PlayCardState`
+See :class:`PlayContentCallbacks`
-
-#### pbox\_set\_state
+
-```python
-def pbox_set_state(value, pbox_state_var, label_var)
-```
+# components.gpio.gpioz.core.converter
-Update progress bar state and related state label
+Provides converter functions/classes for various Jukebox parameters to
+values that can be assigned to GPIO output devices
-
-#### que\_set\_pbox
+
+
+## ColorProperty Objects
```python
-def que_set_pbox(value, pbox_state_var, label_var)
+class ColorProperty()
```
-Queue the action to change the progress bar state to the TK GUI main thread
+Color descriptor ensuring valid weight ranges
-
+
-#### create\_outputs
+## VolumeToRGB Objects
```python
-def create_outputs(frame, default_btn_width, default_padx, default_pady)
+class VolumeToRGB()
```
-Add all output devices to the GUI
+Converts linear volume level to an RGB color value running through the color spectrum
**Arguments**:
-- `frame`: The TK frame (e.g. LabelFrame) in the main GUI to add the representations to
-
-**Returns**:
-
-List of all added GUI objects
-
-
-
-# components.rfid.hardware.generic\_nfcpy.description
-
-List of supported devices https://nfcpy.readthedocs.io/en/latest/overview.html
-
+- `max_input`: Maximum input value of linear input data
+- `offset`: Offset in degrees in the color circle. Color circle
+traverses blue (0), cyan(60), green (120), yellow(180), red (240), magenta (340)
+- `section`: The section of the full color circle to use in degrees
+Map input :data:`0...100` to color range :data:`green...magenta` and get the color for level 50
-
+ conv = VolumeToRGB(100, offset=120, section=180)
+ (r, g, b) = conv(50)
-# components.rfid.hardware.generic\_nfcpy.generic\_nfcpy
+The three components of an RGB LEDs do not have the same luminosity.
+Weight factors are used to get a balanced color output
-
+
-## ReaderClass Objects
+#### \_\_call\_\_
```python
-class ReaderClass(ReaderBaseClass)
+def __call__(volume) -> Tuple[float, float, float]
```
-The reader class for nfcpy supported NFC card readers.
+Perform conversion for single volume level
+**Returns**:
-
+Tuple(red, green, blue)
-#### cleanup
+
+
+#### luminize
```python
-def cleanup()
+def luminize(r, g, b)
```
-The cleanup function: free and release all resources used by this card reader (if any).
-
+Apply the color weight factors to the input color values
-
-#### stop
+
-```python
-def stop()
-```
+# components.gpio.gpioz.core.mock
-This function is called to tell the reader to exit its reading function.
+Changes to the GPIOZero devices for using with the Mock RFID Reader
-
+
-#### read\_card
+#### patch\_mock\_outputs\_with\_callback
```python
-def read_card() -> str
+def patch_mock_outputs_with_callback()
```
-Blocking or non-blocking function that waits for a new card to appear and return the card's UID as string
+Monkey Patch LED + Buzzer to get a callback when state changes
+This targets to represent the state in the TK GUI.
+Other output devices cannot be represented in the GUI and are silently ignored.
-
+> [!NOTE]
+> Only for developing purposes!
-# components.rfid.hardware.generic\_usb.description
-
+
-# components.rfid.hardware.generic\_usb.generic\_usb
+# components.gpio.gpioz.core.output\_devices
-
+Provides all supported output devices for the GPIOZ plugin.
-# components.rfid.hardware.rc522\_spi.description
+For each device all constructor parameters can be set via the configuration file. Only exceptions
+are the :attr:`name` and :attr:`pin_factory` which are set by internal mechanisms.
-
+The devices a are a relatively thin wrapper around the GPIOZero devices with the same name.
+We add a name property to be used for error log message and similar and a :func:`flash` function
+to all devices. This function provides a unified API to all devices. This means it can be called for every device
+with parameters for this device and optional parameters from another device. Unused/unsupported parameters
+are silently ignored. This is done to reduce the amount of coding required for connectivity functions.
-# components.rfid.hardware.rc522\_spi.rc522\_spi
+For examples how to use the devices from the configuration files, see
+[GPIO: Output Devices](../../builders/gpio.md#output-devices).
-
-# components.rfid.hardware.pn532\_i2c\_py532.description
+
-
+## LED Objects
-# components.rfid.hardware.pn532\_i2c\_py532.pn532\_i2c\_py532
+```python
+class LED(NameMixin, gpiozero.LED)
+```
-
+A binary LED
-# components.rfid.hardware.rdm6300\_serial.rdm6300\_serial
+**Arguments**:
-
+- `pin`: The GPIO pin which the LED is connected
+- `active_high`: If :data:`true` the output pin will have a high logic level when the device is turned on.
+- `pin_factory`: The GPIOZero pin factory. This parameter cannot be set through the configuration file
+- `name` (`str`): The name of the button for use in error messages. This parameter cannot be set explicitly
+through the configuration file
-#### decode
+
+
+#### flash
```python
-def decode(raw_card_id: bytearray, number_format: int) -> str
+def flash(on_time=1, off_time=1, n=1, *, background=True, **ignored_kwargs)
```
-Decode the RDM6300 data format into actual card ID
-
-
-
-
-# components.rfid.hardware.rdm6300\_serial.description
-
-
-
-# components.rfid.hardware.template\_new\_reader.description
-
-Provide a short title for this reader.
-
-This is what that user will see when asked for selecting his RFID reader
-So, be precise but readable. Precise means 40 characters or less
-
+Exactly like :func:`blink` but restores the original state after flashing the device
-
+**Arguments**:
-# components.rfid.hardware.template\_new\_reader.template\_new\_reader
+- `on_time` (`float`): Number of seconds on. Defaults to 1 second.
+- `off_time` (`float`): Number of seconds off. Defaults to 1 second.
+- `n`: Number of times to blink; :data:`None` means forever.
+- `background` (`bool`): If :data:`True` (the default), start a background thread to
+continue blinking and return immediately. If :data:`False`, only
+return when the blink is finished
+- `ignored_kwargs`: Ignore all other keywords so this function can be called with identical
+parameters also for all other output devices
-
+
-#### query\_customization
+## Buzzer Objects
```python
-def query_customization() -> dict
+class Buzzer(NameMixin, gpiozero.Buzzer)
```
-Query the user for reader parameter customization
-
-This function will be called during the configuration/setup phase when the user selects this reader module.
-It must return all configuration parameters that are necessary to later use the Reader class.
-You can ask the user for selections and choices. And/or provide default values.
-If your reader requires absolutely no configuration return {}
-
-
-
+
-## ReaderClass Objects
+#### flash
```python
-class ReaderClass(ReaderBaseClass)
+def flash(on_time=1, off_time=1, n=1, *, background=True, **ignored_kwargs)
```
-The actual reader class that is used to read RFID cards.
+Flash the device and restore the previous value afterwards
-It will be instantiated once and then read_card() is called in an endless loop.
-It will be used in a manner
- with Reader(reader_cfg_key) as reader:
- for card_id in reader:
- ...
-which ensures proper resource de-allocation. For this to work derive this class from ReaderBaseClass.
-All the required interfaces are implemented there.
+
-Put your code into these functions (see below for more information)
- - `__init__`
- - read_card
- - cleanup
- - stop
+## PWMLED Objects
+```python
+class PWMLED(NameMixin, gpiozero.PWMLED)
+```
-
+
-#### \_\_init\_\_
+#### flash
```python
-def __init__(reader_cfg_key)
+def flash(on_time=1,
+ off_time=1,
+ n=1,
+ *,
+ fade_in_time=0,
+ fade_out_time=0,
+ background=True,
+ **ignored_kwargs)
```
-In the constructor, you will get the `reader_cfg_key` with which you can access the configuration data
-
-As you are dealing directly with potentially user-manipulated config information, it is
-advisable to do some sanity checks and give useful error messages. Even if you cannot recover gracefully,
-a good error message helps :-)
+Flash the LED and restore the previous value afterwards
-
+
-#### cleanup
+## RGBLED Objects
```python
-def cleanup()
+class RGBLED(NameMixin, gpiozero.RGBLED)
```
-The cleanup function: free and release all resources used by this card reader (if any).
-
-Put all your cleanup code here, e.g. if you are using the serial bus or GPIO pins.
-Will be called implicitly via the __exit__ function
-This function must exist! If there is nothing to do, just leave the pass statement in place below
-
-
-
+
-#### stop
+#### flash
```python
-def stop()
+def flash(on_time=1,
+ off_time=1,
+ *,
+ fade_in_time=0,
+ fade_out_time=0,
+ on_color=(1, 1, 1),
+ off_color=(0, 0, 0),
+ n=None,
+ background=True,
+ **igorned_kwargs)
```
-This function is called to tell the reader to exist it's reading function.
-
-This function is called before cleanup is called.
-
-> [!NOTE]
-> This is usually called from a different thread than the reader's thread! And this is the reason for the
-> two-step exit strategy. This function works across threads to indicate to the reader that is should stop attempt
-> to read a card. Once called, the function read_card will not be called again. When the reader thread exits
-> cleanup is called from the reader thread itself.
+Flash the LED with :attr:`on_color` and restore the previous value afterwards
-
+
-#### read\_card
+## TonalBuzzer Objects
```python
-def read_card() -> str
+class TonalBuzzer(NameMixin, gpiozero.TonalBuzzer)
```
-Blocking or non-blocking function that waits for a new card to appear and return the card's UID as string
-
-This is were your main code goes :-)
-This function must return a string with the card id
-In case of error, it may return None or an empty string
+
-The function should break and return with an empty string, once stop() is called
+#### flash
+```python
+def flash(on_time=1,
+ off_time=1,
+ n=1,
+ *,
+ tone=None,
+ background=True,
+ **ignored_kwargs)
+```
-
+Play the tone :data:`tone` for :attr:`n` times
-# components.rfid.readerbase
-
+
-## ReaderBaseClass Objects
+#### melody
```python
-class ReaderBaseClass(ABC)
+def melody(on_time=0.2,
+ off_time=0.05,
+ *,
+ tone: Optional[List[Tone]] = None,
+ background=True)
```
-Abstract Base Class for all Reader Classes to ensure common API
-
-Look at template_new_reader.py for documentation how to integrate a new RFID reader
+Play a melody from the list of tones in :attr:`tone`
-
+
-# components.rfid.cards
+# components.gpio.gpioz.core.input\_devices
-Handling the RFID card database
+Provides all supported input devices for the GPIOZ plugin.
-A few considerations:
-- Changing the Card DB influences to current state
- - rfid.reader: Does not care, as it always freshly looks into the DB when a new card is triggered
- - fake_reader_gui: Initializes the Drop-down menu once on start --> Will get out of date!
+Input devices are based on GPIOZero devices. So for certain configuration parameters, you should
+their documentation.
-Do we need a notifier? Or a callback for modules to get notified?
-Do we want to publish the information about a card DB update?
-TODO: Add callback for on_database_change
+All callback handlers are replaced by GPIOZ callback handlers. These are usually configured
+by using the :func:`set_rpc_actions` each input device exhibits.
-TODO: check card id type (if int, convert to str)
-TODO: check if args is really a list (convert if not?)
+For examples how to use the devices from the configuration files, see
+[GPIO: Input Devices](../../builders/gpio.md#input-devices).
-
+
-#### list\_cards
+## NameMixin Objects
```python
-@plugs.register
-def list_cards()
+class NameMixin(ABC)
```
-Provide a summarized, decoded list of all card actions
-
-This is intended as basis for a formatter function
-
-Format: 'id': {decoded_function_call, ignore_same_id_delay, ignore_card_removal_action, description, from_alias}
+Provides name property and RPC decode function
-
+
-#### delete\_card
+#### set\_rpc\_actions
```python
-@plugs.register
-def delete_card(card_id: str, auto_save: bool = True)
+@abstractmethod
+def set_rpc_actions(action_config) -> None
```
+Set all input device callbacks from :attr:`action_config`
+
**Arguments**:
-- `auto_save`:
-- `card_id`:
+- `action_config`: Dictionary with one
+[RPC Commands](../../builders/rpc-commands.md) definition entry for every device callback
-
+
-#### register\_card
+## EventProperty Objects
```python
-@plugs.register
-def register_card(card_id: str,
- cmd_alias: str,
- args: Optional[List] = None,
- kwargs: Optional[Dict] = None,
- ignore_card_removal_action: Optional[bool] = None,
- ignore_same_id_delay: Optional[bool] = None,
- overwrite: bool = False,
- auto_save: bool = True)
+class EventProperty()
```
-Register a new card based on quick-selection
-
-If you are going to call this through the RPC it will get a little verbose
-
-**Example:** Registering a new card with ID *0009* for increment volume with a custom argument to inc_volume
-(*here: 15*) and custom *ignore_same_id_delay value*::
-
- plugin.call_ignore_errors('cards', 'register_card',
- args=['0009', 'inc_volume'],
- kwargs={'args': [15], 'ignore_same_id_delay': True, 'overwrite': True})
+Event callback property
-
+
-#### register\_card\_custom
+## ButtonBase Objects
```python
-@plugs.register
-def register_card_custom()
+class ButtonBase(ABC)
```
-Register a new card with full RPC call specification (Not implemented yet)
+Common stuff for single button devices
-
+
-#### save\_card\_database
+#### value
```python
-@plugs.register
-def save_card_database(filename=None, *, only_if_changed=True)
+@property
+def value()
```
-Store the current card database. If filename is None, it is saved back to the file it was loaded from
+Returns 1 if the button is currently pressed, and 0 if it is not.
-
+
-# components.rfid.cardutils
+#### pin
-Common card decoding functions
+```python
+@property
+def pin()
+```
-TODO: Thread safety when accessing the card DB!
+Returns the underlying pin class from GPIOZero.
-
+
-#### decode\_card\_command
+#### pull\_up
```python
-def decode_card_command(cfg_rpc_cmd: Mapping, logger: logging.Logger = log)
+@property
+def pull_up()
```
-Extension of utils.decode_action with card-specific parameters
+If :data:`True`, the device uses an internal pull-up resistor to set the GPIO pin “high” by default.
-
+
-#### card\_command\_to\_str
+#### close
```python
-def card_command_to_str(cfg_rpc_cmd: Mapping, long=False) -> List[str]
+def close()
```
-Returns a list of strings with [card_action, ignore_same_id_delay, ignore_card_removal_action]
-
-The last two parameters are only present, if *long* is True and if they are present in the cfg_rpc_cmd
+Close the device and release the pin
-
+
-#### card\_to\_str
+## Button Objects
```python
-def card_to_str(card_id: str, long=False) -> List[str]
+class Button(NameMixin, ButtonBase)
```
-Returns a list of strings from card entry command in the format of :func:`card_command_to_str`
+A basic Button that runs a single actions on button press
+**Arguments**:
-
+- `pull_up` (`bool`): If :data:`True`, the device uses an internal pull-up resistor to set the GPIO pin “high” by default.
+If :data:`False` the internal pull-down resistor is used. If :data:`None`, the pin will be floating and an external
+resistor must be used and the :attr:`active_state` must be set.
+- `active_state` (`bool or None`): If :data:`True`, when the hardware pin state is ``HIGH``, the software
+pin is ``HIGH``. If :data:`False`, the input polarity is reversed: when
+the hardware pin state is ``HIGH``, the software pin state is ``LOW``.
+Use this parameter to set the active state of the underlying pin when
+configuring it as not pulled (when *pull_up* is :data:`None`). When
+*pull_up* is :data:`True` or :data:`False`, the active state is
+automatically set to the proper value.
+- `bounce_time` (`float or None`): Specifies the length of time (in seconds) that the component will
+ignore changes in state after an initial change. This defaults to
+:data:`None` which indicates that no bounce compensation will be
+performed.
+- `hold_repeat` (`bool`): If :data:`True` repeat the :attr:`on_press` every :attr:`hold_time` seconds. Else action
+is run only once independent of the length of time the button is pressed for.
+- `hold_time` (`float`): Time in seconds to wait between invocations of :attr:`on_press`.
+- `pin_factory`: The GPIOZero pin factory. This parameter cannot be set through the configuration file
+- `name` (`str`): The name of the button for use in error messages. This parameter cannot be set explicitly
+through the configuration file
-# components.publishing
+.. copied from GPIOZero's documentation: active_state, bounce_time
+.. Copyright Ben Nuttall / SPDX-License-Identifier: BSD-3-Clause
-Plugin interface for Jukebox Publisher
+
-Thin wrapper around jukebox.publishing to benefit from the plugin loading / exit handling / function handling
+#### on\_press
-This is the first package to be loaded and the last to be closed: put Hello and Goodbye publish messages here.
+```python
+@property
+def on_press()
+```
+The function to run when the device has been pressed
-
-#### republish
+
+
+## LongPressButton Objects
```python
-@plugin.register
-def republish(topic=None)
+class LongPressButton(NameMixin, ButtonBase)
```
-Re-publish the topic tree 'topic' to all subscribers
+A Button that runs a single actions only when the button is pressed long enough
**Arguments**:
-- `topic`: Topic tree to republish. None = resend all
-
-
-
-# components.player
+- `pull_up`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+- `active_state`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+- `bounce_time`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+- `hold_repeat`: If :data:`True` repeat the :attr:`on_press` every :attr:`hold_time` seconds. Else only action
+is run only once independent of the length of time the button is pressed for.
+- `hold_time`: The minimum time, the button must be pressed be running :attr:`on_press` for the first time.
+Also the time in seconds to wait between invocations of :attr:`on_press`.
-
+
-## MusicLibPath Objects
+#### on\_press
```python
-class MusicLibPath()
+@on_press.setter
+def on_press(func)
```
-Extract the music directory from the mpd.conf file
+The function to run when the device has been pressed for longer than :attr:`hold_time`
-
+
-#### get\_music\_library\_path
+## ShortLongPressButton Objects
```python
-def get_music_library_path()
+class ShortLongPressButton(NameMixin, ButtonBase)
```
-Get the music library path
+A single button that runs two different actions depending if the button is pressed for a short or long time.
+The shortest possible time is used to ensure a unique identification to an action can be made. For example a short press
+can only be identified, when a button is released before :attr:`hold_time`, i.e. not directly on button press.
+But a long press can be identified as soon as :attr:`hold_time` is reached and there is no need to wait for the release
+event. Furthermore, if there is a long hold, only the long hold action is executed - the short press action is not run
+in this case!
-
+**Arguments**:
-# components.jingle
+- `pull_up`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+- `active_state`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+- `bounce_time`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+- `hold_time`: The time in seconds to differentiate if it is a short or long press. If the button is released before
+this time, it is a short press. As soon as the button is held for :attr:`hold_time` it is a long press and the
+short press action is ignored
+- `hold_repeat`: If :data:`True` repeat the long press action every :attr:`hold_time` seconds after first long press
+action
+- `pin_factory`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+- `name`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-Jingle Playback Factory for extensible run-time support of various file types
+
+## RotaryEncoder Objects
-
+```python
+class RotaryEncoder(NameMixin)
+```
-## JingleFactory Objects
+A rotary encoder to run one of two actions depending on the rotation direction.
+
+**Arguments**:
+
+- `bounce_time`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+- `pin_factory`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+- `name`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+
+
+
+#### pin\_a
```python
-class JingleFactory()
+@property
+def pin_a()
```
-Jingle Factory
+Returns the underlying pin A
-
+
-#### list
+#### pin\_b
```python
-def list()
+@property
+def pin_b()
```
-List the available volume services
+Returns the underlying pin B
-
+
-#### play
+#### on\_rotate\_clockwise
```python
-@plugin.register
-def play(filename)
+@property
+def on_rotate_clockwise()
```
-Play the jingle using the configured jingle service
+The function to run when the encoder is rotated clockwise
-> [!NOTE]
-> This runs in a separate thread. And this may cause troubles
-> when changing the volume level before
-> and after the sound playback: There is nothing to prevent another
-> thread from changing the volume and sink while playback happens
-> and afterwards we change the volume back to where it was before!
-There is no way around this dilemma except for not running the jingle as a
-separate thread. Currently (as thread) even the RPC is started before the sound
-is finished and the volume is reset to normal...
+
-However: Volume plugin is loaded before jingle and sets the default
-volume. No interference here. It can now only happen
-if (a) through the RPC or (b) some other plugin the volume is changed. Okay, now
-(a) let's hope that there is enough delay in the user requesting a volume change
-(b) let's hope no other plugin wants to do that
-(c) no bluetooth device connects during this time (and pulseaudio control is set to toggle_on_connect)
-and take our changes with the threaded approach.
+#### on\_rotate\_counter\_clockwise
+
+```python
+@property
+def on_rotate_counter_clockwise()
+```
+The function to run when the encoder is rotated counter clockwise
-
-#### play\_startup
+
+
+#### close
```python
-@plugin.register
-def play_startup()
+def close()
```
-Play the startup sound (using jingle.play)
+Close the device and release the pin
-
+
-#### play\_shutdown
+## TwinButton Objects
```python
-@plugin.register
-def play_shutdown()
+class TwinButton(NameMixin)
```
-Play the shutdown sound (using jingle.play)
-
+A two-button device which can run up to six different actions, a.k.a the six function beast.
-
+Per user press "input" of the TwinButton, only a single callback is executed (but this callback
+may be executed several times).
+The shortest possible time is used to ensure a unique identification to an action can be made. For example a short press
+can only be identified, when a button is released before :attr:`hold_time`, i.e. not directly on button press.
+But a long press can be identified as soon as :attr:`hold_time` is reached and there is no need to wait for the release
+event. Furthermore, if there is a long hold, only the long hold action is executed - the short press action is not run
+in this case!
-# components.jingle.alsawave
+It is not necessary to configure all actions.
-ALSA wave jingle Service for jingle.JingleFactory
+**Arguments**:
+- `pull_up`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+- `active_state`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+- `bounce_time`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+- `hold_time`: The time in seconds to differentiate if it is a short or long press. If the button is released before
+this time, it is a short press. As soon as the button is held for :attr:`hold_time` it is a long press and the
+short press action is ignored.
+- `hold_repeat`: If :data:`True` repeat the long press action every :attr:`hold_time` seconds after first long press
+action. A long dual press is never repeated independent of this setting
+- `pin_factory`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+- `name`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-
+
-## AlsaWave Objects
+## StateVar Objects
```python
-@plugin.register
-class AlsaWave()
+class StateVar(Enum)
```
-Jingle Service for playing wave files directly from Python through ALSA
+State encoding of the Mealy FSM
-
+
-#### play
+#### close
```python
-@plugin.tag
-def play(filename)
+def close()
```
-Play the wave file
+Close the device and release the pins
-
+
-## AlsaWaveBuilder Objects
+#### value
```python
-class AlsaWaveBuilder()
+@property
+def value()
```
-
+2 bit integer indicating if and which button is currently pressed. Button A is the LSB.
-#### \_\_init\_\_
+
+
+
+#### is\_active
```python
-def __init__()
+@property
+def is_active()
```
-Builder instantiates AlsaWave during init and not during first call because
-we want AlsaWave registers as plugin function in any case if this plugin is loaded
-(and not only on first use!)
+
-
+# components.gpio.gpioz.plugin.connectivity
-# components.jingle.jinglemp3
+Provide connector functions to hook up to some kind of Jukebox functionality and change the output device's state
-Generic MP3 jingle Service for jingle.JingleFactory
+accordingly.
+Connector functions can often be used for various output devices. Some connector functions are specific to
+an output device type.
-
-## JingleMp3Play Objects
+
-```python
-@plugin.register(auto_tag=True)
-class JingleMp3Play()
-```
+#### BUZZ\_TONE
-Jingle Service for playing MP3 files
+The tone to be used as buzz tone when the buzzer is an active buzzer
-
+
-#### play
+#### register\_rfid\_callback
```python
-def play(filename)
+def register_rfid_callback(device)
```
-Play the MP3 file
-
+Flash the output device once on successful RFID card detection and thrice if card ID is unknown
-
+Compatible devices:
-## JingleMp3PlayBuilder Objects
+* :class:`components.gpio.gpioz.core.output_devices.LED`
+* :class:`components.gpio.gpioz.core.output_devices.PWMLED`
+* :class:`components.gpio.gpioz.core.output_devices.RGBLED`
+* :class:`components.gpio.gpioz.core.output_devices.Buzzer`
+* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer`
-```python
-class JingleMp3PlayBuilder()
-```
-
+
-#### \_\_init\_\_
+#### register\_status\_led\_callback
```python
-def __init__()
+def register_status_led_callback(device)
```
-Builder instantiates JingleMp3Play during init and not during first call because
-
-we want JingleMp3Play registers as plugin function in any case if this plugin is loaded
-(and not only on first use!)
+Turn LED on when Jukebox App has started
+Compatible devices:
-
+* :class:`components.gpio.gpioz.core.output_devices.LED`
+* :class:`components.gpio.gpioz.core.output_devices.PWMLED`
+* :class:`components.gpio.gpioz.core.output_devices.RGBLED`
-# components.hostif.linux
-
+
-#### shutdown
+#### register\_status\_buzzer\_callback
```python
-@plugin.register
-def shutdown()
+def register_status_buzzer_callback(device)
```
-Shutdown the host machine
-
-
-
-
-#### reboot
+Buzz once when Jukebox App has started, twice when closing down
-```python
-@plugin.register
-def reboot()
-```
+Compatible devices:
-Reboot the host machine
+* :class:`components.gpio.gpioz.core.output_devices.Buzzer`
+* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer`
-
+
-#### jukebox\_is\_service
+#### register\_status\_tonalbuzzer\_callback
```python
-@plugin.register
-def jukebox_is_service()
+def register_status_tonalbuzzer_callback(device)
```
-Check if current Jukebox process is running as a service
+Buzz a multi-note melody when Jukebox App has started and when closing down
+Compatible devices:
-
+* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer`
-#### is\_any\_jukebox\_service\_active
+
+
+
+#### register\_audio\_sink\_change\_callback
```python
-@plugin.register
-def is_any_jukebox_service_active()
+def register_audio_sink_change_callback(device)
```
-Check if a Jukebox service is running
+Turn LED on if secondary audio output is selected. If audio output change
-> [!NOTE]
-> Does not have the be the current app, that is running as a service!
+fails, blink thrice
+Compatible devices:
-
+* :class:`components.gpio.gpioz.core.output_devices.LED`
+* :class:`components.gpio.gpioz.core.output_devices.PWMLED`
+* :class:`components.gpio.gpioz.core.output_devices.RGBLED`
-#### restart\_service
-```python
-@plugin.register
-def restart_service()
-```
+
-Restart Jukebox App if running as a service
+#### register\_volume\_led\_callback
+```python
+def register_volume_led_callback(device)
+```
-
+Have a PWMLED change it's brightness according to current volume. LED flashes when minimum or maximum volume
-#### get\_disk\_usage
+is reached. Minimum value is still a very dimly turned on LED (i.e. LED is never off).
-```python
-@plugin.register()
-def get_disk_usage(path='/')
-```
+Compatible devices:
-Return the disk usage in Megabytes as dictionary for RPC export
+* :class:`components.gpio.gpioz.core.output_devices.PWMLED`
-
+
-#### get\_cpu\_temperature
+#### register\_volume\_buzzer\_callback
```python
-@plugin.register
-def get_cpu_temperature()
+def register_volume_buzzer_callback(device)
```
-Get the CPU temperature with single decimal point
+Sound a buzzer once when minimum or maximum value is reached
-No error handling: this is expected to take place up-level!
+Compatible devices:
+
+* :class:`components.gpio.gpioz.core.output_devices.Buzzer`
+* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer`
-
+
-#### get\_ip\_address
+#### register\_volume\_rgbled\_callback
```python
-@plugin.register
-def get_ip_address()
+def register_volume_rgbled_callback(device)
```
-Get the IP address
+Have a :class:`RGBLED` change it's color according to current volume. LED flashes when minimum or maximum volume
+is reached.
-
+Compatible devices:
-#### wlan\_disable\_power\_down
+* :class:`components.gpio.gpioz.core.output_devices.RGBLED`
-```python
-@plugin.register()
-def wlan_disable_power_down(card=None)
-```
-Turn off power management of wlan. Keep RPi reachable via WLAN
+
-This must be done after every reboot
-card=None takes card from configuration file
+# components.gpio.gpioz.plugin
+The GPIOZ plugin interface build all input and output devices from the configuration file and connects
-
+the actions and callbacks. It also provides a very restricted, but common API for the output devices to the RPC.
+That API is mainly used for testing. All the relevant output state changes are usually made through callbacks directly
+using the output device's API.
-#### get\_autohotspot\_status
-```python
-@plugin.register
-def get_autohotspot_status()
-```
+
-Get the status of the auto hotspot feature
+#### output\_devices
+List of all created output devices
-
-#### stop\_autohotspot
+
-```python
-@plugin.register()
-def stop_autohotspot()
-```
+#### input\_devices
-Stop auto hotspot functionality
+List of all created input devices
-Stopping and disabling the timer and running the service one last time manually
+
-
+#### factory
-#### start\_autohotspot
+The global pin factory used in this module
-```python
-@plugin.register()
-def start_autohotspot()
-```
+Using different pin factories for different devices is not supported
-Start auto hotspot functionality
-Enabling and starting the timer (timer will start the service)
+
+#### IS\_ENABLED
-
+Indicates that the GPIOZ module is enabled and loaded w/o errors
-# components.misc
-Miscellaneous function package
+
+#### IS\_MOCKED
-
+Indicates that the pin factory is a mock factory
-#### rpc\_cmd\_help
-```python
-@plugin.register
-def rpc_cmd_help()
-```
+
-Return all commands for RPC
+#### CONFIG\_FILE
+
+The path of the config file the GPIOZ configuration was loaded from
-
+
-#### get\_all\_loaded\_packages
+## ServiceIsRunningCallbacks Objects
```python
-@plugin.register
-def get_all_loaded_packages()
+class ServiceIsRunningCallbacks(CallbackHandler)
```
-Get all successfully loaded plugins
+Callbacks are executed when
+* Jukebox app started
+* Jukebox shuts down
-
-
-#### get\_all\_failed\_packages
-
-```python
-@plugin.register
-def get_all_failed_packages()
-```
+This is intended to e.g. signal an LED to change state.
+This is integrated into this module because:
-Get all plugins with error during load or initialization
+* we need the GPIO to control a LED (it must be available when the status callback comes)
+* the plugin callback functions provide all the functionality to control the status of the LED
+* which means no need to adapt other modules
-
+
-#### get\_start\_time
+#### register
```python
-@plugin.register
-def get_start_time()
+def register(func: Callable[[int], None])
```
-Time when JukeBox has been started
-
-
-
+Add a new callback function :attr:`func`.
-#### get\_log
+Callback signature is
-```python
-def get_log(handler_name: str)
-```
+.. py:function:: func(status: int)
+ :noindex:
-Get the log file from the loggers (debug_file_handler, error_file_handler)
+**Arguments**:
+- `status`: 1 if app started, 0 if app shuts down
-
+
-#### get\_log\_debug
+#### run\_callbacks
```python
-@plugin.register
-def get_log_debug()
+def run_callbacks(status: int)
```
-Get the log file (from the debug_file_handler)
-
+
-#### get\_log\_error
+#### service\_is\_running\_callbacks
-```python
-@plugin.register
-def get_log_error()
-```
+Callback handler instance for service_is_running_callbacks events.
-Get the log file (from the error_file_handler)
+See :class:`ServiceIsRunningCallbacks`
-
+
-#### get\_git\_state
+#### build\_output\_device
```python
-@plugin.register
-def get_git_state()
+def build_output_device(name: str, config: Dict)
```
-Return git state information for the current branch
+Construct and register a new output device
+In principal all supported GPIOZero output devices can be used.
+For all devices a custom functions need to be written to control the state of the outputs
-
-#### empty\_rpc\_call
+
+
+#### build\_input\_device
```python
-@plugin.register
-def empty_rpc_call(msg: str = '')
+def build_input_device(name: str, config)
```
-This function does nothing.
-
-The RPC command alias 'none' is mapped to this function.
-
-This is also used when configuration errors lead to non existing RPC command alias definitions.
-When the alias definition is void, we still want to return a valid function to simplify error handling
-up the module call stack.
-
-**Arguments**:
-
-- `msg`: If present, this message is send to the logger with severity warning
-
-
-
-# components.controls
-
-
-
-# components.controls.bluetooth\_audio\_buttons
-
-Plugin to attempt to automatically listen to it's buttons (play, next, ...)
-
-when a bluetooth sound device (headphone, speakers) connects
-
-This effectively does:
-
-* register a callback with components.volume to get notified when a new sound card connects
-* if that is a bluetooth device, try opening an input device with similar name using
-* button listeners are run each in its own thread
-
-
-
-
-# components.controls.common.evdev\_listener
+Construct and connect a new input device
-Generalized listener for ``dev/input`` devices
+Supported input devices are those from gpio.gpioz.core.input_devices
-
+
-#### find\_device
+#### get\_output
```python
-def find_device(device_name: str,
- exact_name: bool = True,
- mandatory_keys: Optional[Set[int]] = None) -> str
+def get_output(name: str)
```
-Find an input device with device_name and mandatory keys.
+Get the output device instance based on the configured name
**Arguments**:
-- `device_name`: See :func:`_filter_by_device_name`
-- `exact_name`: See :func:`_filter_by_device_name`
-- `mandatory_keys`: See :func:`_filter_by_mandatory_keys`
-
-**Raises**:
-
-- `FileNotFoundError`: if no device is found.
-- `AttributeError`: if device does not have the mandatory key
-If multiple devices match, the first match is returned
-
-**Returns**:
-
-The path to the device
+- `name`: The alias name output device instance
-
+
-## EvDevKeyListener Objects
+#### on
```python
-class EvDevKeyListener(threading.Thread)
+@plugin.register
+def on(name: str)
```
-Opens and event input device from ``/dev/inputs``, and runs callbacks upon the button presses.
-
-Input devices could be .e.g. Keyboard, Bluetooth audio buttons, USB buttons
-
-Runs as a separate thread. When device disconnects or disappears, thread exists. A new thread must be started
-when device re-connects.
+Turn an output device on
-Assign callbacks to :attr:`EvDevKeyListener.button_callbacks`
+**Arguments**:
+- `name`: The alias name output device instance
-
+
-#### \_\_init\_\_
+#### off
```python
-def __init__(device_name_request: str, exact_name: bool, thread_name: str)
+@plugin.register
+def off(name: str)
```
+Turn an output device off
+
**Arguments**:
-- `device_name_request`: The device name to look for
-- `exact_name`: If true, device_name must mach exactly, else a match is returned if device_name is a substring of
-the reported device name
-- `thread_name`: Name of the listener thread
+- `name`: The alias name output device instance
-
+
-#### run
+#### set\_value
```python
-def run()
+@plugin.register
+def set_value(name: str, value: Any)
```
+Set the output device to :attr:`value`
+**Arguments**:
-
+- `name`: The alias name output device instance
+- `value`: Value to set the device to
-#### start
+
+
+#### flash
```python
-def start() -> None
+@plugin.register
+def flash(name,
+ on_time=1,
+ off_time=1,
+ n=1,
+ *,
+ fade_in_time=0,
+ fade_out_time=0,
+ tone=None,
+ color=(1, 1, 1))
```
-Start the tread and start listening
+Flash (blink or beep) an output device
+This is a generic function for all types of output devices. Parameters not applicable to an
+specific output device are silently ignored
-
+**Arguments**:
-# components.battery\_monitor
+- `name`: The alias name output device instance
+- `on_time`: Time in seconds in state ``ON``
+- `off_time`: Time in seconds in state ``OFF``
+- `n`: Number of flash cycles
+- `tone`: The tone in to play, e.g. 'A4'. *Only for TonalBuzzer*.
+- `color`: The RGB color *only for PWMLED*.
+- `fade_in_time`: Time in seconds for transitioning to on. *Only for PWMLED and RGBLED*
+- `fade_out_time`: Time in seconds for transitioning to off. *Only for PWMLED and RGBLED*
-
+
-# components.battery\_monitor.BatteryMonitorBase
+# components.rfid.configure
-
+
-## pt1\_frac Objects
+#### reader\_install\_dependencies
```python
-class pt1_frac()
+def reader_install_dependencies(reader_path: str,
+ dependency_install: str) -> None
```
-fixed point first order filter, fractional format: 2^16,2^16
+Install dependencies for the selected reader module
+**Arguments**:
-
+- `reader_path`: Path to the reader module
+- `dependency_install`: how to handle installing of dependencies
+'query': query user (default)
+'auto': automatically
+'no': don't install dependencies
-## BattmonBase Objects
+
+
+#### reader\_load\_module
```python
-class BattmonBase()
+def reader_load_module(reader_name)
```
-Battery Monitor base class
+Load the module for the reader_name
+A ModuleNotFoundError is unrecoverable, but we at least want to give some hint how to resolve that to the user
+All other errors will NOT be handled. Modules that do not load due to compile errors have other problems
-
+**Arguments**:
-# components.battery\_monitor.batt\_mon\_simulator
+- `reader_name`: Name of the reader to load the module for
-
+**Returns**:
-## battmon\_simulator Objects
+module
+
+
+
+#### query\_user\_for\_reader
```python
-class battmon_simulator(BatteryMonitorBase.BattmonBase)
+def query_user_for_reader(dependency_install='query') -> dict
```
-Battery Monitor Simulator
+Ask the user to select a RFID reader and prompt for the reader's configuration
+This function performs the following steps, to find and present all available readers to the user
-
+- search for available reader subpackages
+- dynamically load the description module for each reader subpackage
+- queries user for selection
+- if no_dep_install=False, install dependencies as given by requirements.txt and execute setup.inc.sh of subpackage
+- dynamically load the actual reader module from the reader subpackage
+- if selected reader has customization options query user for that now
+- return configuration
-# components.battery\_monitor.batt\_mon\_i2c\_ads1015
+There are checks to make sure we have the right reader modules and they are what we expect.
+The are as few requirements towards the reader module as possible and everything else is optional
+(see reader_template for these requirements)
+However, there is no error handling w.r.t to user input and reader's query_config. Firstly, in this script
+we cannot gracefully handle an exception that occurs on reader level, and secondly the exception will simply
+exit the script w/o writing the config to file. No harm done.
-
+This script expects to reside in the directory with all the reader subpackages, i.e it is part of the rfid-reader package.
+Otherwise you'll need to adjust sys.path
-## battmon\_ads1015 Objects
+**Arguments**:
+
+- `dependency_install`: how to handle installing of dependencies
+'query': query user (default)
+'auto': automatically
+'no': don't install dependencies
+
+**Returns**:
+
+`dict as {section: {parameter: value}}`: nested dict with entire configuration that can be read into ConfigParser
+
+
+
+#### write\_config
```python
-class battmon_ads1015(BatteryMonitorBase.BattmonBase)
+def write_config(config_file: str,
+ config_dict: dict,
+ force_overwrite=False) -> None
```
-Battery Monitor based on a ADS1015
+Write configuration to config_file
-See [Battery Monitor documentation](../../builders/components/power/batterymonitor.md)
+**Arguments**:
+- `config_file`: relative or absolute path to config file
+- `config_dict`: nested dict with configuration parameters for ConfigParser consumption
+- `force_overwrite`: overwrite existing configuration file without asking
-
+
-# components.gpio.gpioz.plugin
+# components.rfid
-The GPIOZ plugin interface build all input and output devices from the configuration file and connects
+
-the actions and callbacks. It also provides a very restricted, but common API for the output devices to the RPC.
-That API is mainly used for testing. All the relevant output state changes are usually made through callbacks directly
-using the output device's API.
+# components.rfid.cardutils
+Common card decoding functions
-
+TODO: Thread safety when accessing the card DB!
-#### output\_devices
-List of all created output devices
+
+#### decode\_card\_command
-
+```python
+def decode_card_command(cfg_rpc_cmd: Mapping, logger: logging.Logger = log)
+```
-#### input\_devices
+Extension of utils.decode_action with card-specific parameters
-List of all created input devices
+
-
+#### card\_command\_to\_str
-#### factory
+```python
+def card_command_to_str(cfg_rpc_cmd: Mapping, long=False) -> List[str]
+```
-The global pin factory used in this module
+Returns a list of strings with [card_action, ignore_same_id_delay, ignore_card_removal_action]
-Using different pin factories for different devices is not supported
+The last two parameters are only present, if *long* is True and if they are present in the cfg_rpc_cmd
-
+
-#### IS\_ENABLED
+#### card\_to\_str
-Indicates that the GPIOZ module is enabled and loaded w/o errors
+```python
+def card_to_str(card_id: str, long=False) -> List[str]
+```
+Returns a list of strings from card entry command in the format of :func:`card_command_to_str`
-
-#### IS\_MOCKED
+
-Indicates that the pin factory is a mock factory
+# components.rfid.cards
+Handling the RFID card database
-
+A few considerations:
+- Changing the Card DB influences to current state
+ - rfid.reader: Does not care, as it always freshly looks into the DB when a new card is triggered
+ - fake_reader_gui: Initializes the Drop-down menu once on start --> Will get out of date!
-#### CONFIG\_FILE
+Do we need a notifier? Or a callback for modules to get notified?
+Do we want to publish the information about a card DB update?
+TODO: Add callback for on_database_change
-The path of the config file the GPIOZ configuration was loaded from
+TODO: check card id type (if int, convert to str)
+TODO: check if args is really a list (convert if not?)
-
+
-## ServiceIsRunningCallbacks Objects
+#### list\_cards
```python
-class ServiceIsRunningCallbacks(CallbackHandler)
+@plugs.register
+def list_cards()
```
-Callbacks are executed when
-
-* Jukebox app started
-* Jukebox shuts down
+Provide a summarized, decoded list of all card actions
-This is intended to e.g. signal an LED to change state.
-This is integrated into this module because:
+This is intended as basis for a formatter function
-* we need the GPIO to control a LED (it must be available when the status callback comes)
-* the plugin callback functions provide all the functionality to control the status of the LED
-* which means no need to adapt other modules
+Format: 'id': {decoded_function_call, ignore_same_id_delay, ignore_card_removal_action, description, from_alias}
-
+
-#### register
+#### delete\_card
```python
-def register(func: Callable[[int], None])
+@plugs.register
+def delete_card(card_id: str, auto_save: bool = True)
```
-Add a new callback function :attr:`func`.
-
-Callback signature is
-
-.. py:function:: func(status: int)
- :noindex:
-
**Arguments**:
-- `status`: 1 if app started, 0 if app shuts down
+- `auto_save`:
+- `card_id`:
-
+
-#### run\_callbacks
+#### register\_card
```python
-def run_callbacks(status: int)
+@plugs.register
+def register_card(card_id: str,
+ cmd_alias: str,
+ args: Optional[List] = None,
+ kwargs: Optional[Dict] = None,
+ ignore_card_removal_action: Optional[bool] = None,
+ ignore_same_id_delay: Optional[bool] = None,
+ overwrite: bool = False,
+ auto_save: bool = True)
```
+Register a new card based on quick-selection
+If you are going to call this through the RPC it will get a little verbose
-
-
-#### service\_is\_running\_callbacks
-
-Callback handler instance for service_is_running_callbacks events.
+**Example:** Registering a new card with ID *0009* for increment volume with a custom argument to inc_volume
+(*here: 15*) and custom *ignore_same_id_delay value*::
-See :class:`ServiceIsRunningCallbacks`
+ plugin.call_ignore_errors('cards', 'register_card',
+ args=['0009', 'inc_volume'],
+ kwargs={'args': [15], 'ignore_same_id_delay': True, 'overwrite': True})
-
+
-#### build\_output\_device
+#### register\_card\_custom
```python
-def build_output_device(name: str, config: Dict)
+@plugs.register
+def register_card_custom()
```
-Construct and register a new output device
-
-In principal all supported GPIOZero output devices can be used.
-For all devices a custom functions need to be written to control the state of the outputs
+Register a new card with full RPC call specification (Not implemented yet)
-
+
-#### build\_input\_device
+#### save\_card\_database
```python
-def build_input_device(name: str, config)
+@plugs.register
+def save_card_database(filename=None, *, only_if_changed=True)
```
-Construct and connect a new input device
+Store the current card database. If filename is None, it is saved back to the file it was loaded from
-Supported input devices are those from gpio.gpioz.core.input_devices
+
-
+# components.rfid.readerbase
-#### get\_output
+
+
+## ReaderBaseClass Objects
```python
-def get_output(name: str)
+class ReaderBaseClass(ABC)
```
-Get the output device instance based on the configured name
+Abstract Base Class for all Reader Classes to ensure common API
-**Arguments**:
+Look at template_new_reader.py for documentation how to integrate a new RFID reader
-- `name`: The alias name output device instance
-
+
-#### on
+# components.rfid.reader
+
+
+
+## RfidCardDetectCallbacks Objects
```python
-@plugin.register
-def on(name: str)
+class RfidCardDetectCallbacks(CallbackHandler)
```
-Turn an output device on
-
-**Arguments**:
+Callbacks are executed if rfid card is detected
-- `name`: The alias name output device instance
-
+
-#### off
+#### register
```python
-@plugin.register
-def off(name: str)
+def register(func: Callable[[str, RfidCardDetectState], None])
```
-Turn an output device off
+Add a new callback function :attr:`func`.
+
+Callback signature is
+
+.. py:function:: func(card_id: str, state: int)
+ :noindex:
**Arguments**:
-- `name`: The alias name output device instance
+- `card_id`: Card ID
+- `state`: See `RfidCardDetectState`
-
+
-#### set\_value
+#### run\_callbacks
```python
-@plugin.register
-def set_value(name: str, value: Any)
+def run_callbacks(card_id: str, state: RfidCardDetectState)
```
-Set the output device to :attr:`value`
-**Arguments**:
-- `name`: The alias name output device instance
-- `value`: Value to set the device to
+
-
+#### rfid\_card\_detect\_callbacks
-#### flash
+Callback handler instance for rfid_card_detect_callbacks events.
-```python
-@plugin.register
-def flash(name,
- on_time=1,
- off_time=1,
- n=1,
- *,
- fade_in_time=0,
- fade_out_time=0,
- tone=None,
- color=(1, 1, 1))
-```
+See [`RfidCardDetectCallbacks`](#components.rfid.reader.RfidCardDetectCallbacks)
-Flash (blink or beep) an output device
-This is a generic function for all types of output devices. Parameters not applicable to an
-specific output device are silently ignored
+
-**Arguments**:
+## CardRemovalTimerClass Objects
-- `name`: The alias name output device instance
-- `on_time`: Time in seconds in state ``ON``
-- `off_time`: Time in seconds in state ``OFF``
-- `n`: Number of flash cycles
-- `tone`: The tone in to play, e.g. 'A4'. *Only for TonalBuzzer*.
-- `color`: The RGB color *only for PWMLED*.
-- `fade_in_time`: Time in seconds for transitioning to on. *Only for PWMLED and RGBLED*
-- `fade_out_time`: Time in seconds for transitioning to off. *Only for PWMLED and RGBLED*
+```python
+class CardRemovalTimerClass(threading.Thread)
+```
-
+A timer watchdog thread that calls timeout_action on time-out
-# components.gpio.gpioz.plugin.connectivity
-Provide connector functions to hook up to some kind of Jukebox functionality and change the output device's state
+
-accordingly.
+#### \_\_init\_\_
-Connector functions can often be used for various output devices. Some connector functions are specific to
-an output device type.
+```python
+def __init__(on_timeout_callback, logger: logging.Logger = None)
+```
+**Arguments**:
-
+- `on_timeout_callback`: The function to execute on time-out
-#### BUZZ\_TONE
+
-The tone to be used as buzz tone when the buzzer is an active buzzer
+# components.rfid.hardware.rdm6300\_serial.description
+
-
+# components.rfid.hardware.rdm6300\_serial.rdm6300\_serial
-#### register\_rfid\_callback
+
+
+#### decode
```python
-def register_rfid_callback(device)
+def decode(raw_card_id: bytearray, number_format: int) -> str
```
-Flash the output device once on successful RFID card detection and thrice if card ID is unknown
-
-Compatible devices:
-
-* :class:`components.gpio.gpioz.core.output_devices.LED`
-* :class:`components.gpio.gpioz.core.output_devices.PWMLED`
-* :class:`components.gpio.gpioz.core.output_devices.RGBLED`
-* :class:`components.gpio.gpioz.core.output_devices.Buzzer`
-* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer`
+Decode the RDM6300 data format into actual card ID
-
+
-#### register\_status\_led\_callback
+# components.rfid.hardware.template\_new\_reader.description
-```python
-def register_status_led_callback(device)
-```
+Provide a short title for this reader.
-Turn LED on when Jukebox App has started
+This is what that user will see when asked for selecting his RFID reader
+So, be precise but readable. Precise means 40 characters or less
-Compatible devices:
-* :class:`components.gpio.gpioz.core.output_devices.LED`
-* :class:`components.gpio.gpioz.core.output_devices.PWMLED`
-* :class:`components.gpio.gpioz.core.output_devices.RGBLED`
+
+# components.rfid.hardware.template\_new\_reader.template\_new\_reader
-
+
-#### register\_status\_buzzer\_callback
+#### query\_customization
```python
-def register_status_buzzer_callback(device)
+def query_customization() -> dict
```
-Buzz once when Jukebox App has started, twice when closing down
-
-Compatible devices:
+Query the user for reader parameter customization
-* :class:`components.gpio.gpioz.core.output_devices.Buzzer`
-* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer`
+This function will be called during the configuration/setup phase when the user selects this reader module.
+It must return all configuration parameters that are necessary to later use the Reader class.
+You can ask the user for selections and choices. And/or provide default values.
+If your reader requires absolutely no configuration return {}
-
+
-#### register\_status\_tonalbuzzer\_callback
+## ReaderClass Objects
```python
-def register_status_tonalbuzzer_callback(device)
+class ReaderClass(ReaderBaseClass)
```
-Buzz a multi-note melody when Jukebox App has started and when closing down
+The actual reader class that is used to read RFID cards.
-Compatible devices:
+It will be instantiated once and then read_card() is called in an endless loop.
-* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer`
+It will be used in a manner
+ with Reader(reader_cfg_key) as reader:
+ for card_id in reader:
+ ...
+which ensures proper resource de-allocation. For this to work derive this class from ReaderBaseClass.
+All the required interfaces are implemented there.
+
+Put your code into these functions (see below for more information)
+ - `__init__`
+ - read_card
+ - cleanup
+ - stop
-
+
-#### register\_audio\_sink\_change\_callback
+#### \_\_init\_\_
```python
-def register_audio_sink_change_callback(device)
+def __init__(reader_cfg_key)
```
-Turn LED on if secondary audio output is selected. If audio output change
-
-fails, blink thrice
-
-Compatible devices:
+In the constructor, you will get the `reader_cfg_key` with which you can access the configuration data
-* :class:`components.gpio.gpioz.core.output_devices.LED`
-* :class:`components.gpio.gpioz.core.output_devices.PWMLED`
-* :class:`components.gpio.gpioz.core.output_devices.RGBLED`
+As you are dealing directly with potentially user-manipulated config information, it is
+advisable to do some sanity checks and give useful error messages. Even if you cannot recover gracefully,
+a good error message helps :-)
-
+
-#### register\_volume\_led\_callback
+#### cleanup
```python
-def register_volume_led_callback(device)
+def cleanup()
```
-Have a PWMLED change it's brightness according to current volume. LED flashes when minimum or maximum volume
-
-is reached. Minimum value is still a very dimly turned on LED (i.e. LED is never off).
-
-Compatible devices:
+The cleanup function: free and release all resources used by this card reader (if any).
-* :class:`components.gpio.gpioz.core.output_devices.PWMLED`
+Put all your cleanup code here, e.g. if you are using the serial bus or GPIO pins.
+Will be called implicitly via the __exit__ function
+This function must exist! If there is nothing to do, just leave the pass statement in place below
-
+
-#### register\_volume\_buzzer\_callback
+#### stop
```python
-def register_volume_buzzer_callback(device)
+def stop()
```
-Sound a buzzer once when minimum or maximum value is reached
+This function is called to tell the reader to exist it's reading function.
-Compatible devices:
+This function is called before cleanup is called.
-* :class:`components.gpio.gpioz.core.output_devices.Buzzer`
-* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer`
+> [!NOTE]
+> This is usually called from a different thread than the reader's thread! And this is the reason for the
+> two-step exit strategy. This function works across threads to indicate to the reader that is should stop attempt
+> to read a card. Once called, the function read_card will not be called again. When the reader thread exits
+> cleanup is called from the reader thread itself.
-
+
-#### register\_volume\_rgbled\_callback
+#### read\_card
```python
-def register_volume_rgbled_callback(device)
+def read_card() -> str
```
-Have a :class:`RGBLED` change it's color according to current volume. LED flashes when minimum or maximum volume
+Blocking or non-blocking function that waits for a new card to appear and return the card's UID as string
-is reached.
+This is were your main code goes :-)
+This function must return a string with the card id
+In case of error, it may return None or an empty string
-Compatible devices:
+The function should break and return with an empty string, once stop() is called
-* :class:`components.gpio.gpioz.core.output_devices.RGBLED`
+
-
+# components.rfid.hardware.generic\_nfcpy.description
-# components.gpio.gpioz.core.converter
+List of supported devices https://nfcpy.readthedocs.io/en/latest/overview.html
-Provides converter functions/classes for various Jukebox parameters to
-values that can be assigned to GPIO output devices
+
+# components.rfid.hardware.generic\_nfcpy.generic\_nfcpy
-
+
-## ColorProperty Objects
+## ReaderClass Objects
```python
-class ColorProperty()
+class ReaderClass(ReaderBaseClass)
```
-Color descriptor ensuring valid weight ranges
+The reader class for nfcpy supported NFC card readers.
-
+
-## VolumeToRGB Objects
+#### cleanup
```python
-class VolumeToRGB()
+def cleanup()
```
-Converts linear volume level to an RGB color value running through the color spectrum
-
-**Arguments**:
-
-- `max_input`: Maximum input value of linear input data
-- `offset`: Offset in degrees in the color circle. Color circle
-traverses blue (0), cyan(60), green (120), yellow(180), red (240), magenta (340)
-- `section`: The section of the full color circle to use in degrees
-Map input :data:`0...100` to color range :data:`green...magenta` and get the color for level 50
-
- conv = VolumeToRGB(100, offset=120, section=180)
- (r, g, b) = conv(50)
+The cleanup function: free and release all resources used by this card reader (if any).
-The three components of an RGB LEDs do not have the same luminosity.
-Weight factors are used to get a balanced color output
-
+
-#### \_\_call\_\_
+#### stop
```python
-def __call__(volume) -> Tuple[float, float, float]
+def stop()
```
-Perform conversion for single volume level
-
-**Returns**:
+This function is called to tell the reader to exit its reading function.
-Tuple(red, green, blue)
-
+
-#### luminize
+#### read\_card
```python
-def luminize(r, g, b)
+def read_card() -> str
```
-Apply the color weight factors to the input color values
+Blocking or non-blocking function that waits for a new card to appear and return the card's UID as string
-
+
-# components.gpio.gpioz.core.mock
+# components.rfid.hardware.generic\_usb.description
-Changes to the GPIOZero devices for using with the Mock RFID Reader
+
+# components.rfid.hardware.generic\_usb.generic\_usb
-
+
-#### patch\_mock\_outputs\_with\_callback
+# components.rfid.hardware.fake\_reader\_gui.fake\_reader\_gui
-```python
-def patch_mock_outputs_with_callback()
-```
-
-Monkey Patch LED + Buzzer to get a callback when state changes
-
-This targets to represent the state in the TK GUI.
-Other output devices cannot be represented in the GUI and are silently ignored.
-
-> [!NOTE]
-> Only for developing purposes!
+
+# components.rfid.hardware.fake\_reader\_gui.description
-
+
-# components.gpio.gpioz.core.input\_devices
+# components.rfid.hardware.fake\_reader\_gui.gpioz\_gui\_addon
-Provides all supported input devices for the GPIOZ plugin.
+Add GPIO input devices and output devices to the RFID Mock Reader GUI
-Input devices are based on GPIOZero devices. So for certain configuration parameters, you should
-their documentation.
-All callback handlers are replaced by GPIOZ callback handlers. These are usually configured
-by using the :func:`set_rpc_actions` each input device exhibits.
+
-For examples how to use the devices from the configuration files, see
-[GPIO: Input Devices](../../builders/gpio.md#input-devices).
+#### create\_inputs
+```python
+def create_inputs(frame, default_btn_width, default_padx, default_pady)
+```
-
+Add all input devies to the GUI
-## NameMixin Objects
+**Arguments**:
-```python
-class NameMixin(ABC)
-```
+- `frame`: The TK frame (e.g. LabelFrame) in the main GUI to add the buttons to
-Provides name property and RPC decode function
+**Returns**:
+List of all added GUI buttons
-
+
-#### set\_rpc\_actions
+#### set\_state
```python
-@abstractmethod
-def set_rpc_actions(action_config) -> None
+def set_state(value, box_state_var)
```
-Set all input device callbacks from :attr:`action_config`
-
-**Arguments**:
+Change the value of a checkbox state variable
-- `action_config`: Dictionary with one
-[RPC Commands](../../builders/rpc-commands.md) definition entry for every device callback
-
+
-## EventProperty Objects
+#### que\_set\_state
```python
-class EventProperty()
+def que_set_state(value, box_state_var)
```
-Event callback property
+Queue the action to change a checkbox state variable to the TK GUI main thread
-
+
-## ButtonBase Objects
+#### fix\_state
```python
-class ButtonBase(ABC)
+def fix_state(box_state_var)
```
-Common stuff for single button devices
+Prevent a checkbox state variable to change on checkbox mouse press
-
+
-#### value
+#### pbox\_set\_state
```python
-@property
-def value()
+def pbox_set_state(value, pbox_state_var, label_var)
```
-Returns 1 if the button is currently pressed, and 0 if it is not.
+Update progress bar state and related state label
-
+
-#### pin
+#### que\_set\_pbox
```python
-@property
-def pin()
+def que_set_pbox(value, pbox_state_var, label_var)
```
-Returns the underlying pin class from GPIOZero.
+Queue the action to change the progress bar state to the TK GUI main thread
-
+
-#### pull\_up
+#### create\_outputs
```python
-@property
-def pull_up()
+def create_outputs(frame, default_btn_width, default_padx, default_pady)
```
-If :data:`True`, the device uses an internal pull-up resistor to set the GPIO pin “high” by default.
+Add all output devices to the GUI
+**Arguments**:
-
+- `frame`: The TK frame (e.g. LabelFrame) in the main GUI to add the representations to
-#### close
+**Returns**:
-```python
-def close()
-```
+List of all added GUI objects
-Close the device and release the pin
+
+# components.rfid.hardware.pn532\_i2c\_py532.pn532\_i2c\_py532
-
+
-## Button Objects
+# components.rfid.hardware.pn532\_i2c\_py532.description
-```python
-class Button(NameMixin, ButtonBase)
-```
+
-A basic Button that runs a single actions on button press
+# components.rfid.hardware.rc522\_spi.rc522\_spi
-**Arguments**:
+
-- `pull_up` (`bool`): If :data:`True`, the device uses an internal pull-up resistor to set the GPIO pin “high” by default.
-If :data:`False` the internal pull-down resistor is used. If :data:`None`, the pin will be floating and an external
-resistor must be used and the :attr:`active_state` must be set.
-- `active_state` (`bool or None`): If :data:`True`, when the hardware pin state is ``HIGH``, the software
-pin is ``HIGH``. If :data:`False`, the input polarity is reversed: when
-the hardware pin state is ``HIGH``, the software pin state is ``LOW``.
-Use this parameter to set the active state of the underlying pin when
-configuring it as not pulled (when *pull_up* is :data:`None`). When
-*pull_up* is :data:`True` or :data:`False`, the active state is
-automatically set to the proper value.
-- `bounce_time` (`float or None`): Specifies the length of time (in seconds) that the component will
-ignore changes in state after an initial change. This defaults to
-:data:`None` which indicates that no bounce compensation will be
-performed.
-- `hold_repeat` (`bool`): If :data:`True` repeat the :attr:`on_press` every :attr:`hold_time` seconds. Else action
-is run only once independent of the length of time the button is pressed for.
-- `hold_time` (`float`): Time in seconds to wait between invocations of :attr:`on_press`.
-- `pin_factory`: The GPIOZero pin factory. This parameter cannot be set through the configuration file
-- `name` (`str`): The name of the button for use in error messages. This parameter cannot be set explicitly
-through the configuration file
+# components.rfid.hardware.rc522\_spi.description
-.. copied from GPIOZero's documentation: active_state, bounce_time
-.. Copyright Ben Nuttall / SPDX-License-Identifier: BSD-3-Clause
+
-
+# components.player
-#### on\_press
+
+
+## MusicLibPath Objects
```python
-@property
-def on_press()
+class MusicLibPath()
```
-The function to run when the device has been pressed
+Extract the music directory from the mpd.conf file
-
+
-## LongPressButton Objects
+#### get\_music\_library\_path
```python
-class LongPressButton(NameMixin, ButtonBase)
+def get_music_library_path()
```
-A Button that runs a single actions only when the button is pressed long enough
+Get the music library path
-**Arguments**:
-- `pull_up`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-- `active_state`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-- `bounce_time`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-- `hold_repeat`: If :data:`True` repeat the :attr:`on_press` every :attr:`hold_time` seconds. Else only action
-is run only once independent of the length of time the button is pressed for.
-- `hold_time`: The minimum time, the button must be pressed be running :attr:`on_press` for the first time.
-Also the time in seconds to wait between invocations of :attr:`on_press`.
+
-
+# components.battery\_monitor.batt\_mon\_i2c\_ina219
-#### on\_press
+
+
+## battmon\_ina219 Objects
```python
-@on_press.setter
-def on_press(func)
+class battmon_ina219(BatteryMonitorBase.BattmonBase)
```
-The function to run when the device has been pressed for longer than :attr:`hold_time`
+Battery Monitor based on a INA219
+See [Battery Monitor documentation](../../builders/components/power/batterymonitor.md)
-
-## ShortLongPressButton Objects
+
+
+# components.battery\_monitor.batt\_mon\_i2c\_ads1015
+
+
+
+## battmon\_ads1015 Objects
```python
-class ShortLongPressButton(NameMixin, ButtonBase)
+class battmon_ads1015(BatteryMonitorBase.BattmonBase)
```
-A single button that runs two different actions depending if the button is pressed for a short or long time.
+Battery Monitor based on a ADS1015
-The shortest possible time is used to ensure a unique identification to an action can be made. For example a short press
-can only be identified, when a button is released before :attr:`hold_time`, i.e. not directly on button press.
-But a long press can be identified as soon as :attr:`hold_time` is reached and there is no need to wait for the release
-event. Furthermore, if there is a long hold, only the long hold action is executed - the short press action is not run
-in this case!
+See [Battery Monitor documentation](../../builders/components/power/batterymonitor.md)
-**Arguments**:
-- `pull_up`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-- `active_state`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-- `bounce_time`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-- `hold_time`: The time in seconds to differentiate if it is a short or long press. If the button is released before
-this time, it is a short press. As soon as the button is held for :attr:`hold_time` it is a long press and the
-short press action is ignored
-- `hold_repeat`: If :data:`True` repeat the long press action every :attr:`hold_time` seconds after first long press
-action
-- `pin_factory`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-- `name`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+
-
+# components.battery\_monitor.batt\_mon\_simulator
-## RotaryEncoder Objects
+
+
+## battmon\_simulator Objects
```python
-class RotaryEncoder(NameMixin)
+class battmon_simulator(BatteryMonitorBase.BattmonBase)
```
-A rotary encoder to run one of two actions depending on the rotation direction.
+Battery Monitor Simulator
-**Arguments**:
-- `bounce_time`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-- `pin_factory`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-- `name`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+
-
+# components.battery\_monitor.BatteryMonitorBase
-#### pin\_a
+
+
+## pt1\_frac Objects
```python
-@property
-def pin_a()
+class pt1_frac()
```
-Returns the underlying pin A
+fixed point first order filter, fractional format: 2^16,2^16
-
+
-#### pin\_b
+## BattmonBase Objects
```python
-@property
-def pin_b()
+class BattmonBase()
```
-Returns the underlying pin B
+Battery Monitor base class
-
+
-#### on\_rotate\_clockwise
+# components.battery\_monitor
-```python
-@property
-def on_rotate_clockwise()
-```
+
-The function to run when the encoder is rotated clockwise
+# components.controls.bluetooth\_audio\_buttons
+Plugin to attempt to automatically listen to it's buttons (play, next, ...)
-
-
-#### on\_rotate\_counter\_clockwise
-
-```python
-@property
-def on_rotate_counter_clockwise()
-```
-
-The function to run when the encoder is rotated counter clockwise
-
+when a bluetooth sound device (headphone, speakers) connects
-
+This effectively does:
-#### close
+* register a callback with components.volume to get notified when a new sound card connects
+* if that is a bluetooth device, try opening an input device with similar name using
+* button listeners are run each in its own thread
-```python
-def close()
-```
-Close the device and release the pin
+
+# components.controls.event\_devices
-
+Plugin to register event_devices (ie USB controllers, keyboards etc) in a
-## TwinButton Objects
+generic manner.
-```python
-class TwinButton(NameMixin)
-```
+This effectively does:
-A two-button device which can run up to six different actions, a.k.a the six function beast.
+ * parse the configured event devices from the evdev.yaml
+ * setup listen threads
-Per user press "input" of the TwinButton, only a single callback is executed (but this callback
-may be executed several times).
-The shortest possible time is used to ensure a unique identification to an action can be made. For example a short press
-can only be identified, when a button is released before :attr:`hold_time`, i.e. not directly on button press.
-But a long press can be identified as soon as :attr:`hold_time` is reached and there is no need to wait for the release
-event. Furthermore, if there is a long hold, only the long hold action is executed - the short press action is not run
-in this case!
-It is not necessary to configure all actions.
+
-**Arguments**:
+#### IS\_ENABLED
-- `pull_up`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-- `active_state`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-- `bounce_time`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-- `hold_time`: The time in seconds to differentiate if it is a short or long press. If the button is released before
-this time, it is a short press. As soon as the button is held for :attr:`hold_time` it is a long press and the
-short press action is ignored.
-- `hold_repeat`: If :data:`True` repeat the long press action every :attr:`hold_time` seconds after first long press
-action. A long dual press is never repeated independent of this setting
-- `pin_factory`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
-- `name`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button)
+Indicates that the module is enabled and loaded w/o errors
-
-## StateVar Objects
+
-```python
-class StateVar(Enum)
-```
+#### CONFIG\_FILE
-State encoding of the Mealy FSM
+The path of the config file the event device configuration was loaded from
-
+
-#### close
+#### activate
```python
-def close()
+@plugin.register
+def activate(device_name: str,
+ button_callbacks: dict[int, Callable],
+ exact: bool = True,
+ mandatory_keys: set[int] | None = None)
```
-Close the device and release the pins
+Activate an event device listener
+**Arguments**:
-
+- `device_name` (`str`): device name
+- `button_callbacks` (`dict[int, Callable]`): mapping of event
+code to RPC
+- `exact` (`bool, optional`): Should the device_name match exactly
+(default, false) or be a substring of the name?
+- `mandatory_keys` (`set[int] | None, optional`): Mandatory event ids the
+device needs to support. Defaults to None
+to require all ids from the button_callbacks
-#### value
+
+
+#### initialize
```python
-@property
-def value()
+@plugin.initialize
+def initialize()
```
-2 bit integer indicating if and which button is currently pressed. Button A is the LSB.
+Initialize event device button listener from config
+Initializes event buttons from the main configuration file.
+Please see the documentation `builders/event-devices.md` for a specification of the format.
-
-#### is\_active
+
+
+#### parse\_device\_config
```python
-@property
-def is_active()
+def parse_device_config(config: dict) -> Tuple[str, bool, dict[int, Callable]]
```
+Parse the device configuration from the config file
+**Arguments**:
-
+- `config` (`dict`): The configuration of the device
-# components.gpio.gpioz.core.output\_devices
+**Returns**:
-Provides all supported output devices for the GPIOZ plugin.
+`Tuple[str, bool, dict[int, Callable]]`: The parsed device configuration
-For each device all constructor parameters can be set via the configuration file. Only exceptions
-are the :attr:`name` and :attr:`pin_factory` which are set by internal mechanisms.
+
-The devices a are a relatively thin wrapper around the GPIOZero devices with the same name.
-We add a name property to be used for error log message and similar and a :func:`flash` function
-to all devices. This function provides a unified API to all devices. This means it can be called for every device
-with parameters for this device and optional parameters from another device. Unused/unsupported parameters
-are silently ignored. This is done to reduce the amount of coding required for connectivity functions.
+# components.controls.common.evdev\_listener
-For examples how to use the devices from the configuration files, see
-[GPIO: Output Devices](../../builders/gpio.md#output-devices).
+Generalized listener for ``dev/input`` devices
-
+
-## LED Objects
+#### find\_device
```python
-class LED(NameMixin, gpiozero.LED)
+def find_device(device_name: str,
+ exact_name: bool = True,
+ mandatory_keys: Optional[Set[int]] = None) -> str
```
-A binary LED
+Find an input device with device_name and mandatory keys.
**Arguments**:
-- `pin`: The GPIO pin which the LED is connected
-- `active_high`: If :data:`true` the output pin will have a high logic level when the device is turned on.
-- `pin_factory`: The GPIOZero pin factory. This parameter cannot be set through the configuration file
-- `name` (`str`): The name of the button for use in error messages. This parameter cannot be set explicitly
-through the configuration file
+- `device_name`: See :func:`_filter_by_device_name`
+- `exact_name`: See :func:`_filter_by_device_name`
+- `mandatory_keys`: See :func:`_filter_by_mandatory_keys`
-
+**Raises**:
-#### flash
+- `FileNotFoundError`: if no device is found.
+- `AttributeError`: if device does not have the mandatory key
+If multiple devices match, the first match is returned
+
+**Returns**:
+
+The path to the device
+
+
+
+## EvDevKeyListener Objects
```python
-def flash(on_time=1, off_time=1, n=1, *, background=True, **ignored_kwargs)
+class EvDevKeyListener(threading.Thread)
```
-Exactly like :func:`blink` but restores the original state after flashing the device
+Opens and event input device from ``/dev/inputs``, and runs callbacks upon the button presses.
-**Arguments**:
+Input devices could be .e.g. Keyboard, Bluetooth audio buttons, USB buttons
-- `on_time` (`float`): Number of seconds on. Defaults to 1 second.
-- `off_time` (`float`): Number of seconds off. Defaults to 1 second.
-- `n`: Number of times to blink; :data:`None` means forever.
-- `background` (`bool`): If :data:`True` (the default), start a background thread to
-continue blinking and return immediately. If :data:`False`, only
-return when the blink is finished
-- `ignored_kwargs`: Ignore all other keywords so this function can be called with identical
-parameters also for all other output devices
+Runs as a separate thread. When device disconnects or disappears, thread exists. A new thread must be started
+when device re-connects.
-
+Assign callbacks to :attr:`EvDevKeyListener.button_callbacks`
-## Buzzer Objects
+
+
+
+#### \_\_init\_\_
```python
-class Buzzer(NameMixin, gpiozero.Buzzer)
+def __init__(device_name_request: str, exact_name: bool, thread_name: str)
```
-
+**Arguments**:
-#### flash
+- `device_name_request`: The device name to look for
+- `exact_name`: If true, device_name must mach exactly, else a match is returned if device_name is a substring of
+the reported device name
+- `thread_name`: Name of the listener thread
+
+
+
+#### run
```python
-def flash(on_time=1, off_time=1, n=1, *, background=True, **ignored_kwargs)
+def run()
```
-Flash the device and restore the previous value afterwards
-
+
-## PWMLED Objects
+#### start
```python
-class PWMLED(NameMixin, gpiozero.PWMLED)
+def start() -> None
```
-
+Start the tread and start listening
-#### flash
-```python
-def flash(on_time=1,
- off_time=1,
- n=1,
- *,
- fade_in_time=0,
- fade_out_time=0,
- background=True,
- **ignored_kwargs)
-```
+
-Flash the LED and restore the previous value afterwards
+# components.controls
+
-
+# components.misc
-## RGBLED Objects
+Miscellaneous function package
-```python
-class RGBLED(NameMixin, gpiozero.RGBLED)
-```
-
+
-#### flash
+#### rpc\_cmd\_help
```python
-def flash(on_time=1,
- off_time=1,
- *,
- fade_in_time=0,
- fade_out_time=0,
- on_color=(1, 1, 1),
- off_color=(0, 0, 0),
- n=None,
- background=True,
- **igorned_kwargs)
+@plugin.register
+def rpc_cmd_help()
```
-Flash the LED with :attr:`on_color` and restore the previous value afterwards
+Return all commands for RPC
-
+
-## TonalBuzzer Objects
+#### get\_all\_loaded\_packages
```python
-class TonalBuzzer(NameMixin, gpiozero.TonalBuzzer)
+@plugin.register
+def get_all_loaded_packages()
```
-
+Get all successfully loaded plugins
-#### flash
+
+
+
+#### get\_all\_failed\_packages
```python
-def flash(on_time=1,
- off_time=1,
- n=1,
- *,
- tone=None,
- background=True,
- **ignored_kwargs)
+@plugin.register
+def get_all_failed_packages()
```
-Play the tone :data:`tone` for :attr:`n` times
+Get all plugins with error during load or initialization
-
+
-#### melody
+#### get\_start\_time
```python
-def melody(on_time=0.2,
- off_time=0.05,
- *,
- tone: Optional[List[Tone]] = None,
- background=True)
+@plugin.register
+def get_start_time()
```
-Play a melody from the list of tones in :attr:`tone`
+Time when JukeBox has been started
-
+
-# components.timers
+#### get\_log
-
+```python
+def get_log(handler_name: str)
+```
+
+Get the log file from the loggers (debug_file_handler, error_file_handler)
+
+
+
+
+#### get\_log\_debug
+
+```python
+@plugin.register
+def get_log_debug()
+```
+
+Get the log file (from the debug_file_handler)
+
+
+
+
+#### get\_log\_error
+
+```python
+@plugin.register
+def get_log_error()
+```
+
+Get the log file (from the error_file_handler)
+
+
+
+
+#### get\_git\_state
+
+```python
+@plugin.register
+def get_git_state()
+```
+
+Return git state information for the current branch
+
+
+
+
+#### empty\_rpc\_call
+
+```python
+@plugin.register
+def empty_rpc_call(msg: str = '')
+```
+
+This function does nothing.
+
+The RPC command alias 'none' is mapped to this function.
+
+This is also used when configuration errors lead to non existing RPC command alias definitions.
+When the alias definition is void, we still want to return a valid function to simplify error handling
+up the module call stack.
+
+**Arguments**:
+
+- `msg`: If present, this message is send to the logger with severity warning
+
+
+
+#### get\_app\_settings
+
+```python
+@plugin.register
+def get_app_settings()
+```
+
+Return settings for web app stored in jukebox.yaml
+
+
+
+
+#### set\_app\_settings
+
+```python
+@plugin.register
+def set_app_settings(settings={})
+```
+
+Set configuration settings for the web app.
+
+
+
+
+# components.publishing
+
+Plugin interface for Jukebox Publisher
+
+Thin wrapper around jukebox.publishing to benefit from the plugin loading / exit handling / function handling
+
+This is the first package to be loaded and the last to be closed: put Hello and Goodbye publish messages here.
+
+
+
+
+#### republish
+
+```python
+@plugin.register
+def republish(topic=None)
+```
+
+Re-publish the topic tree 'topic' to all subscribers
+
+**Arguments**:
+
+- `topic`: Topic tree to republish. None = resend all
+
+
# jukebox
@@ -4131,1817 +4192,2104 @@ def has_callbacks()
-
-
-# jukebox.version
-
-
+
-#### version
+# jukebox.plugs
-```python
-def version()
-```
+A plugin package with some special functionality
-Return the Jukebox version as a string
+Plugins packages are python packages that are dynamically loaded. From these packages only a subset of objects is exposed
+through the plugs.call interface. The python packages can use decorators or dynamic function call to register (callable)
+objects.
+The python package name may be different from the name the package is registered under in plugs. This allows to load different
+python packages for a specific feature based on a configuration file. Note: Python package are still loaded as regular
+python packages and can be accessed by normal means
-
+If you want to provide additional functionality to the same feature (probably even for run-time switching)
+you can implement a Factory Pattern using this package. Take a look at volume.py as an example.
-#### version\_info
+**Example:** Decorate a function for auto-registering under it's own name:
-```python
-def version_info()
-```
+ import jukebox.plugs as plugs
+ @plugs.register
+ def func1(param):
+ pass
-Return the Jukebox version as a tuple of three numbers
+**Example:** Decorate a function for auto-registering under a new name:
-If this is a development version, an identifier string will be appended after the third integer.
+ @plugs.register(name='better_name')
+ def func2(param):
+ pass
+**Example:** Register a function during run-time under it's own name:
-
+ def func3(param):
+ pass
+ plugs.register(func3)
-# jukebox.cfghandler
+**Example:** Register a function during run-time under a new name:
-This module handles global and local configuration data
+ def func4(param):
+ pass
+ plugs.register(func4, name='other_name', package='other_package')
-The concept is that config handler is created and initialized once in the main thread::
+**Example:** Decorate a class for auto registering during initialization,
+including all methods (see _register_class for more info):
- cfg = get_handler('global')
- load_yaml(cfg, 'filename.yaml')
+ @plugs.register(auto_tag=True)
+ class MyClass1:
+ pass
-In all other modules (in potentially different threads) the same handler is obtained and used by::
+**Example:** Register a class instance, from which only report is a callable method through the plugs interface:
- cfg = get_handler('global')
+ class MyClass2:
+ @plugs.tag
+ def report(self):
+ pass
+ myinst2 = MyClass2()
+ plugin.register(myinst2, name='myinst2')
-This eliminates the need to pass an effectively global configuration handler by parameters across the entire design.
-Handlers are identified by their name (in the above example *global*)
+Naming convention:
-The function :func:`get_handler` is the main entry point to obtain a new or existing handler.
+* package
+ * Either a python package
+ * or a plugin package (which is the python package but probably loaded under a different name inside plugs)
+* plugin
+ * An object from the package that can be accessed through the plugs call function (i.e. a function or a class instance)
+ * The string name to above object
+* name
+ * The string name of the plugin object for registration
+* method
+ * In case the object is a class instance a bound method to call from the class instance
+ * The string name to above object
-
+
-## ConfigHandler Objects
+## PluginPackageClass Objects
```python
-class ConfigHandler()
+class PluginPackageClass()
```
-The configuration handler class
-
-Don't instantiate directly. Always use :func:`get_handler`!
-
-**Threads:**
+A local data class for holding all information about a loaded plugin package
-All threads can read and write to the configuration data.
-**Proper thread-safeness must be ensured** by the the thread modifying the data by acquiring the lock
-Easiest and best way is to use the context handler::
- with cfg:
- cfg['key'] = 66
- cfg.setndefault('hello', value='world')
+
-For a single function call, this is done implicitly. In this case, there is no need
-to explicitly acquire the lock.
+#### register
-Alternatively, you can lock and release manually by using :func:`acquire` and :func:`release`
-But be very sure to release the lock even in cases of errors an exceptions!
-Else we have a deadlock.
+```python
+@overload
+def register(plugin: Callable) -> Callable
+```
-Reading may be done without acquiring a lock. But be aware that when reading multiple values without locking, another
-thread may intervene and modify some values in between! So, locking is still recommended.
+1-level decorator around a function
-
+
-#### loaded\_from
+#### register
```python
-@property
-def loaded_from() -> Optional[str]
+@overload
+def register(plugin: Type) -> Any
```
-Property to store filename from which the config was loaded
+Signature: 1-level decorator around a class
-
+
-#### get
+#### register
```python
-def get(key, *, default=None)
+@overload
+def register(*, name: str, package: Optional[str] = None) -> Callable
```
-Enforce keyword on default to avoid accidental misuse when actually getn is wanted
+Signature: 2-level decorator around a function
-
+
-#### setdefault
+#### register
```python
-def setdefault(key, *, value)
+@overload
+def register(*, auto_tag: bool = False, package: Optional[str] = None) -> Type
```
-Enforce keyword on default to avoid accidental misuse when actually setndefault is wanted
+Signature: 2-level decorator around a class
-
+
-#### getn
+#### register
```python
-def getn(*keys, default=None)
+@overload
+def register(plugin: Callable[..., Any] = None,
+ *,
+ name: Optional[str] = None,
+ package: Optional[str] = None,
+ replace: bool = False) -> Callable
```
-Get the value at arbitrary hierarchy depth. Return ``default`` if key not present
-
-The *default* value is returned no matter at which hierarchy level the path aborts.
-A hierarchy is considered as any type with a :func:`get` method.
+Signature: Run-time registration of function / class instance / bound method
-
+
-#### setn
+#### register
```python
-def setn(*keys, value, hierarchy_type=None) -> None
+def register(plugin: Optional[Callable] = None,
+ *,
+ name: Optional[str] = None,
+ package: Optional[str] = None,
+ replace: bool = False,
+ auto_tag: bool = False) -> Callable
```
-Set the ``key: value`` pair at arbitrary hierarchy depth
+A generic decorator / run-time function to register plugin module callables
-All non-existing hierarchy levels are created.
+The functions comes in five distinct signatures for 5 use cases:
+
+1. ``@plugs.register``: decorator for a class w/o any arguments
+2. ``@plugs.register``: decorator for a function w/o any arguments
+3. ``@plugs.register(auto_tag=bool)``: decorator for a class with 1 arguments
+4. ``@plugs.register(name=name, package=package)``: decorator for a function with 1 or 2 arguments
+5. ``plugs.register(plugin, name=name, package=package)``: run-time registration of
+ * function
+ * bound method
+ * class instance
+
+For more documentation see the functions
+* :func:`_register_obj`
+* :func:`_register_class`
+
+See the examples in Module :mod:`plugs` how to use this decorator / function
**Arguments**:
-- `keys`: Key hierarchy path through the nested levels
-- `value`: The value to set
-- `hierarchy_type`: The type for new hierarchy levels. If *None*, the top-level type
-is used
+- `plugin`:
+- `name`:
+- `package`:
+- `replace`:
+- `auto_tag`:
-
+
-#### setndefault
+#### tag
```python
-def setndefault(*keys, value, hierarchy_type=None)
+def tag(func: Callable) -> Callable
```
-Set the ``key: value`` pair at arbitrary hierarchy depth unless the key already exists
+Method decorator for tagging a method as callable through the plugs interface
-All non-existing hierarchy levels are created.
+Note that the instantiated class must still be registered as plugin object
+(either with the class decorator or dynamically)
**Arguments**:
-- `keys`: Key hierarchy path through the nested levels
-- `value`: The default value to set
-- `hierarchy_type`: The type for new hierarchy levels. If *None*, the top-level type
-is used
+- `func`: function to decorate
**Returns**:
-The actual value or or the default value if key does not exit
+the function
-
+
-#### config\_dict
+#### initialize
```python
-def config_dict(data)
+def initialize(func: Callable) -> Callable
```
-Initialize configuration data from dict-like data structure
+Decorator for functions that shall be called by the plugs package directly after the module is loaded
**Arguments**:
-- `data`: configuration data
-
-
+- `func`: Function to decorate
-#### is\_modified
+**Returns**:
-```python
-def is_modified() -> bool
-```
+The function itself
-Check if the data has changed since the last load/store
+
-> [!NOTE]
-> This relies on the *__str__* representation of the underlying data structure
-> In case of ruamel, this ignores comments and only looks at the data
+#### finalize
+```python
+def finalize(func: Callable) -> Callable
+```
-
+Decorator for functions that shall be called by the plugs package directly after ALL modules are loaded
-#### clear\_modified
+**Arguments**:
-```python
-def clear_modified() -> None
-```
+- `func`: Function to decorate
-Sets the current state as new baseline, clearing the is_modified state
+**Returns**:
+The function itself
-
+
-#### save
+#### atexit
```python
-def save(only_if_changed: bool = False) -> None
+def atexit(func: Callable[[int], Any]) -> Callable[[int], Any]
```
-Save config back to the file it was loaded from
-
-If you want to save to a different file, use :func:`write_yaml`.
+Decorator for functions that shall be called by the plugs package directly after at exit of program.
+> [!IMPORTANT]
+> There is no automatism as in atexit.atexit. The function plugs.shutdown() must be explicitly called
+> during the shutdown procedure of your program. This is by design, so you can choose the exact situation in your
+> shutdown handler.
-
+The atexit-functions are called with a single integer argument, which is passed down from plugin.exit(int)
+It is intended for passing down the signal number that initiated the program termination
-#### load
+**Arguments**:
-```python
-def load(filename: str) -> None
-```
+- `func`: Function to decorate
-Load YAML config file into memory
+**Returns**:
+The function itself
-
+
-#### get\_handler
+#### load
```python
-def get_handler(name: str) -> ConfigHandler
+def load(package: str,
+ load_as: Optional[str] = None,
+ prefix: Optional[str] = None)
```
-Get a configuration data handler with the specified name, creating it
+Loads a python package as plugin package
-if it doesn't yet exit. If created, it is always created empty.
+Executes a regular python package load. That means a potentially existing `__init__.py` is executed.
+Decorator `@register` can by used to register functions / classes / class istances as plugin callable
+Decorator `@initializer` can be used to tag functions that shall be called after package loading
+Decorator `@finalizer` can be used to tag functions that shall be called after ALL plugin packges have been loaded
+Instead of using `@initializer`, you may of course use `__init__.py`
-This is the main entry point for obtaining an configuration handler
+Python packages may be loaded under a different plugs package name. Python packages must be unique and the name under
+which they are loaded as plugin package also.
**Arguments**:
-- `name`: Name of the config handler
-
-**Returns**:
-
-`ConfigHandler`: The configuration data handler for *name*
+- `package`: Python package to load as plugin package
+- `load_as`: Plugin package registration name. If None the name is the python's package simple name
+- `prefix`: Prefix to python package to create fully qualified name. This is used only to locate the python package
+and ignored otherwise. Useful if all the plugin module are in a dedicated folder
-
+
-#### load\_yaml
+#### load\_all\_named
```python
-def load_yaml(cfg: ConfigHandler, filename: str) -> None
+def load_all_named(packages_named: Mapping[str, str],
+ prefix: Optional[str] = None,
+ ignore_errors=False)
```
-Load a yaml file into a ConfigHandler
+Load all packages in packages_named with mapped names
**Arguments**:
-- `cfg`: ConfigHandler instance
-- `filename`: filename to yaml file
-
-**Returns**:
-
-None
+- `packages_named`: Dict[load_as, package]
-
+
-#### write\_yaml
+#### load\_all\_unnamed
```python
-def write_yaml(cfg: ConfigHandler,
- filename: str,
- only_if_changed: bool = False,
- *args,
- **kwargs) -> None
+def load_all_unnamed(packages_unnamed: Iterable[str],
+ prefix: Optional[str] = None,
+ ignore_errors=False)
```
-Writes ConfigHandler data to yaml file / sys.stdout
+Load all packages in packages_unnamed with default names
-**Arguments**:
-- `cfg`: ConfigHandler instance
-- `filename`: filename to output file. If *sys.stdout*, output is written to console
-- `only_if_changed`: Write file only, if ConfigHandler.is_modified()
-- `args`: passed on to yaml.dump(...)
-- `kwargs`: passed on to yaml.dump(...)
+
-**Returns**:
+#### load\_all\_finalize
-None
+```python
+def load_all_finalize(ignore_errors=False)
+```
-
+Calls all functions registered with @finalize from all loaded modules in the order they were loaded
-# jukebox.playlistgenerator
+This must be executed after the last plugin package is loaded
-Playlists are build from directory content in the following way:
-a directory is parsed and files are added to the playlist in the following way
+
-1. files are added in alphabetic order
-2. files ending with ``*livestream.txt`` are unpacked and the containing URL(s) are added verbatim to the playlist
-3. files ending with ``*podcast.txt`` are unpacked and the containing Podcast URL(s) are expanded and added to the playlist
-4. files ending with ``*.m3u`` are treated as folder playlist. Regular folder processing is suspended and the playlist
- is build solely from the ``*.m3u`` content. Only the alphabetically first ``*.m3u`` is processed. URLs are added verbatim
- to the playlist except for ``*.xml`` and ``*.podcast`` URLS, which are expanded first
+#### close\_down
-An directory may contain a mixed set of files and multiple ``*.txt`` files, e.g.
+```python
+def close_down(**kwargs) -> Any
+```
- 01-livestream.txt
- 02-livestream.txt
- music.mp3
- podcast.txt
+Calls all functions registered with @atexit from all loaded modules in reverse order of module load order
-All files are treated as music files and are added to the playlist, except those:
+Modules are processed in reverse order. Several at-exit tagged functions of a single module are processed
+in the order of registration.
- * starting with ``.``,
- * not having a file ending, i.e. do not contain a ``.``,
- * ending with ``.txt``,
- * ending with ``.m3u``,
- * ending with one of the excluded file endings in :attr:`PlaylistCollector._exclude_endings`
+Errors raised in functions are suppressed to ensure all plugins are processed
-In recursive mode, the playlist is generated by concatenating all sub-folder playlists. Sub-folders are parsed
-in alphabetic order. Symbolic links are being followed. The above rules are enforced on a per-folder bases.
-This means, one ``*.m3u`` file per sub-folder is processed (if present).
-In ``*.txt`` and ``*.m3u`` files, all lines starting with ``#`` are ignored.
+
+#### call
-
+```python
+def call(package: str,
+ plugin: str,
+ method: Optional[str] = None,
+ *,
+ args=(),
+ kwargs=None,
+ as_thread: bool = False,
+ thread_name: Optional[str] = None) -> Any
+```
-#### TYPE\_DECODE
+Call a function/method from the loaded plugins
-Types if file entires in parsed directory
+If a plugin is a function or a callable instance of a class, this is equivalent to
+``package.plugin(*args, **kwargs)``
-
+If plugin is a class instance from which a method is called, this is equivalent to the followig.
+Also remember, that method must have the attribute ``plugin_callable = True``
-## PlaylistCollector Objects
+``package.plugin.method(*args, **kwargs)``
-```python
-class PlaylistCollector()
-```
-
-Build a playlist from directory(s)
-
-This class is intended to be used with an absolute path to the music library::
+Calls are serialized by a thread lock. The thread lock is shared with call_ignore_errors.
- plc = PlaylistCollector('/home/chris/music')
- plc.parse('Traumfaenger')
- print(f"res = {plc}")
+> [!NOTE]
+> There is no logger in this function as they all belong up-level where the exceptions are handled.
+> If you want logger messages instead of exceptions, use :func:`call_ignore_errors`
-But it can also be used with relative paths from current working directory::
+**Arguments**:
- plc = PlaylistCollector('.')
- plc.parse('../../../../music/Traumfaenger')
- print(f"res = {plc}")
+- `package`: Name of the plugin package in which to look for function/class instance
+- `plugin`: Function name or instance name of a class
+- `method`: Method name when accessing a class instance' method. Leave at *None* if unneeded.
+- `as_thread`: Run the callable in separate daemon thread.
+There is no return value from the callable in this case! The return value is the thread object.
+Also note that Exceptions in the Thread must be handled in the Thread and are not propagated to the main Thread.
+All threads are started as daemon threads with terminate upon main program termination.
+There is not stop-thread mechanism. This is intended for short lived threads.
+- `thread_name`: Name of the thread
+- `args`: Arguments passed to callable
+- `kwargs`: Keyword arguments passed to callable
-The file ending exclusion list :attr:`PlaylistCollector._exclude_endings` is a class variable for performance reasons.
-If changed it will affect all instances. For modifications always call :func:`set_exclusion_endings`.
+**Returns**:
+The return value from the called function, or, if started as thread the thread object
-
+
-#### \_\_init\_\_
+#### call\_ignore\_errors
```python
-def __init__(music_library_base_path='/')
+def call_ignore_errors(package: str,
+ plugin: str,
+ method: Optional[str] = None,
+ *,
+ args=(),
+ kwargs=None,
+ as_thread: bool = False,
+ thread_name: Optional[str] = None) -> Any
```
-Initialize the playlist generator with music_library_base_path
+Call a function/method from the loaded plugins ignoring all raised Exceptions.
-**Arguments**:
+Errors get logged.
-- `music_library_base_path`: Base path the the music library. This is used to locate the file in the disk
-but is omitted when generating the playlist entries. I.e. all files in the playlist are relative to this base dir
+See :func:`call` for parameter documentation.
-
-#### set\_exclusion\_endings
+
+
+#### exists
```python
-@classmethod
-def set_exclusion_endings(cls, endings: List[str])
+def exists(package: str,
+ plugin: Optional[str] = None,
+ method: Optional[str] = None) -> bool
```
-Set the class-wide file ending exclusion list
-
-See :attr:`PlaylistCollector._exclude_endings`
+Check if an object is registered within the plugs package
-
+
-#### get\_directory\_content
+#### get
```python
-def get_directory_content(path='.')
+def get(package: str,
+ plugin: Optional[str] = None,
+ method: Optional[str] = None) -> Any
```
-Parse the folder ``path`` and create a content list. Depth is always the current level
-
-**Arguments**:
+Get a plugs-package registered object
-- `path`: Path to folder **relative** to ``music_library_base_path``
+The return object depends on the number of parameters
-**Returns**:
+* 1 argument: Get the python module reference for the plugs *package*
+* 2 arguments: Get the plugin reference for the plugs *package.plugin*
+* 3 arguments: Get the plugin reference for the plugs *package.plugin.method*
-[ { type: 'directory', name: 'Simone', path: '/some/path/to/Simone' }, {...} ]
-where type is one of :attr:`TYPE_DECODE`
-
+
-#### parse
+#### loaded\_as
```python
-def parse(path='.', recursive=False)
+def loaded_as(module_name: str) -> str
```
-Parse the folder ``path`` and create a playlist from it's content
+Return the plugin name a python module is loaded as
-**Arguments**:
-- `path`: Path to folder **relative** to ``music_library_base_path``
-- `recursive`: Parse folder recursivley, or stay in top-level folder
+
-
+#### delete
-# jukebox.NvManager
+```python
+def delete(package: str, plugin: Optional[str] = None, ignore_errors=False)
+```
-
+Delete a plugin object from the registered plugs callables
-# jukebox.publishing
+> [!NOTE]
+> This does not 'unload' the python module. It merely makes it un-callable via plugs!
-
-#### get\_publisher
+
+
+#### dump\_plugins
```python
-def get_publisher()
+def dump_plugins(stream)
```
-Return the publisher instance for this thread
+Write a human readable summary of all plugin callables to stream
-Per thread, only one publisher instance is required to connect to the inproc socket.
-A new instance is created if it does not already exist.
-If there is a remote-chance that your function publishing something may be called form
-different threads, always make a fresh call to ``get_publisher()`` to get the correct instance for the current thread.
+
-Example::
+#### summarize
- import jukebox.publishing as publishing
+```python
+def summarize()
+```
- class MyClass:
- def __init__(self):
- pass
+Create a reference summary of all plugin callables in dictionary format
- def say_hello(name):
- publishing.get_publisher().send('hello', f'Hi {name}, howya?')
-To stress what **NOT** to do: don't get a publisher instance in the constructor and save it to ``self._pub``.
-If you do and ``say_hello`` gets called from different threads, the publisher of the thread which instantiated the class
-will be used.
+
-If you need your very own private Publisher Instance, you'll need to instantiate it yourself.
-But: the use cases are very rare for that. I cannot think of one at the moment.
+#### generate\_help\_rst
-**Remember**: Don’t share ZeroMQ sockets between threads.
+```python
+def generate_help_rst(stream)
+```
+Write a reference of all plugin callables in Restructured Text format
-
-# jukebox.publishing.subscriber
+
-
+#### get\_all\_loaded\_packages
-# jukebox.publishing.server
+```python
+def get_all_loaded_packages() -> Dict[str, str]
+```
-## Publishing Server
+Report a short summary of all loaded packages
-The common publishing server for the entire Jukebox using ZeroMQ
+**Returns**:
-### Structure
+Dictionary of the form `{loaded_as: loaded_from, ...}`
- +-----------------------+
- | functional interface | Publisher
- | | - functional interface for single Thread
- | PUB | - sends data to publisher (and thus across threads)
- +-----------------------+
- | (1)
- v
- +-----------------------+
- | SUB (bind) | PublishServer
- | | - Last Value (LV) Cache
- | XPUB (bind) | - Subscriber notification and LV resend
- +-----------------------+ - independent thread
- | (2)
- v
+
-#### Connection (1): Internal connection
+#### get\_all\_failed\_packages
-Internal connection only - do not use (no, not even inside this App for you own plugins - always bind to the PublishServer)
+```python
+def get_all_failed_packages() -> Dict[str, str]
+```
- Protocol: Multi-part message
+Report those packages that did not load error free
- Part 1: Topic (in topic tree format)
- E.g. player.status.elapsed
+> [!NOTE]
+> Package could fail to load
+> * altogether: these package are not registered
+> * partially: during initializer, finalizer functions: The package is loaded,
+> but the function did not execute error-free
+>
+> Partially loaded packages are listed in both _PLUGINS and _PLUGINS_FAILED
- Part 2: Payload or Message in json serialization
- If empty (i.e. ``b''``), it means delete the topic sub-tree from cache. And instruct subscribers to do the same
+**Returns**:
- Part 3: Command
- Usually empty, i.e. ``b''``. If not empty the message is treated as command for the PublishServer
- and the message is not forwarded to the outside. This third part of the message is never forwarded
+Dictionary of the form `{loaded_as: loaded_from, ...}`
-#### Connection (2): External connection
+
-Upon connection of a new subscriber, the entire current state is resend from cache to ALL subscribers!
-Subscribers must subscribe to topics. Topics are treated as topic trees! Subscribing to a root tree will
-also get you all the branch topics. To get everything, subscribe to ``b''``
+# jukebox.cfghandler
- Protocol: Multi-part message
+This module handles global and local configuration data
- Part 1: Topic (in topic tree format)
- E.g. player.status.elapsed
+The concept is that config handler is created and initialized once in the main thread::
- Part 2: Payload or Message in json serialization
- If empty (i.e. b''), it means the subscriber must delete this key locally (not valid anymore)
+ cfg = get_handler('global')
+ load_yaml(cfg, 'filename.yaml')
-### Why? Why?
+In all other modules (in potentially different threads) the same handler is obtained and used by::
-Check out the [ZeroMQ Documentation](https://zguide.zeromq.org/docs/chapter5)
-for why you need a proxy in a good design.
+ cfg = get_handler('global')
-For use case, we made a few simplifications
+This eliminates the need to pass an effectively global configuration handler by parameters across the entire design.
+Handlers are identified by their name (in the above example *global*)
-### Design Rationales
+The function :func:`get_handler` is the main entry point to obtain a new or existing handler.
-* "If you need [millions of messages per second](https://zguide.zeromq.org/docs/chapter5/`Pros`-and-Cons-of-Pub-Sub)
- sent to thousands of points,
- you'll appreciate pub-sub a lot more than if you need a few messages a second sent to a handful of recipients."
-* "lower-volume network with a few dozen subscribers and a limited number of topics, we can use TCP and then
- the [XSUB and XPUB](https://zguide.zeromq.org/docs/chapter5/`Last`-Value-Caching)"
-* "Let's imagine [our feed has an average of 100,000 100-byte messages a
- second](https://zguide.zeromq.org/docs/chapter5/`High`-Speed-Subscribers-Black-Box-Pattern) [...].
- While 100K messages a second is easy for a ZeroMQ application, ..."
-**But we have:**
+
-* few dozen subscribers --> Check!
-* limited number of topics --> Check!
-* max ~10 messages per second --> Check!
-* small common state information --> Check!
-* only the server updates the state --> Check!
+## ConfigHandler Objects
-This means, we can use less complex patters than used for these high-speed, high code count, high data rate networks :-)
+```python
+class ConfigHandler()
+```
-* XPUB / XSUB to detect new subscriber
-* Cache the entire state in the publisher
-* Re-send the entire state on-demand (and then even to every subscriber)
-* Using the same channel: sends state to every subscriber
+The configuration handler class
-**Reliability considerations**
+Don't instantiate directly. Always use :func:`get_handler`!
-* Late joining client (or drop-off and re-join): get full state update
-* Server crash etc: No special handling necessary, we are simple
- and don't need recovery in this case. Server will publish initial state
- after re-start
-* Subscriber too slow: Subscribers problem (TODO: Do we need to do anything about it?)
-
-**Start-up sequence:**
-
-* Publisher plugin is first plugin to be loaded
-* Due to Publisher - PublisherServer structure no further sequencing required
+**Threads:**
-### Plugin interactions and usage
+All threads can read and write to the configuration data.
+**Proper thread-safeness must be ensured** by the the thread modifying the data by acquiring the lock
+Easiest and best way is to use the context handler::
-RPC can trigger through function call in components/publishing plugin that
+ with cfg:
+ cfg['key'] = 66
+ cfg.setndefault('hello', value='world')
-* entire state is re-published (from the cache)
-* a specific topic tree is re-published (from the cache)
+For a single function call, this is done implicitly. In this case, there is no need
+to explicitly acquire the lock.
-Plugins publishing state information should publish initial state at @plugin.finalize
+Alternatively, you can lock and release manually by using :func:`acquire` and :func:`release`
+But be very sure to release the lock even in cases of errors an exceptions!
+Else we have a deadlock.
-> [!IMPORTANT]
-> Do not direclty instantiate the Publisher in your plugin module. Only one Publisher is
-> required per thread. But the publisher instance **must** be thread-local!
-> Always go through :func:`publishing.get_publisher()`.
+Reading may be done without acquiring a lock. But be aware that when reading multiple values without locking, another
+thread may intervene and modify some values in between! So, locking is still recommended.
-**Sockets**
-Three sockets are opened:
+
-1. TCP (on a configurable port)
-2. Websocket (on a configurable port)
-3. Inproc: On ``inproc://PublisherToProxy`` all topics are published app-internally. This can be used for plugin modules
- that want to know about the current state on event based updates.
+#### loaded\_from
-**Further ZeroMQ References:**
+```python
+@property
+def loaded_from() -> Optional[str]
+```
-* [Working with Messages](https://zguide.zeromq.org/docs/chapter2/`Working`-with-Messages)
-* [Multiple Threads](https://zguide.zeromq.org/docs/chapter2/`Multithreading`-with-ZeroMQ)
+Property to store filename from which the config was loaded
-
+
-## PublishServer Objects
+#### get
```python
-class PublishServer(threading.Thread)
+def get(key, *, default=None)
```
-The publish proxy server that collects and caches messages from all internal publishers and
-
-forwards them to the outside world
-
-Handles new subscriptions by sending out the entire cached state to **all** subscribers
-
-The code is structures using a [Reactor Pattern](https://zguide.zeromq.org/docs/chapter5/`Using`-a-Reactor)
+Enforce keyword on default to avoid accidental misuse when actually getn is wanted
-
+
-#### run
+#### setdefault
```python
-def run()
+def setdefault(key, *, value)
```
-Thread's activity
+Enforce keyword on default to avoid accidental misuse when actually setndefault is wanted
-
+
-#### handle\_message
+#### getn
```python
-def handle_message(msg)
+def getn(*keys, default=None)
```
-Handle incoming messages
+Get the value at arbitrary hierarchy depth. Return ``default`` if key not present
+The *default* value is returned no matter at which hierarchy level the path aborts.
+A hierarchy is considered as any type with a :func:`get` method.
-
-#### handle\_subscription
+
+
+#### setn
```python
-def handle_subscription(msg)
+def setn(*keys, value, hierarchy_type=None) -> None
```
-Handle new subscribers
+Set the ``key: value`` pair at arbitrary hierarchy depth
+All non-existing hierarchy levels are created.
-
+**Arguments**:
-## Publisher Objects
+- `keys`: Key hierarchy path through the nested levels
+- `value`: The value to set
+- `hierarchy_type`: The type for new hierarchy levels. If *None*, the top-level type
+is used
+
+
+
+#### setndefault
```python
-class Publisher()
+def setndefault(*keys, value, hierarchy_type=None)
```
-The publisher that provides the functional interface to the application
+Set the ``key: value`` pair at arbitrary hierarchy depth unless the key already exists
-> [!NOTE]
-> * An instance must not be shared across threads!
-> * One instance per thread is enough
+All non-existing hierarchy levels are created.
+**Arguments**:
-
+- `keys`: Key hierarchy path through the nested levels
+- `value`: The default value to set
+- `hierarchy_type`: The type for new hierarchy levels. If *None*, the top-level type
+is used
-#### \_\_init\_\_
+**Returns**:
+
+The actual value or or the default value if key does not exit
+
+
+
+#### config\_dict
```python
-def __init__(check_thread_owner=True)
+def config_dict(data)
```
+Initialize configuration data from dict-like data structure
+
**Arguments**:
-- `check_thread_owner`: Check if send() is always called from the correct thread. This is debug feature
-and is intended to expose the situation before it leads to real trouble. Leave it on!
+- `data`: configuration data
-
+
-#### send
+#### is\_modified
```python
-def send(topic: str, payload)
+def is_modified() -> bool
```
-Send out a message for topic
+Check if the data has changed since the last load/store
+
+> [!NOTE]
+> This relies on the *__str__* representation of the underlying data structure
+> In case of ruamel, this ignores comments and only looks at the data
-
+
-#### revoke
+#### clear\_modified
```python
-def revoke(topic: str)
+def clear_modified() -> None
```
-Revoke a single topic element (not a topic tree!)
+Sets the current state as new baseline, clearing the is_modified state
-
+
-#### resend
+#### save
```python
-def resend(topic: Optional[str] = None)
+def save(only_if_changed: bool = False) -> None
```
-Instructs the PublishServer to resend current status to all subscribers
+Save config back to the file it was loaded from
-Not necessary to call after incremental updates or new subscriptions - that will happen automatically!
+If you want to save to a different file, use :func:`write_yaml`.
-
+
-#### close\_server
+#### load
```python
-def close_server()
+def load(filename: str) -> None
```
-Instructs the PublishServer to close itself down
-
-
-
+Load YAML config file into memory
-# jukebox.daemon
-
+
-#### log\_active\_threads
+#### get\_handler
```python
-@atexit.register
-def log_active_threads()
+def get_handler(name: str) -> ConfigHandler
```
-This functions is registered with atexit very early, meaning it will be run very late. It is the best guess to
+Get a configuration data handler with the specified name, creating it
-evaluate which Threads are still running (and probably shouldn't be)
+if it doesn't yet exit. If created, it is always created empty.
-This function is registered before all the plugins and their dependencies are loaded
+This is the main entry point for obtaining an configuration handler
+**Arguments**:
-
+- `name`: Name of the config handler
-## JukeBox Objects
+**Returns**:
-```python
-class JukeBox()
-```
+`ConfigHandler`: The configuration data handler for *name*
-
+
-#### signal\_handler
+#### load\_yaml
```python
-def signal_handler(esignal, frame)
+def load_yaml(cfg: ConfigHandler, filename: str) -> None
```
-Signal handler for orderly shutdown
+Load a yaml file into a ConfigHandler
-On first Ctrl-C (or SIGTERM) orderly shutdown procedure is embarked upon. It gets allocated a time-out!
-On third Ctrl-C (or SIGTERM), this is interrupted and there will be a hard exit!
+**Arguments**:
+- `cfg`: ConfigHandler instance
+- `filename`: filename to yaml file
-
+**Returns**:
-# jukebox.plugs
+None
-A plugin package with some special functionality
+
-Plugins packages are python packages that are dynamically loaded. From these packages only a subset of objects is exposed
-through the plugs.call interface. The python packages can use decorators or dynamic function call to register (callable)
-objects.
+#### write\_yaml
-The python package name may be different from the name the package is registered under in plugs. This allows to load different
-python packages for a specific feature based on a configuration file. Note: Python package are still loaded as regular
-python packages and can be accessed by normal means
+```python
+def write_yaml(cfg: ConfigHandler,
+ filename: str,
+ only_if_changed: bool = False,
+ *args,
+ **kwargs) -> None
+```
-If you want to provide additional functionality to the same feature (probably even for run-time switching)
-you can implement a Factory Pattern using this package. Take a look at volume.py as an example.
+Writes ConfigHandler data to yaml file / sys.stdout
-**Example:** Decorate a function for auto-registering under it's own name:
+**Arguments**:
- import jukebox.plugs as plugs
- @plugs.register
- def func1(param):
- pass
+- `cfg`: ConfigHandler instance
+- `filename`: filename to output file. If *sys.stdout*, output is written to console
+- `only_if_changed`: Write file only, if ConfigHandler.is_modified()
+- `args`: passed on to yaml.dump(...)
+- `kwargs`: passed on to yaml.dump(...)
-**Example:** Decorate a function for auto-registering under a new name:
+**Returns**:
- @plugs.register(name='better_name')
- def func2(param):
- pass
+None
-**Example:** Register a function during run-time under it's own name:
+
- def func3(param):
- pass
- plugs.register(func3)
+# jukebox.speaking\_text
-**Example:** Register a function during run-time under a new name:
+Text to Speech. Plugin to speak any given text via speaker
- def func4(param):
- pass
- plugs.register(func4, name='other_name', package='other_package')
-
-**Example:** Decorate a class for auto registering during initialization,
-including all methods (see _register_class for more info):
-
- @plugs.register(auto_tag=True)
- class MyClass1:
- pass
-**Example:** Register a class instance, from which only report is a callable method through the plugs interface:
-
- class MyClass2:
- @plugs.tag
- def report(self):
- pass
- myinst2 = MyClass2()
- plugin.register(myinst2, name='myinst2')
+
-Naming convention:
+# jukebox.utils
-* package
- * Either a python package
- * or a plugin package (which is the python package but probably loaded under a different name inside plugs)
-* plugin
- * An object from the package that can be accessed through the plugs call function (i.e. a function or a class instance)
- * The string name to above object
-* name
- * The string name of the plugin object for registration
-* method
- * In case the object is a class instance a bound method to call from the class instance
- * The string name to above object
+Common utility functions
-
+
-## PluginPackageClass Objects
+#### decode\_rpc\_call
```python
-class PluginPackageClass()
+def decode_rpc_call(cfg_rpc_call: Dict) -> Optional[Dict]
```
-A local data class for holding all information about a loaded plugin package
-
+Makes sure that the core rpc call parameters have valid default values in cfg_rpc_call.
-
+> [!IMPORTANT]
+> Leaves all other parameters in cfg_action untouched or later downstream processing!
-#### register
+**Arguments**:
-```python
-@overload
-def register(plugin: Callable) -> Callable
-```
+- `cfg_rpc_call`: RPC command as configuration entry
-1-level decorator around a function
+**Returns**:
+A fully populated deep copy of cfg_rpc_call
-
+
-#### register
+#### decode\_rpc\_command
```python
-@overload
-def register(plugin: Type) -> Any
+def decode_rpc_command(cfg_rpc_cmd: Dict,
+ logger: logging.Logger = log) -> Optional[Dict]
```
-Signature: 1-level decorator around a class
+Decode an RPC Command from a config entry.
+
+This means
+* Decode RPC command alias (if present)
+* Ensure all RPC call parameters have valid default values
-
+If the command alias cannot be decoded correctly, the command is mapped to misc.empty_rpc_call
+which emits a misuse warning when called
+If an explicitly specified this is not done. However, it is ensured that the returned
+dictionary contains all mandatory parameters for an RPC call. RPC call functions have error handling
+for non-existing RPC commands and we get a clearer error message.
-#### register
+**Arguments**:
-```python
-@overload
-def register(*, name: str, package: Optional[str] = None) -> Callable
-```
+- `cfg_rpc_cmd`: RPC command as configuration entry
+- `logger`: The logger to use
-Signature: 2-level decorator around a function
+**Returns**:
+A decoded, fully populated deep copy of cfg_rpc_cmd
-
+
-#### register
+#### decode\_and\_call\_rpc\_command
```python
-@overload
-def register(*, auto_tag: bool = False, package: Optional[str] = None) -> Type
+def decode_and_call_rpc_command(rpc_cmd: Dict, logger: logging.Logger = log)
```
-Signature: 2-level decorator around a class
+Convenience function combining decode_rpc_command and plugs.call_ignore_errors
-
+
-#### register
+#### bind\_rpc\_command
```python
-@overload
-def register(plugin: Callable[..., Any] = None,
- *,
- name: Optional[str] = None,
- package: Optional[str] = None,
- replace: bool = False) -> Callable
+def bind_rpc_command(cfg_rpc_cmd: Dict,
+ dereference=False,
+ logger: logging.Logger = log)
```
-Signature: Run-time registration of function / class instance / bound method
+Decode an RPC command configuration entry and bind it to a function
+**Arguments**:
-
+- `dereference`: Dereference even the call to plugs.call(...)
+ ``. If false, the returned function is ``plugs.call(package, plugin, method, *args, **kwargs)`` with
+ all checks applied at bind time
+ ``. If true, the returned function is ``package.plugin.method(*args, **kwargs)`` with
+ all checks applied at bind time.
-#### register
+Setting deference to True, circumvents the dynamic nature of the plugins: the function to call
+ must exist at bind time and cannot change. If False, the function to call must only exist at call time.
+ This can be important during the initialization where package ordering and initialization means that not all
+ classes have been instantiated yet. With dereference=True also the plugs thread lock for serialization of calls
+ is circumvented. Use with care!
-```python
-def register(plugin: Optional[Callable] = None,
- *,
- name: Optional[str] = None,
- package: Optional[str] = None,
- replace: bool = False,
- auto_tag: bool = False) -> Callable
-```
+**Returns**:
-A generic decorator / run-time function to register plugin module callables
+Callable function w/o parameters which directly runs the RPC command
+using plugs.call_ignore_errors
-The functions comes in five distinct signatures for 5 use cases:
+
-1. ``@plugs.register``: decorator for a class w/o any arguments
-2. ``@plugs.register``: decorator for a function w/o any arguments
-3. ``@plugs.register(auto_tag=bool)``: decorator for a class with 1 arguments
-4. ``@plugs.register(name=name, package=package)``: decorator for a function with 1 or 2 arguments
-5. ``plugs.register(plugin, name=name, package=package)``: run-time registration of
- * function
- * bound method
- * class instance
+#### rpc\_call\_to\_str
-For more documentation see the functions
-* :func:`_register_obj`
-* :func:`_register_class`
+```python
+def rpc_call_to_str(cfg_rpc_call: Dict, with_args=True) -> str
+```
-See the examples in Module :mod:`plugs` how to use this decorator / function
+Return a readable string of an RPC call config
**Arguments**:
-- `plugin`:
-- `name`:
-- `package`:
-- `replace`:
-- `auto_tag`:
+- `cfg_rpc_call`: RPC call configuration entry
+- `with_args`: Return string shall include the arguments of the function
-
+
-#### tag
+#### get\_config\_action
```python
-def tag(func: Callable) -> Callable
+def get_config_action(cfg, section, option, default, valid_actions_dict,
+ logger)
```
-Method decorator for tagging a method as callable through the plugs interface
-
-Note that the instantiated class must still be registered as plugin object
-(either with the class decorator or dynamically)
-
-**Arguments**:
-
-- `func`: function to decorate
+Looks up the given {section}.{option} config option and returns
-**Returns**:
+the associated entry from valid_actions_dict, if valid. Falls back to the given
+default otherwise.
-the function
-
+
-#### initialize
+#### generate\_cmd\_alias\_rst
```python
-def initialize(func: Callable) -> Callable
+def generate_cmd_alias_rst(stream)
```
-Decorator for functions that shall be called by the plugs package directly after the module is loaded
-
-**Arguments**:
-
-- `func`: Function to decorate
-
-**Returns**:
+Write a reference of all rpc command aliases in Restructured Text format
-The function itself
-
+
-#### finalize
+#### generate\_cmd\_alias\_reference
```python
-def finalize(func: Callable) -> Callable
+def generate_cmd_alias_reference(stream)
```
-Decorator for functions that shall be called by the plugs package directly after ALL modules are loaded
-
-**Arguments**:
-
-- `func`: Function to decorate
-
-**Returns**:
+Write a reference of all rpc command aliases in text format
-The function itself
-
+
-#### atexit
+#### get\_git\_state
```python
-def atexit(func: Callable[[int], Any]) -> Callable[[int], Any]
+def get_git_state()
```
-Decorator for functions that shall be called by the plugs package directly after at exit of program.
+Return git state information for the current branch
-> [!IMPORTANT]
-> There is no automatism as in atexit.atexit. The function plugs.shutdown() must be explicitly called
-> during the shutdown procedure of your program. This is by design, so you can choose the exact situation in your
-> shutdown handler.
-The atexit-functions are called with a single integer argument, which is passed down from plugin.exit(int)
-It is intended for passing down the signal number that initiated the program termination
+
-**Arguments**:
+# jukebox.version
-- `func`: Function to decorate
+
-**Returns**:
+#### version
-The function itself
+```python
+def version()
+```
-
+Return the Jukebox version as a string
-#### load
+
+
+
+#### version\_info
```python
-def load(package: str,
- load_as: Optional[str] = None,
- prefix: Optional[str] = None)
+def version_info()
```
-Loads a python package as plugin package
+Return the Jukebox version as a tuple of three numbers
-Executes a regular python package load. That means a potentially existing `__init__.py` is executed.
-Decorator `@register` can by used to register functions / classes / class istances as plugin callable
-Decorator `@initializer` can be used to tag functions that shall be called after package loading
-Decorator `@finalizer` can be used to tag functions that shall be called after ALL plugin packges have been loaded
-Instead of using `@initializer`, you may of course use `__init__.py`
+If this is a development version, an identifier string will be appended after the third integer.
-Python packages may be loaded under a different plugs package name. Python packages must be unique and the name under
-which they are loaded as plugin package also.
-**Arguments**:
+
-- `package`: Python package to load as plugin package
-- `load_as`: Plugin package registration name. If None the name is the python's package simple name
-- `prefix`: Prefix to python package to create fully qualified name. This is used only to locate the python package
-and ignored otherwise. Useful if all the plugin module are in a dedicated folder
+# jukebox.playlistgenerator
-
+Playlists are build from directory content in the following way:
-#### load\_all\_named
+a directory is parsed and files are added to the playlist in the following way
-```python
-def load_all_named(packages_named: Mapping[str, str],
- prefix: Optional[str] = None,
- ignore_errors=False)
-```
+1. files are added in alphabetic order
+2. files ending with ``*livestream.txt`` are unpacked and the containing URL(s) are added verbatim to the playlist
+3. files ending with ``*podcast.txt`` are unpacked and the containing Podcast URL(s) are expanded and added to the playlist
+4. files ending with ``*.m3u`` are treated as folder playlist. Regular folder processing is suspended and the playlist
+ is build solely from the ``*.m3u`` content. Only the alphabetically first ``*.m3u`` is processed. URLs are added verbatim
+ to the playlist except for ``*.xml`` and ``*.podcast`` URLS, which are expanded first
-Load all packages in packages_named with mapped names
+An directory may contain a mixed set of files and multiple ``*.txt`` files, e.g.
-**Arguments**:
+ 01-livestream.txt
+ 02-livestream.txt
+ music.mp3
+ podcast.txt
-- `packages_named`: Dict[load_as, package]
+All files are treated as music files and are added to the playlist, except those:
-
+ * starting with ``.``,
+ * not having a file ending, i.e. do not contain a ``.``,
+ * ending with ``.txt``,
+ * ending with ``.m3u``,
+ * ending with one of the excluded file endings in :attr:`PlaylistCollector._exclude_endings`
-#### load\_all\_unnamed
+In recursive mode, the playlist is generated by concatenating all sub-folder playlists. Sub-folders are parsed
+in alphabetic order. Symbolic links are being followed. The above rules are enforced on a per-folder bases.
+This means, one ``*.m3u`` file per sub-folder is processed (if present).
-```python
-def load_all_unnamed(packages_unnamed: Iterable[str],
- prefix: Optional[str] = None,
- ignore_errors=False)
-```
+In ``*.txt`` and ``*.m3u`` files, all lines starting with ``#`` are ignored.
-Load all packages in packages_unnamed with default names
+
-
+#### TYPE\_DECODE
-#### load\_all\_finalize
+Types if file entires in parsed directory
+
+
+
+
+## PlaylistCollector Objects
```python
-def load_all_finalize(ignore_errors=False)
+class PlaylistCollector()
```
-Calls all functions registered with @finalize from all loaded modules in the order they were loaded
+Build a playlist from directory(s)
-This must be executed after the last plugin package is loaded
+This class is intended to be used with an absolute path to the music library::
+ plc = PlaylistCollector('/home/chris/music')
+ plc.parse('Traumfaenger')
+ print(f"res = {plc}")
-
+But it can also be used with relative paths from current working directory::
-#### close\_down
+ plc = PlaylistCollector('.')
+ plc.parse('../../../../music/Traumfaenger')
+ print(f"res = {plc}")
+
+The file ending exclusion list :attr:`PlaylistCollector._exclude_endings` is a class variable for performance reasons.
+If changed it will affect all instances. For modifications always call :func:`set_exclusion_endings`.
+
+
+
+
+#### \_\_init\_\_
```python
-def close_down(**kwargs) -> Any
+def __init__(music_library_base_path='/')
```
-Calls all functions registered with @atexit from all loaded modules in reverse order of module load order
-
-Modules are processed in reverse order. Several at-exit tagged functions of a single module are processed
-in the order of registration.
+Initialize the playlist generator with music_library_base_path
-Errors raised in functions are suppressed to ensure all plugins are processed
+**Arguments**:
+- `music_library_base_path`: Base path the the music library. This is used to locate the file in the disk
+but is omitted when generating the playlist entries. I.e. all files in the playlist are relative to this base dir
-
+
-#### call
+#### set\_exclusion\_endings
```python
-def call(package: str,
- plugin: str,
- method: Optional[str] = None,
- *,
- args=(),
- kwargs=None,
- as_thread: bool = False,
- thread_name: Optional[str] = None) -> Any
+@classmethod
+def set_exclusion_endings(cls, endings: List[str])
```
-Call a function/method from the loaded plugins
+Set the class-wide file ending exclusion list
-If a plugin is a function or a callable instance of a class, this is equivalent to
+See :attr:`PlaylistCollector._exclude_endings`
-``package.plugin(*args, **kwargs)``
-If plugin is a class instance from which a method is called, this is equivalent to the followig.
-Also remember, that method must have the attribute ``plugin_callable = True``
+
-``package.plugin.method(*args, **kwargs)``
+#### get\_directory\_content
-Calls are serialized by a thread lock. The thread lock is shared with call_ignore_errors.
+```python
+def get_directory_content(path='.')
+```
-> [!NOTE]
-> There is no logger in this function as they all belong up-level where the exceptions are handled.
-> If you want logger messages instead of exceptions, use :func:`call_ignore_errors`
+Parse the folder ``path`` and create a content list. Depth is always the current level
**Arguments**:
-- `package`: Name of the plugin package in which to look for function/class instance
-- `plugin`: Function name or instance name of a class
-- `method`: Method name when accessing a class instance' method. Leave at *None* if unneeded.
-- `as_thread`: Run the callable in separate daemon thread.
-There is no return value from the callable in this case! The return value is the thread object.
-Also note that Exceptions in the Thread must be handled in the Thread and are not propagated to the main Thread.
-All threads are started as daemon threads with terminate upon main program termination.
-There is not stop-thread mechanism. This is intended for short lived threads.
-- `thread_name`: Name of the thread
-- `args`: Arguments passed to callable
-- `kwargs`: Keyword arguments passed to callable
+- `path`: Path to folder **relative** to ``music_library_base_path``
**Returns**:
-The return value from the called function, or, if started as thread the thread object
+[ { type: 'directory', name: 'Simone', path: '/some/path/to/Simone' }, {...} ]
+where type is one of :attr:`TYPE_DECODE`
-
+
-#### call\_ignore\_errors
+#### parse
```python
-def call_ignore_errors(package: str,
- plugin: str,
- method: Optional[str] = None,
- *,
- args=(),
- kwargs=None,
- as_thread: bool = False,
- thread_name: Optional[str] = None) -> Any
+def parse(path='.', recursive=False)
```
-Call a function/method from the loaded plugins ignoring all raised Exceptions.
-
-Errors get logged.
-
-See :func:`call` for parameter documentation.
+Parse the folder ``path`` and create a playlist from its content
+**Arguments**:
-
+- `path`: Path to folder **relative** to ``music_library_base_path``
+- `recursive`: Parse folder recursivley, or stay in top-level folder
-#### exists
+
-```python
-def exists(package: str,
- plugin: Optional[str] = None,
- method: Optional[str] = None) -> bool
-```
+# jukebox.multitimer
-Check if an object is registered within the plugs package
+Multitimer Module
-
+
-#### get
+## MultiTimer Objects
```python
-def get(package: str,
- plugin: Optional[str] = None,
- method: Optional[str] = None) -> Any
+class MultiTimer(threading.Thread)
```
-Get a plugs-package registered object
+Call a function after a specified number of seconds, repeat that iteration times
-The return object depends on the number of parameters
+May be cancelled during any of the wait times.
+Function is called with keyword parameter 'iteration' (which decreases down to 0 for the last iteration)
-* 1 argument: Get the python module reference for the plugs *package*
-* 2 arguments: Get the plugin reference for the plugs *package.plugin*
-* 3 arguments: Get the plugin reference for the plugs *package.plugin.method*
+If iterations is negative, an endlessly repeating timer is created (which needs to be cancelled with cancel())
+Initiates start and publishing by calling self.publish_callback
-
+Note: Inspired by threading.Timer and generally using the same API
-#### loaded\_as
+
+
+
+#### cancel
```python
-def loaded_as(module_name: str) -> str
+def cancel()
```
-Return the plugin name a python module is loaded as
+Stop the timer if it hasn't finished all iterations yet.
-
+
-#### delete
+## GenericTimerClass Objects
```python
-def delete(package: str, plugin: Optional[str] = None, ignore_errors=False)
+class GenericTimerClass()
```
-Delete a plugin object from the registered plugs callables
-
-> [!NOTE]
-> This does not 'unload' the python module. It merely makes it un-callable via plugs!
+Interface for plugin / RPC accessibility for a single event timer
-
+
-#### dump\_plugins
+#### \_\_init\_\_
```python
-def dump_plugins(stream)
+def __init__(name, wait_seconds: float, function, args=None, kwargs=None)
```
-Write a human readable summary of all plugin callables to stream
+**Arguments**:
+- `wait_seconds`: The time in seconds to wait before calling function
+- `function`: The function to call with args and kwargs.
+- `args`: Parameters for function call
+- `kwargs`: Parameters for function call
-
+
-#### summarize
+#### start
```python
-def summarize()
+@plugin.tag
+def start(wait_seconds=None)
```
-Create a reference summary of all plugin callables in dictionary format
+Start the timer (with default or new parameters)
-
+
-#### generate\_help\_rst
+#### cancel
```python
-def generate_help_rst(stream)
+@plugin.tag
+def cancel()
```
-Write a reference of all plugin callables in Restructured Text format
+Cancel the timer
-
+
-#### get\_all\_loaded\_packages
+#### toggle
```python
-def get_all_loaded_packages() -> Dict[str, str]
+@plugin.tag
+def toggle()
```
-Report a short summary of all loaded packages
-
-**Returns**:
+Toggle the activation of the timer
-Dictionary of the form `{loaded_as: loaded_from, ...}`
-
+
-#### get\_all\_failed\_packages
+#### trigger
```python
-def get_all_failed_packages() -> Dict[str, str]
+@plugin.tag
+def trigger()
```
-Report those packages that did not load error free
+Trigger the next target execution before the time is up
-> [!NOTE]
-> Package could fail to load
-> * altogether: these package are not registered
-> * partially: during initializer, finalizer functions: The package is loaded,
-> but the function did not execute error-free
->
-> Partially loaded packages are listed in both _PLUGINS and _PLUGINS_FAILED
+
+
+
+#### is\_alive
+
+```python
+@plugin.tag
+def is_alive()
+```
+
+Check if timer is active
+
+
+
+
+#### get\_timeout
+
+```python
+@plugin.tag
+def get_timeout()
+```
+
+Get the configured time-out
**Returns**:
-Dictionary of the form `{loaded_as: loaded_from, ...}`
+The total wait time. (Not the remaining wait time!)
-
+
-# jukebox.speaking\_text
+#### set\_timeout
-Text to Speech. Plugin to speak any given text via speaker
+```python
+@plugin.tag
+def set_timeout(wait_seconds: float)
+```
+
+Set a new time-out in seconds. Re-starts the timer if already running!
-
+
-# jukebox.multitimer
+#### publish
-Multitimer Module
+```python
+@plugin.tag
+def publish()
+```
+Publish the current state and config
-
-## MultiTimer Objects
+
+
+#### get\_state
+
+```python
+@plugin.tag
+def get_state()
+```
+
+Get the current state and config as dictionary
+
+
+
+
+## GenericEndlessTimerClass Objects
+
+```python
+class GenericEndlessTimerClass(GenericTimerClass)
+```
+
+Interface for plugin / RPC accessibility for an event timer call function endlessly every m seconds
+
+
+
+
+## GenericMultiTimerClass Objects
+
+```python
+class GenericMultiTimerClass(GenericTimerClass)
+```
+
+Interface for plugin / RPC accessibility for an event timer that performs an action n times every m seconds
+
+
+
+
+#### \_\_init\_\_
+
+```python
+def __init__(name,
+ iterations: int,
+ wait_seconds_per_iteration: float,
+ callee,
+ args=None,
+ kwargs=None)
+```
+
+**Arguments**:
+
+- `iterations`: Number of times callee is called
+- `wait_seconds_per_iteration`: Wait in seconds before each iteration
+- `callee`: A builder class that gets instantiated once as callee(*args, iterations=iterations, **kwargs).
+Then with every time out iteration __call__(*args, iteration=iteration, **kwargs) is called.
+'iteration' is the current iteration count in decreasing order!
+- `args`:
+- `kwargs`:
+
+
+
+#### start
+
+```python
+@plugin.tag
+def start(iterations=None, wait_seconds_per_iteration=None)
+```
+
+Start the timer (with default or new parameters)
+
+
+
+
+# jukebox.NvManager
+
+
+
+# jukebox.publishing.subscriber
+
+
+
+# jukebox.publishing
+
+
+
+#### get\_publisher
+
+```python
+def get_publisher()
+```
+
+Return the publisher instance for this thread
+
+Per thread, only one publisher instance is required to connect to the inproc socket.
+A new instance is created if it does not already exist.
+
+If there is a remote-chance that your function publishing something may be called form
+different threads, always make a fresh call to ``get_publisher()`` to get the correct instance for the current thread.
+
+Example::
+
+ import jukebox.publishing as publishing
+
+ class MyClass:
+ def __init__(self):
+ pass
+
+ def say_hello(name):
+ publishing.get_publisher().send('hello', f'Hi {name}, howya?')
+
+To stress what **NOT** to do: don't get a publisher instance in the constructor and save it to ``self._pub``.
+If you do and ``say_hello`` gets called from different threads, the publisher of the thread which instantiated the class
+will be used.
+
+If you need your very own private Publisher Instance, you'll need to instantiate it yourself.
+But: the use cases are very rare for that. I cannot think of one at the moment.
+
+**Remember**: Don’t share ZeroMQ sockets between threads.
+
+
+
+
+# jukebox.publishing.server
+
+## Publishing Server
+
+The common publishing server for the entire Jukebox using ZeroMQ
+
+### Structure
+
+ +-----------------------+
+ | functional interface | Publisher
+ | | - functional interface for single Thread
+ | PUB | - sends data to publisher (and thus across threads)
+ +-----------------------+
+ | (1)
+ v
+ +-----------------------+
+ | SUB (bind) | PublishServer
+ | | - Last Value (LV) Cache
+ | XPUB (bind) | - Subscriber notification and LV resend
+ +-----------------------+ - independent thread
+ | (2)
+ v
+
+#### Connection (1): Internal connection
+
+Internal connection only - do not use (no, not even inside this App for you own plugins - always bind to the PublishServer)
+
+ Protocol: Multi-part message
+
+ Part 1: Topic (in topic tree format)
+ E.g. player.status.elapsed
+
+ Part 2: Payload or Message in json serialization
+ If empty (i.e. ``b''``), it means delete the topic sub-tree from cache. And instruct subscribers to do the same
+
+ Part 3: Command
+ Usually empty, i.e. ``b''``. If not empty the message is treated as command for the PublishServer
+ and the message is not forwarded to the outside. This third part of the message is never forwarded
+
+#### Connection (2): External connection
+
+Upon connection of a new subscriber, the entire current state is resend from cache to ALL subscribers!
+Subscribers must subscribe to topics. Topics are treated as topic trees! Subscribing to a root tree will
+also get you all the branch topics. To get everything, subscribe to ``b''``
+
+ Protocol: Multi-part message
+
+ Part 1: Topic (in topic tree format)
+ E.g. player.status.elapsed
+
+ Part 2: Payload or Message in json serialization
+ If empty (i.e. b''), it means the subscriber must delete this key locally (not valid anymore)
+
+### Why? Why?
+
+Check out the [ZeroMQ Documentation](https://zguide.zeromq.org/docs/chapter5)
+for why you need a proxy in a good design.
+
+For use case, we made a few simplifications
+
+### Design Rationales
+
+* "If you need [millions of messages per second](https://zguide.zeromq.org/docs/chapter5/`Pros`-and-Cons-of-Pub-Sub)
+ sent to thousands of points,
+ you'll appreciate pub-sub a lot more than if you need a few messages a second sent to a handful of recipients."
+* "lower-volume network with a few dozen subscribers and a limited number of topics, we can use TCP and then
+ the [XSUB and XPUB](https://zguide.zeromq.org/docs/chapter5/`Last`-Value-Caching)"
+* "Let's imagine [our feed has an average of 100,000 100-byte messages a
+ second](https://zguide.zeromq.org/docs/chapter5/`High`-Speed-Subscribers-Black-Box-Pattern) [...].
+ While 100K messages a second is easy for a ZeroMQ application, ..."
+
+**But we have:**
+
+* few dozen subscribers --> Check!
+* limited number of topics --> Check!
+* max ~10 messages per second --> Check!
+* small common state information --> Check!
+* only the server updates the state --> Check!
+
+This means, we can use less complex patters than used for these high-speed, high code count, high data rate networks :-)
+
+* XPUB / XSUB to detect new subscriber
+* Cache the entire state in the publisher
+* Re-send the entire state on-demand (and then even to every subscriber)
+* Using the same channel: sends state to every subscriber
+
+**Reliability considerations**
+
+* Late joining client (or drop-off and re-join): get full state update
+* Server crash etc: No special handling necessary, we are simple
+ and don't need recovery in this case. Server will publish initial state
+ after re-start
+* Subscriber too slow: Subscribers problem (TODO: Do we need to do anything about it?)
+
+**Start-up sequence:**
+
+* Publisher plugin is first plugin to be loaded
+* Due to Publisher - PublisherServer structure no further sequencing required
+
+### Plugin interactions and usage
+
+RPC can trigger through function call in components/publishing plugin that
+
+* entire state is re-published (from the cache)
+* a specific topic tree is re-published (from the cache)
+
+Plugins publishing state information should publish initial state at @plugin.finalize
+
+> [!IMPORTANT]
+> Do not direclty instantiate the Publisher in your plugin module. Only one Publisher is
+> required per thread. But the publisher instance **must** be thread-local!
+> Always go through :func:`publishing.get_publisher()`.
+
+**Sockets**
+
+Three sockets are opened:
+
+1. TCP (on a configurable port)
+2. Websocket (on a configurable port)
+3. Inproc: On ``inproc://PublisherToProxy`` all topics are published app-internally. This can be used for plugin modules
+ that want to know about the current state on event based updates.
+
+**Further ZeroMQ References:**
+
+* [Working with Messages](https://zguide.zeromq.org/docs/chapter2/`Working`-with-Messages)
+* [Multiple Threads](https://zguide.zeromq.org/docs/chapter2/`Multithreading`-with-ZeroMQ)
+
+
+
+
+## PublishServer Objects
+
+```python
+class PublishServer(threading.Thread)
+```
+
+The publish proxy server that collects and caches messages from all internal publishers and
+
+forwards them to the outside world
+
+Handles new subscriptions by sending out the entire cached state to **all** subscribers
+
+The code is structures using a [Reactor Pattern](https://zguide.zeromq.org/docs/chapter5/`Using`-a-Reactor)
+
+
+
+
+#### run
```python
-class MultiTimer(threading.Thread)
+def run()
```
-Call a function after a specified number of seconds, repeat that iteration times
+Thread's activity
-May be cancelled during any of the wait times.
-Function is called with keyword parameter 'iteration' (which decreases down to 0 for the last iteration)
-If iterations is negative, an endlessly repeating timer is created (which needs to be cancelled with cancel())
+
-Initiates start and publishing by calling self.publish_callback
+#### handle\_message
-Note: Inspired by threading.Timer and generally using the same API
+```python
+def handle_message(msg)
+```
+Handle incoming messages
-
-#### cancel
+
+
+#### handle\_subscription
```python
-def cancel()
+def handle_subscription(msg)
```
-Stop the timer if it hasn't finished all iterations yet.
+Handle new subscribers
-
+
-## GenericTimerClass Objects
+## Publisher Objects
```python
-class GenericTimerClass()
+class Publisher()
```
-Interface for plugin / RPC accessibility for a single event timer
+The publisher that provides the functional interface to the application
+
+> [!NOTE]
+> * An instance must not be shared across threads!
+> * One instance per thread is enough
-
+
#### \_\_init\_\_
```python
-def __init__(name, wait_seconds: float, function, args=None, kwargs=None)
+def __init__(check_thread_owner=True)
```
**Arguments**:
-- `wait_seconds`: The time in seconds to wait before calling function
-- `function`: The function to call with args and kwargs.
-- `args`: Parameters for function call
-- `kwargs`: Parameters for function call
+- `check_thread_owner`: Check if send() is always called from the correct thread. This is debug feature
+and is intended to expose the situation before it leads to real trouble. Leave it on!
-
+
-#### start
+#### send
```python
-@plugin.tag
-def start(wait_seconds=None)
+def send(topic: str, payload)
```
-Start the timer (with default or new parameters)
+Send out a message for topic
-
+
-#### cancel
+#### revoke
```python
-@plugin.tag
-def cancel()
+def revoke(topic: str)
```
-Cancel the timer
+Revoke a single topic element (not a topic tree!)
-
+
-#### toggle
+#### resend
```python
-@plugin.tag
-def toggle()
+def resend(topic: Optional[str] = None)
```
-Toggle the activation of the timer
+Instructs the PublishServer to resend current status to all subscribers
+Not necessary to call after incremental updates or new subscriptions - that will happen automatically!
-
-#### trigger
+
+
+#### close\_server
```python
-@plugin.tag
-def trigger()
+def close_server()
```
-Trigger the next target execution before the time is up
+Instructs the PublishServer to close itself down
-
+
-#### is\_alive
+# jukebox.daemon
+
+
+
+#### log\_active\_threads
```python
-@plugin.tag
-def is_alive()
+@atexit.register
+def log_active_threads()
```
-Check if timer is active
+This functions is registered with atexit very early, meaning it will be run very late. It is the best guess to
+evaluate which Threads are still running (and probably shouldn't be)
-
+This function is registered before all the plugins and their dependencies are loaded
-#### get\_timeout
+
+
+
+## JukeBox Objects
```python
-@plugin.tag
-def get_timeout()
+class JukeBox()
```
-Get the configured time-out
+
-**Returns**:
+#### signal\_handler
-The total wait time. (Not the remaining wait time!)
+```python
+def signal_handler(esignal, frame)
+```
-
+Signal handler for orderly shutdown
-#### set\_timeout
+On first Ctrl-C (or SIGTERM) orderly shutdown procedure is embarked upon. It gets allocated a time-out!
+On third Ctrl-C (or SIGTERM), this is interrupted and there will be a hard exit!
-```python
-@plugin.tag
-def set_timeout(wait_seconds: float)
-```
-Set a new time-out in seconds. Re-starts the timer if already running!
+
+# jukebox.rpc.client
-
+
-#### publish
+# jukebox.rpc
-```python
-@plugin.tag
-def publish()
-```
+
-Publish the current state and config
+# jukebox.rpc.server
+## Remote Procedure Call Server (RPC)
-
+Bind to tcp and/or websocket port and translates incoming requests to procedure calls.
+Avaiable procedures to call are all functions registered with the plugin package.
-#### get\_state
+The protocol is loosely based on [jsonrpc](https://www.jsonrpc.org/specification)
-```python
-@plugin.tag
-def get_state()
-```
+But with different elements directly relating to the plugin concept and Python function argument options
-Get the current state and config as dictionary
+ {
+ 'package' : str # The plugin package loaded from python module
+ 'plugin' : str # The plugin object to be accessed from the package
+ # (i.e. function or class instance)
+ 'method' : str # (optional) The method of the class instance
+ 'args' : [ ] # (optional) Positional arguments as list
+ 'kwargs' : { } # (optional) Keyword arguments as dictionary
+ 'as_thread': bool # (optional) start call in separate thread
+ 'id' : Any # (optional) Round-trip id for response (may not be None)
+ 'tsp' : Any # (optional) measure and return total processing time for
+ # the call request (may not be None)
+ }
+**Response**
-
+A response will ALWAYS be send, independent of presence of 'id'. This is in difference to the
+jsonrpc specification. But this is a ZeroMQB REQ/REP pattern requirement!
-## GenericEndlessTimerClass Objects
+If 'id' is omitted, the response will be 'None'! Unless an error occurred, then the error is returned.
+The absence of 'id' indicates that the requester is not interested in the response.
+If present, 'id' and 'tsp' may not be None. If they are None, there are treated as if non-existing.
-```python
-class GenericEndlessTimerClass(GenericTimerClass)
-```
+**Sockets**
-Interface for plugin / RPC accessibility for an event timer call function endlessly every m seconds
+Three sockets are opened
+
+1. TCP (on a configurable port)
+2. Websocket (on a configurable port)
+3. Inproc: On ``inproc://JukeBoxRpcServer`` connection from the internal app are accepted. This is indented be
+ call arbitrary RPC functions from plugins that provide an interface to the outside world (e.g. GPIO). By also going though
+ the RPC instead of calling function directly we increase thread-safety and provide easy configurability (e.g. which
+ button triggers what action)
-
+
-## GenericMultiTimerClass Objects
+## RpcServer Objects
```python
-class GenericMultiTimerClass(GenericTimerClass)
+class RpcServer()
```
-Interface for plugin / RPC accessibility for an event timer that performs an action n times every m seconds
+The RPC Server Class
-
+
#### \_\_init\_\_
```python
-def __init__(name,
- iterations: int,
- wait_seconds_per_iteration: float,
- callee,
- args=None,
- kwargs=None)
+def __init__(context=None)
```
-**Arguments**:
+Initialize the connections and bind to the ports
-- `iterations`: Number of times callee is called
-- `wait_seconds_per_iteration`: Wait in seconds before each iteration
-- `callee`: A builder class that gets instantiated once as callee(*args, iterations=iterations, **kwargs).
-Then with every time out iteration __call__(*args, iteration=iteration, **kwargs) is called.
-'iteration' is the current iteration count in decreasing order!
-- `args`:
-- `kwargs`:
-
+
-#### start
+#### run
```python
-@plugin.tag
-def start(iterations=None, wait_seconds_per_iteration=None)
+def run()
```
-Start the timer (with default or new parameters)
-
+The main endless loop waiting for requests and forwarding the
-
+call request to the plugin module
-# jukebox.utils
-Common utility functions
+
+# misc
-
+
-#### decode\_rpc\_call
+#### recursive\_chmod
```python
-def decode_rpc_call(cfg_rpc_call: Dict) -> Optional[Dict]
+def recursive_chmod(path, mode_files, mode_dirs)
```
-Makes sure that the core rpc call parameters have valid default values in cfg_rpc_call.
+Recursively change folder and file permissions
-> [!IMPORTANT]
-> Leaves all other parameters in cfg_action untouched or later downstream processing!
+mode_files/mode dirs can be given in octal notation e.g. 0o777
+flags from the stats module.
-**Arguments**:
+Reference: https://docs.python.org/3/library/os.html#os.chmod
-- `cfg_rpc_call`: RPC command as configuration entry
-**Returns**:
+
+
+#### flatten
+
+```python
+def flatten(iterable)
+```
+
+Flatten all levels of hierarchy in nested iterables
-A fully populated deep copy of cfg_rpc_call
-
+
-#### decode\_rpc\_command
+#### getattr\_hierarchical
```python
-def decode_rpc_command(cfg_rpc_cmd: Dict,
- logger: logging.Logger = log) -> Optional[Dict]
+def getattr_hierarchical(obj: Any, name: str) -> Any
```
-Decode an RPC Command from a config entry.
-
-This means
+Like the builtin getattr, but descends though the hierarchy levels
-* Decode RPC command alias (if present)
-* Ensure all RPC call parameters have valid default values
-If the command alias cannot be decoded correctly, the command is mapped to misc.empty_rpc_call
-which emits a misuse warning when called
-If an explicitly specified this is not done. However, it is ensured that the returned
-dictionary contains all mandatory parameters for an RPC call. RPC call functions have error handling
-for non-existing RPC commands and we get a clearer error message.
+
-**Arguments**:
+# misc.simplecolors
-- `cfg_rpc_cmd`: RPC command as configuration entry
-- `logger`: The logger to use
+Zero 3rd-party dependency module to add colors to unix terminal output
-**Returns**:
+Yes, there are modules out there to do the same and they have more features.
+However, this is low-complexity and has zero dependencies
-A decoded, fully populated deep copy of cfg_rpc_cmd
-
+
-#### decode\_and\_call\_rpc\_command
+## Colors Objects
```python
-def decode_and_call_rpc_command(rpc_cmd: Dict, logger: logging.Logger = log)
+class Colors()
```
-Convenience function combining decode_rpc_command and plugs.call_ignore_errors
+Container class for all the colors as constants
-
+
-#### bind\_rpc\_command
+#### resolve
```python
-def bind_rpc_command(cfg_rpc_cmd: Dict,
- dereference=False,
- logger: logging.Logger = log)
+def resolve(color_name: str)
```
-Decode an RPC command configuration entry and bind it to a function
+Resolve a color name into the respective color constant
**Arguments**:
-- `dereference`: Dereference even the call to plugs.call(...)
- ``. If false, the returned function is ``plugs.call(package, plugin, method, *args, **kwargs)`` with
- all checks applied at bind time
- ``. If true, the returned function is ``package.plugin.method(*args, **kwargs)`` with
- all checks applied at bind time.
-
-Setting deference to True, circumvents the dynamic nature of the plugins: the function to call
- must exist at bind time and cannot change. If False, the function to call must only exist at call time.
- This can be important during the initialization where package ordering and initialization means that not all
- classes have been instantiated yet. With dereference=True also the plugs thread lock for serialization of calls
- is circumvented. Use with care!
+- `color_name`: Name of the color
**Returns**:
-Callable function w/o parameters which directly runs the RPC command
-using plugs.call_ignore_errors
+color constant
-
+
-#### rpc\_call\_to\_str
+#### print
```python
-def rpc_call_to_str(cfg_rpc_call: Dict, with_args=True) -> str
+def print(color: Colors,
+ *values,
+ sep=' ',
+ end='\n',
+ file=sys.stdout,
+ flush=False)
```
-Return a readable string of an RPC call config
+Drop-in replacement for print with color choice and auto color reset for convenience
-**Arguments**:
+Use just as a regular print function, but with first parameter as color
-- `cfg_rpc_call`: RPC call configuration entry
-- `with_args`: Return string shall include the arguments of the function
-
+
-#### generate\_cmd\_alias\_rst
+# misc.inputminus
-```python
-def generate_cmd_alias_rst(stream)
-```
+Zero 3rd-party dependency module for user prompting
-Write a reference of all rpc command aliases in Restructured Text format
+Yes, there are modules out there to do the same and they have more features.
+However, this is low-complexity and has zero dependencies
-
+
-#### generate\_cmd\_alias\_reference
+#### input\_int
```python
-def generate_cmd_alias_reference(stream)
+def input_int(prompt,
+ blank=None,
+ min=None,
+ max=None,
+ prompt_color=None,
+ prompt_hint=False) -> int
```
-Write a reference of all rpc command aliases in text format
+Request an integer input from user
+**Arguments**:
-
+- `prompt`: The prompt to display
+- `blank`: Value to return when user just hits enter. Leave at None, if blank is invalid
+- `min`: Minimum valid integer value (None disables this check)
+- `max`: Maximum valid integer value (None disables this check)
+- `prompt_color`: Color of the prompt. Color will be reset at end of prompt
+- `prompt_hint`: Append a 'hint' with [min...max, default=xx] to end of prompt
-#### get\_git\_state
+**Returns**:
+
+integer value read from user input
+
+
+
+#### input\_yesno
```python
-def get_git_state()
+def input_yesno(prompt,
+ blank=None,
+ prompt_color=None,
+ prompt_hint=False) -> bool
```
-Return git state information for the current branch
+Request a yes / no choice from user
+Accepts multiple input for true/false and is case insensitive
-
+**Arguments**:
-# jukebox.rpc
+- `prompt`: The prompt to display
+- `blank`: Value to return when user just hits enter. Leave at None, if blank is invalid
+- `prompt_color`: Color of the prompt. Color will be reset at end of prompt
+- `prompt_hint`: Append a 'hint' with [y/n] to end of prompt. Default choice will be capitalized
-
+**Returns**:
-# jukebox.rpc.client
+boolean value read from user input
-
+
-# jukebox.rpc.server
+# misc.loggingext
-## Remote Procedure Call Server (RPC)
+## Logger
-Bind to tcp and/or websocket port and translates incoming requests to procedure calls.
-Avaiable procedures to call are all functions registered with the plugin package.
+We use a hierarchical Logger structure based on pythons logging module. It can be finely configured with a yaml file.
-The protocol is loosely based on [jsonrpc](https://www.jsonrpc.org/specification)
+The top-level logger is called 'jb' (to make it short). In any module you may simple create a child-logger at any hierarchy
+level below 'jb'. It will inherit settings from it's parent logger unless otherwise configured in the yaml file.
+Hierarchy separator is the '.'. If the logger already exits, getLogger will return a reference to the same, else it will be
+created on the spot.
-But with different elements directly relating to the plugin concept and Python function argument options
+Example: How to get logger and log away at your heart's content:
- {
- 'package' : str # The plugin package loaded from python module
- 'plugin' : str # The plugin object to be accessed from the package
- # (i.e. function or class instance)
- 'method' : str # (optional) The method of the class instance
- 'args' : [ ] # (optional) Positional arguments as list
- 'kwargs' : { } # (optional) Keyword arguments as dictionary
- 'as_thread': bool # (optional) start call in separate thread
- 'id' : Any # (optional) Round-trip id for response (may not be None)
- 'tsp' : Any # (optional) measure and return total processing time for
- # the call request (may not be None)
- }
+ >>> import logging
+ >>> logger = logging.getLogger('jb.awesome_module')
+ >>> logger.info('Started general awesomeness aura')
-**Response**
+Example: YAML snippet, setting WARNING as default level everywhere and DEBUG for jb.awesome_module:
-A response will ALWAYS be send, independent of presence of 'id'. This is in difference to the
-jsonrpc specification. But this is a ZeroMQB REQ/REP pattern requirement!
+ loggers:
+ jb:
+ level: WARNING
+ handlers: [console, debug_file_handler, error_file_handler]
+ propagate: no
+ jb.awesome_module:
+ level: DEBUG
-If 'id' is omitted, the response will be 'None'! Unless an error occurred, then the error is returned.
-The absence of 'id' indicates that the requester is not interested in the response.
-If present, 'id' and 'tsp' may not be None. If they are None, there are treated as if non-existing.
-**Sockets**
+> [!NOTE]
+> The name (and hierarchy path) of the logger can be arbitrary and must not necessarily match the module name (still makes
+> sense).
+> There can be multiple loggers per module, e.g. for special classes, to further control the amount of log output
-Three sockets are opened
-1. TCP (on a configurable port)
-2. Websocket (on a configurable port)
-3. Inproc: On ``inproc://JukeBoxRpcServer`` connection from the internal app are accepted. This is indented be
- call arbitrary RPC functions from plugins that provide an interface to the outside world (e.g. GPIO). By also going though
- the RPC instead of calling function directly we increase thread-safety and provide easy configurability (e.g. which
- button triggers what action)
+
+## ColorFilter Objects
-
+```python
+class ColorFilter(logging.Filter)
+```
-## RpcServer Objects
+This filter adds colors to the logger
+
+It adds all colors from simplecolors by using the color name as new keyword,
+i.e. use %(colorname)c or {colorname} in the formatter string
+
+It also adds the keyword {levelnameColored} which is an auto-colored drop-in replacement
+for the levelname depending on severity.
+
+Don't forget to {reset} the color settings at the end of the string.
+
+
+
+
+#### \_\_init\_\_
```python
-class RpcServer()
+def __init__(enable=True, color_levelname=True)
```
-The RPC Server Class
+**Arguments**:
+- `enable`: Enable the coloring
+- `color_levelname`: Enable auto-coloring when using the levelname keyword
-
+
-#### \_\_init\_\_
+## PubStream Objects
```python
-def __init__(context=None)
+class PubStream()
```
-Initialize the connections and bind to the ports
+Stream handler wrapper around the publisher for logging.StreamHandler
+Allows logging to send all log information (based on logging configuration)
+to the Publisher.
-
+> [!CAUTION]
+> This can lead to recursions!
+> Recursions come up when
+> * Publish.send / PublishServer.send also emits logs, which cause a another send, which emits a log,
+> which causes a send, .....
+> * Publisher initialization emits logs, which need a Publisher instance to send logs
-#### run
+> [!IMPORTANT]
+> To avoid endless recursions: The creation of a Publisher MUST NOT generate any log messages! Nor any of the
+> functions in the send-function stack!
+
+
+
+
+## PubStreamHandler Objects
```python
-def run()
+class PubStreamHandler(logging.StreamHandler)
```
-The main endless loop waiting for requests and forwarding the
+Wrapper for logging.StreamHandler with stream = PubStream
-call request to the plugin module
+This serves one purpose: In logger.yaml custom handlers
+can be configured (which are automatically instantiated).
+Using this Handler, we can output to PubStream whithout
+support code to instantiate PubStream keeping this file generic
diff --git a/requirements.txt b/requirements.txt
index 8ddfc881a..93799f251 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -37,3 +37,6 @@ mock
# API docs generation
pydoc-markdown
+
+# MQTT
+paho-mqtt
diff --git a/src/jukebox/components/mqtt/__init__.py b/src/jukebox/components/mqtt/__init__.py
new file mode 100644
index 000000000..ec44821a3
--- /dev/null
+++ b/src/jukebox/components/mqtt/__init__.py
@@ -0,0 +1,242 @@
+import json
+import logging
+import threading
+from typing import Any
+
+import paho.mqtt.client as paho_mqtt
+
+import jukebox.cfghandler
+import jukebox.plugs as plugs
+import jukebox.publishing
+import jukebox.publishing.server
+import jukebox.publishing.subscriber
+
+from .mqtt_command_alias import legacy_mqtt_cmd, mqtt_cmd
+from .mqtt_const import Mqtt_Attributes, topics_to_send
+from .utils import (
+ get_args,
+ get_current_time_milli,
+ get_kwargs,
+ get_rpc_command,
+ map_repeat_mode,
+ split_topic,
+)
+
+logger = logging.getLogger("jb.mqtt")
+cfg = jukebox.cfghandler.get_handler("jukebox")
+
+base_topic = cfg.setndefault("mqtt", "base_topic", value="phoniebox-dev")
+mqtt_enabled = cfg.setndefault('mqtt', 'enable', value=False) is True
+legacy_support_enabled = cfg.setndefault("mqtt", "enable_legacy", value=True)
+
+
+class MQTT(threading.Thread):
+ """A thread for monitoring events and publishing interesting events via MQTT."""
+
+ _topic_name: str
+ _mqtt_client: paho_mqtt.Client
+ _attributes: dict = {}
+ _available_cmds = mqtt_cmd
+
+ def __init__(self, client: paho_mqtt.Client):
+ super().__init__(name="MqttClient")
+ if mqtt_enabled:
+ self._mqtt_client = client
+ if legacy_support_enabled:
+ logger.info("Supporting legacy MQTT commands.")
+ self._available_cmds = {**mqtt_cmd, **legacy_mqtt_cmd}
+
+ self.daemon = True
+ self._keep_running = True
+ self.listen_done = threading.Event()
+ self.action_done = threading.Event()
+ else:
+ logger.info("MQTT Client is disabled")
+
+ def _subscribe(self):
+ logger.debug("Subscribing to MQTT topics.")
+ self._mqtt_client.message_callback_add("phoniebox-dev/cmd/#", self._on_cmd)
+
+ def _on_cmd(self, client, userdata, msg):
+ cmd = split_topic(topic=msg.topic)
+ payload = msg.payload.decode("utf-8")
+ logger.debug(f'Received MQTT command "{cmd}" with payload "{payload}"')
+ try:
+ config = self._available_cmds.get(cmd)
+ if not config:
+ logger.warning(f'No configuration found for MQTT command "{cmd}"')
+ return
+
+ rpc = get_rpc_command(config)
+ args = get_args(config, payload)
+ kwargs = get_kwargs(config, payload)
+
+ if rpc is None:
+ logger.warning(f'No RPC call configured for MQTT command "{cmd}"')
+ return
+
+ package = rpc.get("package")
+ plugin = rpc.get("plugin")
+ method = rpc.get("method")
+
+ if package is None:
+ raise ValueError(
+ f'Missing "package" attribute for MQTT command "{cmd}"'
+ )
+ elif plugin is None:
+ raise ValueError(f'Missing "plugin" attribute for MQTT command "{cmd}"')
+ elif method is None:
+ raise ValueError(f'Missing "method" attribute for MQTT command "{cmd}"')
+ else:
+ logger.info(
+ f'Executing MQTT command "{cmd}" with package="{package}",'
+ + f'plugin="{plugin}", method="{method}", args={args}, kwargs={kwargs}'
+ )
+ plugs.call_ignore_errors(
+ package=package,
+ plugin=plugin,
+ method=method,
+ args=args,
+ kwargs=kwargs,
+ )
+ except Exception as e:
+ logger.error(
+ f"Ignoring failed call for MQTT command '{cmd}': {e}", exc_info=True
+ )
+
+ def _publish(self, topic: str, payload: Any, *, qos=0, retain=False):
+ """Publish a message via MQTT."""
+ logger.debug(
+ f'Publishing to topic "{topic}" with payload "{payload}", qos={qos}, retain={retain}'
+ )
+ self._mqtt_client.publish(
+ topic=f"{base_topic}/{topic}",
+ payload=json.dumps(payload),
+ qos=qos,
+ retain=retain,
+ )
+
+ def _send_throttled(
+ self, topic: str, payload: Any, *, min_time_skip=500, qos=0, retain=False
+ ):
+ """Send an MQTT message throttled unless value has changed."""
+ now = get_current_time_milli()
+
+ if topic in self._attributes:
+ prev = self._attributes[topic]
+ time_since_last_update = now - prev["last_update"]
+ if prev["value"] == payload and time_since_last_update < 30000:
+ return
+ if prev["value"] != payload and time_since_last_update < min_time_skip:
+ return
+
+ logger.debug(
+ f'Sending throttled message for topic "{topic}" with payload "{payload}"'
+ )
+ self._attributes[topic] = {"value": payload, "last_update": now}
+ self._publish(topic, payload, retain=retain, qos=qos)
+
+ def _send_player_state(self, payload: Any):
+ """Map player state data."""
+ self._send_throttled(Mqtt_Attributes.STATE.value, payload["state"])
+ for attr in ["title", "artist", "elapsed", "duration", "track", "file"]:
+ if attr in payload:
+ self._send_throttled(Mqtt_Attributes[attr.upper()].value, payload[attr])
+
+ self._send_throttled(Mqtt_Attributes.RANDOM.value, payload.get("random") == "1")
+
+ repeat_active = bool(payload.get("repeat") == "1")
+ self._send_throttled(Mqtt_Attributes.REPEAT.value, repeat_active)
+ self._send_throttled(
+ Mqtt_Attributes.REPEAT_MODE.value,
+ map_repeat_mode(repeat_active, payload.get("single") == "1"),
+ )
+
+ def _send_volume(self, payload: Any):
+ """Map volume data."""
+ logger.debug(f"Sending volume update with payload: {payload}")
+ if legacy_support_enabled:
+ self._send_throttled(Mqtt_Attributes.VOLUME.value, payload.get("volume"))
+ self._send_throttled(Mqtt_Attributes.MUTE.value, bool(payload.get("mute")))
+ self._send_throttled("status/player/volume", payload.get("volume"))
+ self._send_throttled("status/player/mute", bool(payload.get("mute")))
+
+ def run(self) -> None:
+ """Main loop of the MQTT thread."""
+ logger.info("Starting MQTT Thread")
+ self._send_throttled("state", "online", qos=1, retain=True)
+ self._send_throttled("version", jukebox.version(), qos=1, retain=True) # type: ignore
+ self._subscribe()
+
+ sub = jukebox.publishing.subscriber.Subscriber(
+ "inproc://PublisherToProxy", topics_to_send
+ )
+ while self._keep_running:
+ topic, payload = sub.receive()
+ if topic == "volume.level":
+ self._send_volume(payload)
+ elif topic == "playerstatus":
+ self._send_player_state(payload)
+ logger.info("Exiting MQTT Thread")
+
+ def stop(self):
+ """Stop the MQTT thread."""
+ logger.info("Stopping MQTT Thread")
+ self._send_throttled("state", "offline", qos=1, retain=True)
+
+ self._keep_running = False
+ self.listen_done.clear()
+ self.action_done.set()
+
+
+mqtt: MQTT
+mqtt_client: paho_mqtt.Client
+
+
+def on_connect(client, userdata, flags, rc):
+ """Start thread on successful MQTT connection."""
+ global mqtt
+ logger.debug(f"Connected with result code {rc} to {base_topic}")
+ mqtt = MQTT(client)
+ mqtt.start()
+
+
+@plugs.initialize
+def initialize():
+ """Setup connection and trigger the MQTT loop."""
+ global mqtt_client
+
+ if mqtt_enabled:
+ client_id = cfg.setndefault("mqtt", "client_id", value="phoniebox-future3")
+ username = cfg.setndefault("mqtt", "username", value="phoniebox-dev")
+ password = cfg.setndefault("mqtt", "password", value="phoniebox-dev")
+ host = cfg.setndefault("mqtt", "host", value="127.0.0.1")
+ port = cfg.setndefault("mqtt", "port", value=1883)
+
+ logger.info(
+ f"Initializing MQTT client with client_id={client_id}, username={username}, host={host}, port={port}"
+ )
+ mqtt_client = paho_mqtt.Client(client_id=client_id)
+ mqtt_client.username_pw_set(username=username, password=password)
+ mqtt_client.on_connect = on_connect
+ mqtt_client.will_set(
+ topic=f"{base_topic}/state", payload=json.dumps("offline"), qos=1, retain=True
+ )
+ mqtt_client.connect(host, port, 60)
+ mqtt_client.loop_start()
+ logger.info("MQTT client initialized and loop started")
+ else:
+ logger.info("MQTT client is disabled")
+
+
+@plugs.atexit
+def atexit(signal_id: int, **ignored_kwargs):
+ global mqtt, mqtt_client
+ if mqtt_enabled:
+ logger.info("Executing atexit handler, stopping MQTT client")
+ mqtt.stop()
+ mqtt_client.loop_stop()
+ mqtt_client.disconnect()
+ logger.info("MQTT client stopped and disconnected")
+ else:
+ logger.info("MQTT client is disabled")
diff --git a/src/jukebox/components/mqtt/mqtt_command_alias.py b/src/jukebox/components/mqtt/mqtt_command_alias.py
new file mode 100644
index 000000000..0f4c02998
--- /dev/null
+++ b/src/jukebox/components/mqtt/mqtt_command_alias.py
@@ -0,0 +1,197 @@
+"""
+This file provides definitions for MQTT to RPC command aliases
+
+See [RPC Commands](../../builders/rpc-commands.md)
+"""
+
+import json
+
+import jukebox.plugs as plugs
+
+from .mqtt_const import Mqtt_Commands
+from .utils import parse_repeat_mode, play_folder_recursive_args
+
+
+def get_mute(payload) -> bool:
+ """Helper to toggle mute in legacy support."""
+ is_mute = plugs.call_ignore_errors(
+ package="volume", plugin="ctrl", method="get_mute"
+ )
+ return not is_mute
+
+
+legacy_mqtt_cmd = {
+ "volumeup": {"rpc": "change_volume", "args": 1},
+ "volumedown": {"rpc": "change_volume", "args": -1},
+ "mute": {
+ "rpc": {
+ "package": "volume",
+ "plugin": "ctrl",
+ "method": "mute",
+ },
+ "args": get_mute,
+ },
+ "playerplay": {"rpc": "play"},
+ "playerpause": {"rpc": "pause"},
+ "playernext": {"rpc": "next_song"},
+ "playerprev": {"rpc": "prev_song"},
+ "playerstop": {
+ "rpc": {
+ "package": "player",
+ "plugin": "ctrl",
+ "method": "stop",
+ }
+ },
+ "playerrewind": {
+ "rpc": {
+ "package": "player",
+ "plugin": "ctrl",
+ "method": "rewind",
+ }
+ },
+ "playershuffle": {"rpc": "shuffle"},
+ "playerreplay": {
+ "rpc": {
+ "package": "player",
+ "plugin": "ctrl",
+ "method": "replay",
+ }
+ },
+ "setvolume": {
+ "rpc": "set_volume",
+ "args": int,
+ },
+ "setmaxvolume": {
+ "rpc": "set_soft_max_volume",
+ "args": int,
+ },
+ "shutdownafter": {
+ "rpc": "timer_shutdown",
+ "args": int,
+ },
+ "playerstopafter": {
+ "rpc": "timer_stop_player",
+ "args": int,
+ },
+ "playerrepeat": {
+ "rpc": "repeat",
+ "args": parse_repeat_mode,
+ },
+ "playfolder": {
+ "rpc": "play_folder",
+ "args": str,
+ },
+ "playfolderrecursive": {
+ "rpc": "play_folder",
+ "kwargs": play_folder_recursive_args, # kwargs: folder, recursive
+ },
+ # "scan": {},
+ # "shutdownsilent": {},
+ # "disablewifi": {},
+ # "setidletime": {},
+ # "playerseek": {},
+ # "setvolstep": {},
+ # "rfid": {},
+ # "gpio": {},
+ # "swipecard": {},
+}
+
+
+_player_cmds = {
+ Mqtt_Commands.PLAY.value: {"rpc": "play"},
+ Mqtt_Commands.PLAY_FOLDER.value: {
+ "rpc": "play_folder",
+ "kwargs": json.loads, # kwargs: folder, recursive
+ },
+ Mqtt_Commands.PLAY_ALBUM.value: {
+ "rpc": "play_album",
+ "kwargs": json.loads, # kwargs: albumartist, album
+ },
+ Mqtt_Commands.PLAY_CARD.value: {
+ "rpc": "play_card",
+ "kwargs": json.loads, # kwargs: folder, recursive
+ },
+ Mqtt_Commands.PLAY_SINGLE.value: {
+ "rpc": "play_single",
+ "kwargs": json.loads, # kwargs: song_url
+ },
+ Mqtt_Commands.PAUSE.value: {"rpc": "pause"},
+ Mqtt_Commands.NEXT_SONG.value: {"rpc": "next_song"},
+ Mqtt_Commands.PREV_SONG.value: {"rpc": "prev_song"},
+ Mqtt_Commands.STOP.value: {
+ "rpc": {
+ "package": "player",
+ "plugin": "ctrl",
+ "method": "stop",
+ }
+ },
+ Mqtt_Commands.REWIND.value: {
+ "rpc": {
+ "package": "player",
+ "plugin": "ctrl",
+ "method": "rewind",
+ }
+ },
+ Mqtt_Commands.SHUFFLE.value: {"rpc": "shuffle"},
+ Mqtt_Commands.REPLAY.value: {
+ "rpc": {
+ "package": "player",
+ "plugin": "ctrl",
+ "method": "replay",
+ }
+ },
+ Mqtt_Commands.REPEAT.value: {
+ "rpc": "repeat",
+ "kwargs": json.loads, # kwargs: option
+ },
+}
+
+_volume_cmds = {
+ Mqtt_Commands.CHANGE_VOLUME.value: {
+ "rpc": "change_volume",
+ "kwargs": json.loads, # kwargs: step
+ },
+ Mqtt_Commands.SET_VOLUME.value: {
+ "rpc": "set_volume",
+ "kwargs": json.loads, # kwargs: volume
+ },
+ Mqtt_Commands.VOLUME_MUTE.value: {
+ "rpc": {
+ "package": "volume",
+ "plugin": "ctrl",
+ "method": "mute",
+ },
+ "kwargs": json.loads, # kwargs: mute
+ },
+ Mqtt_Commands.SET_SOFT_MAX_VOLUME.value: {
+ "rpc": "set_soft_max_volume",
+ "kwargs": json.loads, # kwargs: max_volume
+ },
+}
+
+_system_cmd = {
+ Mqtt_Commands.SAY_MY_IP.value: {
+ "rpc": "say_my_ip",
+ "kwargs": json.loads, # kwargs: option
+ },
+ Mqtt_Commands.SHUTDOWN.value: {"rpc": "shutdown"},
+ Mqtt_Commands.REBOOT.value: {"rpc": "reboot"},
+ Mqtt_Commands.TIMER_SHUTDOWN.value: {
+ "rpc": "timer_shutdown",
+ "kwargs": json.loads, # kwargs: value
+ },
+ Mqtt_Commands.TIMER_STOP_PLAYER.value: {
+ "rpc": "timer_stop_player",
+ "kwargs": json.loads, # kwargs: value
+ },
+ Mqtt_Commands.TIMER_FADE_VOLUME.value: {
+ "rpc": "timer_fade_volume",
+ "kwargs": json.loads, # kwargs: value
+ },
+}
+
+mqtt_cmd = {
+ **_volume_cmds,
+ **_system_cmd,
+ **_player_cmds,
+}
diff --git a/src/jukebox/components/mqtt/mqtt_const.py b/src/jukebox/components/mqtt/mqtt_const.py
new file mode 100644
index 000000000..0fdb211b0
--- /dev/null
+++ b/src/jukebox/components/mqtt/mqtt_const.py
@@ -0,0 +1,51 @@
+from enum import Enum
+
+
+class Mqtt_Attributes(Enum):
+ STATE = "attribute/state"
+ TITLE = "attribute/title"
+ ARTIST = "attribute/artist"
+ ELAPSED = "attribute/elapsed"
+ DURATION = "attribute/duration"
+ TRACK = "attribute/track"
+ FILE = "attribute/file"
+ RANDOM = "attribute/random"
+ REPEAT = "attribute/repeat"
+ REPEAT_MODE = "attribute/repeat_mode"
+ VOLUME = "attribute/volume"
+ MUTE = "attribute/mute"
+
+
+class Mqtt_Commands(Enum):
+ PLAY = "play"
+ PLAY_FOLDER = "play_folder"
+ PLAY_ALBUM = "play_album"
+ PLAY_CARD = "play_card"
+ PLAY_SINGLE = "play_single"
+ PAUSE = "pause"
+ NEXT_SONG = "next_song"
+ PREV_SONG = "prev_song"
+ STOP = "stop"
+ REWIND = "rewind"
+ SHUFFLE = "shuffle"
+ REPLAY = "replay"
+ REPEAT = "repeat"
+ CHANGE_VOLUME = "change_volume"
+ SET_VOLUME = "set_volume"
+ VOLUME_MUTE = "volume_mute"
+ SET_SOFT_MAX_VOLUME = "set_soft_max_volume"
+ SAY_MY_IP = "say_my_ip"
+ SHUTDOWN = "shutdown"
+ REBOOT = "reboot"
+ TIMER_SHUTDOWN = "timer_shutdown"
+ TIMER_STOP_PLAYER = "timer_stop_player"
+ TIMER_FADE_VOLUME = "timer_fade_volume"
+
+
+# List of topics to send
+topics_to_send = ["volume.level", "playerstatus"]
+
+# Constants for repeat modes
+REPEAT_MODE_OFF = "off"
+REPEAT_MODE_SINGLE = "single"
+REPEAT_MODE_PLAYLIST = "playlist"
diff --git a/src/jukebox/components/mqtt/utils.py b/src/jukebox/components/mqtt/utils.py
new file mode 100644
index 000000000..48843a9ac
--- /dev/null
+++ b/src/jukebox/components/mqtt/utils.py
@@ -0,0 +1,70 @@
+import time
+from typing import Callable, Optional
+
+from components.rpc_command_alias import cmd_alias_definitions
+
+from .mqtt_const import REPEAT_MODE_OFF, REPEAT_MODE_PLAYLIST, REPEAT_MODE_SINGLE
+
+
+def play_folder_recursive_args(payload: str) -> dict:
+ """Create arguments for playing a folder recursively."""
+ return {"folder": payload, "recursive": True}
+
+
+def parse_repeat_mode(payload: str) -> Optional[str]:
+ """Parse a repeat mode command based on the given payload."""
+ if payload == "single":
+ return "toggle_repeat_single"
+ elif payload == "playlist":
+ return "toggle_repeat"
+ elif payload in ["disable", "off"]:
+ return None
+ return "toggle"
+
+
+def get_args(config: dict, payload: dict) -> Optional[dict]:
+ """Retrieve arguments based on the configuration and payload."""
+ if "args" not in config:
+ return None
+ elif isinstance(config["args"], Callable):
+ return config["args"](payload)
+ return config["args"]
+
+
+def get_rpc_command(config: dict) -> Optional[dict]:
+ """Retrieve the RPC command based on the configuration."""
+ rpc_key = config.get("rpc")
+ if rpc_key is None:
+ return None
+ elif isinstance(config["rpc"], str):
+ return cmd_alias_definitions[rpc_key]
+ return config["rpc"]
+
+
+def get_kwargs(config: dict, payload: dict) -> Optional[dict]:
+ """Retrieve keyword arguments based on the configuration and payload."""
+ if "kwargs" not in config:
+ return None
+ elif isinstance(config["kwargs"], Callable):
+ return config["kwargs"](payload)
+ return config["kwargs"]
+
+
+def get_current_time_milli() -> int:
+ """Get the current time in milliseconds."""
+ return round(time.time() * 1000)
+
+
+def split_topic(topic: str) -> str:
+ """Split an MQTT topic and return a part of it."""
+ parts = topic.split("/")
+ return parts[2] if len(parts) == 3 else parts[1]
+
+
+def map_repeat_mode(repeat_active: bool, single_active: bool) -> str:
+ """Map boolean flags to repeat mode constants."""
+ if not repeat_active:
+ return REPEAT_MODE_OFF
+ if single_active:
+ return REPEAT_MODE_SINGLE
+ return REPEAT_MODE_PLAYLIST