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