From d6b48d8c7a3cb5069e05687a1ac397c96f45d6e4 Mon Sep 17 00:00:00 2001 From: Alvin Schiller <103769832+AlvinSchiller@users.noreply.github.com> Date: Mon, 12 Feb 2024 00:07:24 +0100 Subject: [PATCH 01/37] Bump to v3.6.0-alpha --- src/jukebox/jukebox/version.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/jukebox/jukebox/version.py b/src/jukebox/jukebox/version.py index dae1bc8c0..a7f37c5e6 100644 --- a/src/jukebox/jukebox/version.py +++ b/src/jukebox/jukebox/version.py @@ -1,8 +1,8 @@ VERSION_MAJOR = 3 -VERSION_MINOR = 5 -VERSION_PATCH = 1 -VERSION_EXTRA = "" +VERSION_MINOR = 6 +VERSION_PATCH = 0 +VERSION_EXTRA = "alpha" # build a version string in compliance with the SemVer specification # https://semver.org/#semantic-versioning-specification-semver From 75743da4ccbd9e7b68f6e410eb89fefdc240736e Mon Sep 17 00:00:00 2001 From: s-martin Date: Wed, 14 Feb 2024 09:25:01 +0100 Subject: [PATCH 02/37] Extract docs for battery monitor (#2257) * extract battmon docs into markdown * Fix typo * add link to ADS1015 --- documentation/builders/README.md | 16 +++++---- .../components/power/batterymonitor.md | 33 +++++++++++++++++++ .../batt_mon_i2c_ads1015/__init__.py | 31 +---------------- 3 files changed, 43 insertions(+), 37 deletions(-) create mode 100644 documentation/builders/components/power/batterymonitor.md diff --git a/documentation/builders/README.md b/documentation/builders/README.md index 6d9e67bff..ed08f5980 100644 --- a/documentation/builders/README.md +++ b/documentation/builders/README.md @@ -9,29 +9,31 @@ ## Features * Audio - * [Audio Output](./audio.md) - * [Bluetooth audio buttons](./bluetooth-audio-buttons.md) + * [Audio Output](./audio.md) + * [Bluetooth audio buttons](./bluetooth-audio-buttons.md) * [GPIO Recipes](./gpio.md) * [Card Database](./card-database.md) - * [RFID Cards synchronisation](./components/synchronisation/rfidcards.md) + * [RFID Cards synchronisation](./components/synchronisation/rfidcards.md) * [Auto Hotspot](./autohotspot.md) * File Management - * [Network share / Samba](./samba.md) + * [Network share / Samba](./samba.md) ## Hardware Components * [Power](./components/power/) - * [OnOff SHIM for safe power on/off](./components/power/onoff-shim.md) + * [OnOff SHIM for safe power on/off](./components/power/onoff-shim.md) + * [Battery Monitor based on a ADS1015](./components/power/batterymonitor.md) * [Soundcards](./components/soundcards/) - * [HiFiBerry Boards](./components/soundcards/hifiberry.md) + * [HiFiBerry Boards](./components/soundcards/hifiberry.md) * [RFID Readers](./../developers/rfid/README.md) ## Web Application * Music - * [Playlists, Livestreams and Podcasts](./webapp/playlists-livestreams-podcasts.md) + * [Playlists, Livestreams and Podcasts](./webapp/playlists-livestreams-podcasts.md) ## Advanced + * [Troubleshooting](./troubleshooting.md) * [Concepts](./concepts.md) * [System](./system.md) diff --git a/documentation/builders/components/power/batterymonitor.md b/documentation/builders/components/power/batterymonitor.md new file mode 100644 index 000000000..d57e14acb --- /dev/null +++ b/documentation/builders/components/power/batterymonitor.md @@ -0,0 +1,33 @@ +# Battery Monitor based on a ADS1015 + +> [!CAUTION] +> Lithium and other batteries are dangerous and must be treated with care. +> Rechargeable Lithium Ion batteries are potentially hazardous and can +> present a serious **FIRE HAZARD** if damaged, defective or improperly used. +> Do not use this circuit to a lithium ion battery without expertise and +> training in handling and use of batteries of this type. +> Use appropriate test equipment and safety protocols during development. +> There is no warranty, this may not work as expected or at all! + +The script in [src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/\_\_init\_\_.py](../../../../src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/__init__.py) is intended to read out the voltage of a single Cell LiIon Battery using a [CY-ADS1015 Board](https://www.adafruit.com/product/1083): + +```text + 3.3V + + + | + .----o----. + ___ | | SDA + .--------|___|---o----o---------o AIN0 o------ + | 2MΩ | | | | SCL + | .-. | | ADS1015 o------ + --- | | --- | | + Battery - 1.5MΩ| | ---100nF '----o----' + 2.9V-4.2V| '-' | | + | | | | + === === === === +``` + +> [!WARNING] +> +> * the circuit is constantly draining the battery! (leak current up to: 2.1µA) +> * the time between sample needs to be a minimum 1sec with this high impedance voltage divider don't use the continuous conversion method! diff --git a/src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/__init__.py b/src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/__init__.py index af193a37f..551759c14 100644 --- a/src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/__init__.py +++ b/src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/__init__.py @@ -37,36 +37,7 @@ class battmon_ads1015(BatteryMonitorBase.BattmonBase): """Battery Monitor based on a ADS1015 - > [!CAUTION] - > Lithium and other batteries are dangerous and must be treated with care. - > Rechargeable Lithium Ion batteries are potentially hazardous and can - > present a serious **FIRE HAZARD** if damaged, defective or improperly used. - > Do not use this circuit to a lithium ion battery without expertise and - > training in handling and use of batteries of this type. - > Use appropriate test equipment and safety protocols during development. - > There is no warranty, this may not work as expected or at all! - - This script is intended to read out the Voltage of a single Cell LiIon Battery using a CY-ADS1015 Board: - - 3.3V - + - | - .----o----. - ___ | | SDA - .--------|___|---o----o---------o AIN0 o------ - | 2MΩ | | | | SCL - | .-. | | ADS1015 o------ - --- | | --- | | - Battery - 1.5MΩ| | ---100nF '----o----' - 2.9V-4.2V| '-' | | - | | | | - === === === === - - Attention: - * the circuit is constantly draining the battery! (leak current up to: 2.1µA) - * the time between sample needs to be a minimum 1sec with this high impedance voltage divider - don't use the continuous conversion method! - + See [Battery Monitor documentation](../../builders/components/power/batterymonitor.md) """ def __init__(self, cfg): From 876219ad9d674807d6294321b53b9607614f2109 Mon Sep 17 00:00:00 2001 From: Alvin Schiller <103769832+AlvinSchiller@users.noreply.github.com> Date: Wed, 14 Feb 2024 10:43:03 +0100 Subject: [PATCH 03/37] github actions update (#2239) * Bump actions/download-artifact from 3 to 4 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 3 to 4. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v3...v4) * Bump actions/upload-artifact from 3 to 4 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](actions/upload-artifact@v3...v4) * Bump geekyeggo/delete-artifact from 2 to 4 * add write permission for artifact deletion * ignore fails for artifact deletion * Revert "ignore fails for artifact deletion" This reverts commit a759d89fc0e9e091e0bd11ff7c94cff96c0a5ff6. * Revert "add write permission for artifact deletion" This reverts commit 102840204290fb6543fc5d8f8245dca1ac21c63e. * remove cleanup stage for now --- .../bundle_webapp_and_release_v3.yml | 4 +-- .../test_docker_debian_codename_sub_v3.yml | 28 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/bundle_webapp_and_release_v3.yml b/.github/workflows/bundle_webapp_and_release_v3.yml index 13bffe472..a64d6e288 100644 --- a/.github/workflows/bundle_webapp_and_release_v3.yml +++ b/.github/workflows/bundle_webapp_and_release_v3.yml @@ -101,7 +101,7 @@ jobs: tar -czvf ${{ steps.vars.outputs.webapp_bundle_name }} build - name: Artifact Upload - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ steps.vars.outputs.webapp_bundle_name }} path: ${{ steps.build-webapp.outputs.webapp-root-path }}/${{ steps.vars.outputs.webapp_bundle_name }} @@ -119,7 +119,7 @@ jobs: steps: - name: Artifact Download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: ${{ needs.build.outputs.webapp_bundle_name }} diff --git a/.github/workflows/test_docker_debian_codename_sub_v3.yml b/.github/workflows/test_docker_debian_codename_sub_v3.yml index a9ec217dc..6deedb478 100644 --- a/.github/workflows/test_docker_debian_codename_sub_v3.yml +++ b/.github/workflows/test_docker_debian_codename_sub_v3.yml @@ -134,7 +134,7 @@ jobs: BASE_TEST_IMAGE=${{ steps.vars.outputs.image_tag_name_local_base }} - name: Artifact Upload Docker Image - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ steps.vars.outputs.image_file_name }} path: ${{ steps.vars.outputs.image_file_path }} @@ -159,7 +159,7 @@ jobs: uses: docker/setup-buildx-action@v3.0.0 - name: Artifact Download Docker Image - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: ${{ needs.build.outputs.image_file_name }} @@ -177,15 +177,15 @@ jobs: args: | ./${{ matrix.test_script }} - # cleanup after test execution - cleanup: - # run only if tests didn't fail: keep the artifact to make job reruns possible - if: ${{ !failure() }} - needs: [build, test] - runs-on: ${{ inputs.runs_on }} - - steps: - - name: Artifact Delete Docker Image - uses: geekyeggo/delete-artifact@v2 - with: - name: ${{ needs.build.outputs.image_file_name }} + ## cleanup after test execution + #cleanup: + # # run only if tests didn't fail: keep the artifact to make job reruns possible + # if: ${{ !failure() }} + # needs: [build, test] + # runs-on: ${{ inputs.runs_on }} + # + # steps: + # - name: Artifact Delete Docker Image + # uses: geekyeggo/delete-artifact@v4 + # with: + # name: ${{ needs.build.outputs.image_file_name }} From f630d30fea559963c26694ed624d6a1025cd04ff Mon Sep 17 00:00:00 2001 From: s-martin Date: Wed, 14 Feb 2024 10:44:38 +0100 Subject: [PATCH 04/37] (doc) update the docstring markdown file (#2259) --- documentation/developers/docstring/README.md | 97 +++++++++++++------- 1 file changed, 65 insertions(+), 32 deletions(-) diff --git a/documentation/developers/docstring/README.md b/documentation/developers/docstring/README.md index aa7cefecc..c48c34a41 100644 --- a/documentation/developers/docstring/README.md +++ b/documentation/developers/docstring/README.md @@ -104,6 +104,12 @@ * [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) @@ -1948,6 +1954,61 @@ Add all output devices to the GUI List of all added GUI objects + + +# components.rfid.hardware.generic\_nfcpy.description + +List of supported devices https://nfcpy.readthedocs.io/en/latest/overview.html + + + + +# components.rfid.hardware.generic\_nfcpy.generic\_nfcpy + + + +## ReaderClass Objects + +```python +class ReaderClass(ReaderBaseClass) +``` + +The reader class for nfcpy supported NFC card readers. + + + + +#### cleanup + +```python +def cleanup() +``` + +The cleanup function: free and release all resources used by this card reader (if any). + + + + +#### stop + +```python +def stop() +``` + +This function is called to tell the reader to exit its reading function. + + + + +#### read\_card + +```python +def read_card() -> str +``` + +Blocking or non-blocking function that waits for a new card to appear and return the card's UID as string + + # components.rfid.hardware.generic\_usb.description @@ -2659,7 +2720,7 @@ def stop_autohotspot() Stop auto hotspot functionality -Basically disabling the cronjob and running the script one last time manually +Stopping and disabling the timer and running the service one last time manually @@ -2671,9 +2732,9 @@ Basically disabling the cronjob and running the script one last time manually def start_autohotspot() ``` -start auto hotspot functionality +Start auto hotspot functionality -Basically enabling the cronjob and running the script one time manually +Enabling and starting the timer (timer will start the service) @@ -2966,35 +3027,7 @@ class battmon_ads1015(BatteryMonitorBase.BattmonBase) Battery Monitor based on a ADS1015 -> [!CAUTION] -> Lithium and other batteries are dangerous and must be treated with care. -> Rechargeable Lithium Ion batteries are potentially hazardous and can -> present a serious **FIRE HAZARD** if damaged, defective or improperly used. -> Do not use this circuit to a lithium ion battery without expertise and -> training in handling and use of batteries of this type. -> Use appropriate test equipment and safety protocols during development. -> There is no warranty, this may not work as expected or at all! - -This script is intended to read out the Voltage of a single Cell LiIon Battery using a CY-ADS1015 Board: - - 3.3V - + - | - .----o----. - ___ | | SDA - .--------|___|---o----o---------o AIN0 o------ - | 2MΩ | | | | SCL - | .-. | | ADS1015 o------ - --- | | --- | | - Battery - 1.5MΩ| | ---100nF '----o----' - 2.9V-4.2V| '-' | | - | | | | - === === === === - -Attention: -* the circuit is constantly draining the battery! (leak current up to: 2.1µA) -* the time between sample needs to be a minimum 1sec with this high impedance voltage divider - don't use the continuous conversion method! +See [Battery Monitor documentation](../../builders/components/power/batterymonitor.md) From 21639b9480008d98e03456c3a7c87878ef8cee9b Mon Sep 17 00:00:00 2001 From: s-martin Date: Sun, 18 Feb 2024 22:01:17 +0100 Subject: [PATCH 05/37] (maint) Remove duplicate rst (#2269) * remove duplicate rst doc file * fix link --- documentation/developers/rfid/mock_reader.md | 2 +- .../bluetooth_audio_buttons/README.rst | 55 ------------------- 2 files changed, 1 insertion(+), 56 deletions(-) delete mode 100644 src/jukebox/components/controls/bluetooth_audio_buttons/README.rst diff --git a/documentation/developers/rfid/mock_reader.md b/documentation/developers/rfid/mock_reader.md index 4d5a3ea36..d6cac8cd0 100644 --- a/documentation/developers/rfid/mock_reader.md +++ b/documentation/developers/rfid/mock_reader.md @@ -6,7 +6,7 @@ machine - probably in a Python virtual environment. **place-capable**: yes -If you [mock the GPIO pins](../../../src/jukebox/components/gpio/gpioz/README.rst#use-mock-pins), this GUI will show the GPIO devices. +If you [mock the GPIO pins](../../builders/gpio.md#use-mock-pins), this GUI will show the GPIO devices. ![image](mock_reader.png) diff --git a/src/jukebox/components/controls/bluetooth_audio_buttons/README.rst b/src/jukebox/components/controls/bluetooth_audio_buttons/README.rst deleted file mode 100644 index 87a7eab2b..000000000 --- a/src/jukebox/components/controls/bluetooth_audio_buttons/README.rst +++ /dev/null @@ -1,55 +0,0 @@ -When a bluetooth sound device (headphone, speakers) connects -attempt to automatically listen to it's buttons (play, next, ...) - -The bluetooth input device name is matched automatically from the -bluetooth sound card device name. During boot up, it is uncertain if the bluetooth device connects first, -or the Jukebox service is ready first. Therefore, -after service initialization, already connected bluetooth sound devices are scanned and an attempt is made -to find their input buttons. - -.. note:: If the automatic matching fails, there currently is no - manual configuration option. Open an issue ticket if you have problems with the automatic matching. - -Button key codes are standardized and by default the buttons -play, pause, next song, previous song are recognized. Volume up/down is handled independently -from this module by PulseAudio and the bluetooth audio transmission protocol. - -The module needs to be enabled in the main configuration file with: - -.. code-block:: yaml - - bluetooth_audio_buttons: - enable: true - - -Custom key bindings ---------------------- - -You may change or extend the actions assigned to a button in the configuration. If the configuration contains -a block 'mapping', the default button-action mapping is *completely* replaced with the new mapping. The definitions for -each key looks like ``key-code: {rpc_command_definition}``. -The RPC command follows the regular RPC command rules as defined in :ref:`userguide/rpc_commands:RPC Commands`. - -.. code-block:: yaml - - bluetooth_audio_buttons: - enable: true - mapping: - # Play & pause both map to toggle which is also the usual behaviour of headsets - 200: - alias: toggle - 201: - alias: toggle - # Re-map next song button, to set defined output volume (for some fun) - 163: - package: volume - plugin: ctrl - method: set_volume - args: [18] - # Re-map prev song button to shutdown - 165: - alias: shutdown - - -Key codes can be found in the log files. Press the various buttons on your headset, while watching the -logs with e.g. ``tail -f shared/logs/app.log``. Look for entries like ``No callback registered for button ...``. From 07208e60c507bd3c0761e48718bc8b363029aa47 Mon Sep 17 00:00:00 2001 From: Vito Zanotelli Date: Tue, 20 Feb 2024 16:27:51 +0100 Subject: [PATCH 06/37] Event Device Support (`evdev`) (#1943) * Add event device plugin This implements an "event device" listener plugin, that enables the user to configure the phoniebox to respond to events from an "event device" (device under /dev/input). This incluces eg button presses from an USB controller or keyboard. * Adds documentation for the event device plugin Includes a detailed how-to as well as example config * Allow empty button config This is an actual usecase in case someone wants to setup a device and figure out the button ids by looking at the logs * Update README.rst * Improve README - Remove duplicated section as suggested - Add Zero Delay Arcade USB Encoder usecase * Give more meaningfull name to listener thread variable * Link to old documentation * Remove README and fix typo The documentation is now part of the markdown documentation * Remove unecessary plain=1 * Remove indents in example code There was some left over indentation from the old rtf format in the python code examples. * Add example how to access evdev in docker * Example for new Evdev config structure This is tighly modeled after gpio. In contrast to GPIO, this is a list of devices containing each one or more input/output devices. Eg a Joystick has keys but potentially also rumble or leds. Currently only very simply input devices (buttons) are supported. This structer should enable to extend this easily. * Move evdev config to separate file As suggested this should be a separate file, similar to the gpioz config * Adapt evdev code to new config Now the config for evdev is akin to GPIO. The big difference is that multiple devices with each multiple input/output devices are supported. Note that the backend is still the old event device listener backend. Thus to support more features, the backend would need to be quite drastically overhauled. * Update documentation for new evdev config format * Fix wrong reference * Fix bug to correctly check supported input device type * Add more tests for evdev init * Fix wrong attribute name The attribute 'device_request' of EvDevKeyListener is now called 'device_name_request' * Validate input config better Fails if there is no device_name and setts proper default for 'exact' * Add pyzmq installation for github action This is required for some tests. Following the instructions from issue #2050 Specifically: https://github.com/MiczFlor/RPi-Jukebox-RFID/issues/2050#issuecomment-1804785695 * Revert "Add pyzmq installation for github action" This reverts commit 75161b545a7a127a5d486c0770c449fbbd52c3d4. As this was not working * Mock `jukebox.publishing` in tests to avoid zmq Currently it is hard to install zmq (#2050) and for CI pytest `zmq` is not available. As the test do not really require `jukebox.publishing` running, mocking it in the test avoids it being imported. Thus the tests do not require `zmq` to be installed. --------- Co-authored-by: pabera <1260686+pabera@users.noreply.github.com> Co-authored-by: Vito Zanotelli Co-authored-by: s-martin --- documentation/builders/README.md | 1 + documentation/builders/event-devices.md | 120 ++++++++++ documentation/developers/docker.md | 14 ++ resources/default-settings/evdev.example.yaml | 59 +++++ .../bluetooth_audio_buttons/__init__.py | 4 +- .../controls/event_devices/__init__.py | 221 ++++++++++++++++++ test/evdev/test_evdev_init.py | 185 +++++++++++++++ 7 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 documentation/builders/event-devices.md create mode 100644 resources/default-settings/evdev.example.yaml create mode 100644 src/jukebox/components/controls/event_devices/__init__.py create mode 100644 test/evdev/test_evdev_init.py diff --git a/documentation/builders/README.md b/documentation/builders/README.md index ed08f5980..512d26ed0 100644 --- a/documentation/builders/README.md +++ b/documentation/builders/README.md @@ -26,6 +26,7 @@ * [Soundcards](./components/soundcards/) * [HiFiBerry Boards](./components/soundcards/hifiberry.md) * [RFID Readers](./../developers/rfid/README.md) +* [Event devices (USB and other buttons)](./event-devices.md) ## Web Application diff --git a/documentation/builders/event-devices.md b/documentation/builders/event-devices.md new file mode 100644 index 000000000..8fe5d08e0 --- /dev/null +++ b/documentation/builders/event-devices.md @@ -0,0 +1,120 @@ +# Event devices + +## Background +Event devices are generic input devices that are exposed in `/dev/input`. +This includes USB peripherals (Keyboards, Controllers, Joysticks or Mouse) as well as potentially bluetooth devices. + +A specific usecase for this could be, if a Zero Delay Arcade USB Encoder is used to wire arcade buttons instead of using GPIO pins. + +These device interface support various kinds of input events, such as press, movements and potentially also outputs (eg. rumble, led lights, ...). Currently only the usage of button presses as input is supported. + +This functionality was previously implemented under the name of [USB buttons](https://github.com/MiczFlor/RPi-Jukebox-RFID/blob/develop/components/controls/buttons_usb_encoder/README.md). + +The devices and their button mappings need to be mapped in the configuration file. + +## Configuration + +To configure event devices, first add the plugin as an entry to the module list of your main configuration file ``shared/settings/jukebox.yaml``: + +``` yaml +modules: + named: + event_devices: controls.event_devices +``` + +And add the following section with the plugin specific configuration: +``` yaml +evdev: + enabled: true + config_file: ../../shared/settings/evdev.yaml +``` + +The actual configuration itself is stored in a separate file. In this case in ``../../shared/settings/evdev.yaml``. + +The configuration is structured akin to the configuration of the [GPIO devices](./gpio.md). + +In contrast to `gpio`, multiple devices (eg arcade controllser, keyboards, joysticks, mice, ...) are supported, each with their own `input_devices` (=buttons). `output_devices` or actions other than `on_press` are currently not yet supported. + +``` yaml +devices: # list of devices to listen for + {device nickname}: # config for a specific device + device_name: {device_name} # name of the device + exact: False/True # optional to require exact match. Otherwise it is sufficient that a part of the name matches + input_devices: # list of buttons to listen for for this device + {button nickname}: + type: Button + kwargs: + key_code: {key-code}: # evdev event id + actions: + on_press: # Currently only the on_press action is supported + {rpc_command_definition} # eg `alias: toggle` +``` +The `{device nickname}` is only for your own orientation and can be choosen freely. +For each device you need to figure out the `{device_name}` and the `{event_id}` corresponding to key strokes, as indicated in the sections below. + +### Identifying the `{device_name}` + +The `{device_name}` can be identified using the following Python snippet: + +``` Python +import evdev +devices = [evdev.InputDevice(path) for path in evdev.list_devices()] +for device in devices: + print(device.path, device.name, device.phys) +``` + +The output could be in the style of: + +``` +/dev/input/event1 Dell Dell USB Keyboard usb-0000:00:12.1-2/input0 +/dev/input/event0 Dell USB Optical Mouse usb-0000:00:12.0-2/input0 +``` + +In this example, the `{device_name}` could be `DELL USB Optical Mouse`. +Note that if you use the option `exact: False`, it would be sufficient to add a substring such as `USB Keyboard`. + +### Identifying the `{key-code}` + +The key code for a button press can be determined using the following code snippet: + +``` Python +import evdev +device = evdev.InputDevice('/dev/input/event0') +device.capabilities(verbose=True)[('EV_KEY', evdev.ecodes.EV_KEY)] +``` + +With the `InputDevice` corresponding to the path from the output of the section `{device_name}` (eg. in the example `/dev/input/event0` +would correspond to `Dell Dell USB Keyboard`). + +If the naming is not clear, it is also possible to empirically check for the key code by listening for events: + +``` Python +from evdev import InputDevice, categorize, ecodes +dev = InputDevice('/dev/input/event1') +print(dev) +for event in dev.read_loop(): + if event.type == ecodes.EV_KEY: + print(categorize(event)) +``` +The output could be of the form: +``` +device /dev/input/event1, name "DragonRise Inc. Generic USB Joystick ", phys "usb-3f980000.usb-1.2/input0" +key event at 1672569673.124168, 297 (BTN_BASE4), down +key event at 1672569673.385170, 297 (BTN_BASE4), up +``` + +In this example output, the `{key-code}` would be `297` + +Alternatively, the device could also be setup without a mapping. +Afterwards, when pressing keys, the key codes can be found in the log files. Press various buttons on your device, +while watching the logs with `tail -f shared/logs/app.log`. +Look for entries like `No callback registered for button ...`. + +### Specifying the `{rpc_command_definition}` + +The RPC command follows the regular RPC command rules as defined in the [following documentation](./rpc-commands.md). + + +## Full example config + +A complete configuration example for a USB Joystick controller can be found in the [examples](../../resources/default-settings/evdev.example.yaml). \ No newline at end of file diff --git a/documentation/developers/docker.md b/documentation/developers/docker.md index 827178aca..19a57e414 100644 --- a/documentation/developers/docker.md +++ b/documentation/developers/docker.md @@ -291,6 +291,20 @@ $ docker run -it --rm \ --name jukebox jukebox ``` +## Testing EVDEV devices in Linux +To test the [event device capabilities](../builders/event-devices.md) in docker, the device needs to be made available to the container. + +### Linux +Mount the device into the container by configuring the appropriate device in a `devices` section of the `jukebox` service in the docker compose file. For example: + +```yaml + jukebox: + ... + devices: + - /dev/input/event3:/dev/input/event3 +``` + + ### Resources #### Mac diff --git a/resources/default-settings/evdev.example.yaml b/resources/default-settings/evdev.example.yaml new file mode 100644 index 000000000..5fbcd57e8 --- /dev/null +++ b/resources/default-settings/evdev.example.yaml @@ -0,0 +1,59 @@ +devices: # A list of evdev devices each containing one or multiple input/output devices + joystick: # A nickname for a device + device_name: DragonRise Inc. Generic USB # Device name + exact: false # If true, the device name must match exactly, otherwise it is sufficient to contain the name + input_devices: + TogglePlayback: + type: Button + kwargs: + key_code: 299 + actions: + on_press: + alias: toggle + NextSong: + type: Button + kwargs: + key_code: 298 + actions: + on_press: + alias: next_song + PrevSong: + type: Button + kwargs: + key_code: 297 + actions: + on_press: + alias: prev_song + VolumeUp: + type: Button + kwargs: + key_code: 296 + actions: + on_press: + alias: change_volume + args: 5 + VolumeDown: + type: Button + kwargs: + key_code: 295 + actions: + on_press: + alias: change_volume + args: -5 + VolumeReset: + type: Button + kwargs: + key_code: 291 + actions: + on_press: + package: volume + plugin: ctrl + method: set_volume + args: [18] + Shutdown: + type: Button + kwargs: + key_code: 292 + actions: + on_press: + alias: shutdown \ No newline at end of file diff --git a/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py b/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py index 4d17f398e..f03a447cd 100644 --- a/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py +++ b/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py @@ -49,8 +49,8 @@ def activate(device_name: str, exact: bool = True, open_initial_delay: float = 0 # Do a bit of housekeeping: Delete dead threads listener = list(filter(lambda x: x.is_alive(), listener)) # Check that there is no running thread for this device already - for ll in listener: - if ll.device_request == device_name and ll.is_alive(): + for thread in listener: + if thread.device_request == device_name and thread.is_alive(): logger.debug(f"Button listener thread already active for '{device_name}'") return diff --git a/src/jukebox/components/controls/event_devices/__init__.py b/src/jukebox/components/controls/event_devices/__init__.py new file mode 100644 index 000000000..407a73f14 --- /dev/null +++ b/src/jukebox/components/controls/event_devices/__init__.py @@ -0,0 +1,221 @@ +""" +Plugin to register event_devices (ie USB controllers, keyboards etc) in a + generic manner. + +This effectively does: + + * parse the configured event devices from the evdev.yaml + * setup listen threads + +""" +from __future__ import annotations + +import logging +from typing import Callable +from typing import Tuple + +import jukebox.cfghandler +import jukebox.plugs as plugin +import jukebox.utils +from components.controls.common.evdev_listener import EvDevKeyListener + +logger = logging.getLogger("jb.EventDevice") +cfg_main = jukebox.cfghandler.get_handler("jukebox") +cfg_evdev = jukebox.cfghandler.get_handler("eventdevices") + +# Keep track of all active key event listener threads +# Removal of dead listener threads is done in lazy fashion: +# only on a new connect are dead threads removed +listener: list[EvDevKeyListener] = [] +# Running count of all created listener threads for unique thread naming IDs +listener_cnt = 0 + +#: Indicates that the module is enabled and loaded w/o errors +IS_ENABLED: bool = False +#: The path of the config file the event device configuration was loaded from +CONFIG_FILE: str + +# Constants +_TYPE_BUTTON = 'Button' +_ACTION_ON_PRESS = 'on_press' + +_SUPPORTED_TYPES = [_TYPE_BUTTON] +_SUPPORTED_ACTIONS = {_TYPE_BUTTON: _ACTION_ON_PRESS} + + +@plugin.register +def activate( + device_name: str, + button_callbacks: dict[int, Callable], + exact: bool = True, + mandatory_keys: set[int] | None = None, +): + """Activate an event device listener + + :param device_name: device name + :type device_name: str + :param button_callbacks: mapping of event + code to RPC + :type button_callbacks: dict[int, Callable] + :param exact: Should the device_name match exactly + (default, false) or be a substring of the name? + :type exact: bool, optional + :param mandatory_keys: Mandatory event ids the + device needs to support. Defaults to None + to require all ids from the button_callbacks + :type mandatory_keys: set[int] | None, optional + """ + global listener + global listener_cnt + logger.debug("activate event device: %s", device_name) + # Do a bit of housekeeping: Delete dead threads + listener = list(filter(lambda x: x.is_alive(), listener)) + # Check that there is no running thread for this device already + for thread in listener: + if thread.device_name_request == device_name and thread.is_alive(): + logger.debug( + "Event device listener thread already active for '%s'", + device_name, + ) + return + + listener_cnt += 1 + new_listener = EvDevKeyListener( + device_name_request=device_name, + exact_name=exact, + thread_name=f"EvDevKeyListener-{listener_cnt}", + ) + + listener.append(new_listener) + if button_callbacks is not None: + new_listener.button_callbacks = button_callbacks + if mandatory_keys is not None: + new_listener.mandatory_keys = mandatory_keys + else: + new_listener.mandatory_keys = set(button_callbacks.keys()) + new_listener.start() + + +@plugin.initialize +def initialize(): + """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. + """ + global IS_ENABLED + global CONFIG_FILE + IS_ENABLED = False + enable = cfg_main.setndefault('evdev', 'enable', value=False) + CONFIG_FILE = cfg_main.setndefault('evdev', 'config_file', value='../../shared/settings/evdev.yaml') + if not enable: + return + try: + jukebox.cfghandler.load_yaml(cfg_evdev, CONFIG_FILE) + except Exception as e: + logger.error(f"Disable Event Devices due to error loading evdev config file. {e.__class__.__name__}: {e}") + return + + IS_ENABLED = True + + with cfg_evdev: + for name, config in cfg_evdev.getn( + "devices", + default={}, + ).items(): + logger.debug("activate %s", name) + try: + device_name, exact, button_callbacks = parse_device_config(config) + except Exception as e: + logger.error(f"Error parsing event device config for '{name}'. {e.__class__.__name__}: {e}") + continue + + logger.debug( + f'Call activate with: "{device_name}" and exact: {exact}', + ) + activate( + device_name, + button_callbacks=button_callbacks, + exact=exact, + ) + + +def parse_device_config(config: dict) -> Tuple[str, bool, dict[int, Callable]]: + """Parse the device configuration from the config file + + :param config: The configuration of the device + :type config: dict + :return: The parsed device configuration + :rtype: Tuple[str, bool, dict[int, Callable]] + """ + device_name = config.get("device_name") + if device_name is None: + raise ValueError("'device_name' is required but missing") + exact = bool(config.get("exact", False)) + input_devices = config.get("input_devices", {}) + # Raise warning if not used config present + if 'output_devices' in config: + logger.warning( + "Output devices are not yet supported for event devices", + ) + + # Parse input devices and convert them to button mappings. + # Due to the current implementation of the Event Device Listener, + # only the 'on_press' action is supported. + button_mapping = _input_devices_to_key_mapping(input_devices) + button_callbacks: dict[int, Callable] = {} + for key, action_request in button_mapping.items(): + button_callbacks[key] = jukebox.utils.bind_rpc_command( + action_request, + dereference=False, + logger=logger, + ) + return device_name, exact, button_callbacks + + +def _input_devices_to_key_mapping(input_devices: dict) -> dict: + """Convert input devices to key mapping + + Currently this only supports 'button' input devices with the 'on_press' action. + + :param input_devices: The input devices + :type input_devices: dict + :return: The mapping of key_code to action + :rtype: dict + """ + mapping = {} + for nickname, device in input_devices.items(): + input_type = device.get('type') + if input_type not in _SUPPORTED_TYPES: + logger.warning( + f"Input '{nickname}' device type '{input_type}' is not supported", + ) + continue + + key_code = device.get('kwargs', {}).get('key_code') + if key_code is None: + logger.warning( + f"Input '{nickname}' has no key_code and cannot be mapped.", + ) + continue + + actions = device.get('actions') + + for action_name, action in actions.items(): + if action_name not in _SUPPORTED_ACTIONS[_TYPE_BUTTON]: + logger.warning( + f"Input '{nickname}' has unsupported action '{action_name}'.\n" + f"Currently supported actions: {_SUPPORTED_ACTIONS}", + ) + if action_name == _ACTION_ON_PRESS: + mapping[key_code] = action + + return mapping + + +@plugin.atexit +def atexit(**ignored_kwargs): + global listener + for ll in listener: + ll.stop() + return listener diff --git a/test/evdev/test_evdev_init.py b/test/evdev/test_evdev_init.py new file mode 100644 index 000000000..c37f6ea85 --- /dev/null +++ b/test/evdev/test_evdev_init.py @@ -0,0 +1,185 @@ +""" Tests for the evdev __init__ module +""" +import sys +import unittest +from unittest.mock import patch +from unittest.mock import MagicMock + +# Before importing the module, the jukebox.plugs decorators need to be patched +# to not try to register the plugins +import jukebox.plugs as plugin + + +def dummy_decorator(fkt): + return fkt + + +plugin.register = dummy_decorator +plugin.initialize = dummy_decorator +plugin.atexit = dummy_decorator + +# Mock the jukebox.publishing module to prevent issues with zmq +# which is currently hard to install(see issue #2050) +# and not installed properly for CI +sys.modules['jukebox.publishing'] = MagicMock() + +# Import uses the patched decorators +from components.controls.event_devices import _input_devices_to_key_mapping # noqa: E402 +from components.controls.event_devices import parse_device_config # noqa: E402 + + +class TestInputDevicesToKeyMapping(unittest.TestCase): + def test_mapping_with_supported_input_type_and_key_code(self): + input_devices = { + 'device1': { + 'type': 'Button', + 'kwargs': { + 'key_code': 123 + }, + 'actions': { + 'on_press': 'action1' + } + }, + 'device2': { + 'type': 'Button', + 'kwargs': { + 'key_code': 456 + }, + 'actions': { + 'on_press': 'action2' + } + } + } + + expected_mapping = { + 123: 'action1', + 456: 'action2' + } + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, expected_mapping) + + def test_mapping_with_missing_type(self): + input_devices = { + 'device1': { + 'kwargs': { + 'key_code': 123 + }, + 'actions': { + 'on_press': 'action1' + } + } + } + + expected_mapping = {} + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, expected_mapping) + + def test_mapping_with_unsupported_input_type(self): + input_devices = { + 'device1': { + 'type': 'unknown', + 'kwargs': { + 'key_code': 'A' + }, + 'actions': { + 'on_press': 'action1' + } + } + } + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, {}) + + def test_mapping_with_missing_key_code(self): + input_devices = { + 'device1': { + 'type': 'button', + 'kwargs': {}, + 'actions': { + 'on_press': 'action1' + } + } + } + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, {}) + + def test_mapping_with_unsupported_action(self): + input_devices = { + 'device1': { + 'type': 'button', + 'kwargs': { + 'key_code': 'A' + }, + 'actions': { + 'unknown_action': 'action1' + } + } + } + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, {}) + + +class TestParseDeviceConfig(unittest.TestCase): + @patch('components.controls.event_devices.jukebox.utils.bind_rpc_command') + def test_parse_device_config(self, bind_rpc_command_mock): + config = { + "device_name": "Test Device", + "exact": True, + "input_devices": { + 'device1': { + 'type': 'Button', + 'kwargs': {'key_code': 123}, + 'actions': { + 'on_press': 'action1' + } + } + } + } + + device_name, exact, button_callbacks = parse_device_config(config) + self.assertEqual(device_name, "Test Device") + self.assertEqual(exact, True) + self.assertEqual(button_callbacks, { + 123: bind_rpc_command_mock.return_value, + }) + + def test_parse_device_config_missing_input_devices(self): + config = { + "device_name": "Test Device", + "exact": True + } + device_name, exact, button_callbacks = parse_device_config(config) + self.assertEqual(device_name, "Test Device") + self.assertEqual(exact, True) + self.assertEqual(button_callbacks, {}) + + def test_parse_device_config_missing_device_name(self): + config = { + "exact": True, + "input_devices": {} + } + self.assertRaises(ValueError, parse_device_config, config) + + def test_parse_device_config_missing_exact(self): + """Test that the default value for exact is False""" + config = { + "device_name": "Test Device", + "input_devices": {} + } + device_name, exact, button_callbacks = parse_device_config(config) + self.assertEqual(device_name, "Test Device") + self.assertEqual(exact, False) + self.assertEqual(button_callbacks, {}) + + +if __name__ == '__main__': + unittest.main() From c5e58d58ee1c3fc493841536b845d3f1bcfe4f5a Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sat, 16 Mar 2024 08:11:05 +0100 Subject: [PATCH 07/37] Fix search filter in library #2290 (#2292) --- src/webapp/src/components/Library/lists/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webapp/src/components/Library/lists/index.js b/src/webapp/src/components/Library/lists/index.js index 22970d9a9..e2b7a2d46 100644 --- a/src/webapp/src/components/Library/lists/index.js +++ b/src/webapp/src/components/Library/lists/index.js @@ -28,7 +28,7 @@ const LibraryLists = () => { const [cardId] = useState(searchParams.get('cardId')); const [musicFilter, setMusicFilter] = useState(''); - const handleMusicFolder = (event) => { + const handleMusicFilter = (event) => { setMusicFilter(event.target.value); }; @@ -49,7 +49,7 @@ const LibraryLists = () => { {isSelecting && } Date: Sat, 16 Mar 2024 16:50:46 +0100 Subject: [PATCH 08/37] fix: Stop Back Action at audiofolder root level (#2293) * bugfix: Stop Back Action at audiofolder root level This also allows to go back when folder is empty Fixes #2224 * fix: Flake8 issue * Remove some minor issues --- src/jukebox/jukebox/playlistgenerator.py | 9 +++++-- src/webapp/public/locales/de/translation.json | 4 +-- src/webapp/public/locales/en/translation.json | 4 +-- .../actions/play-music/selected-folder.js | 3 +-- .../Library/lists/folders/folder-list-item.js | 13 +++++---- .../Library/lists/folders/folder-list.js | 27 ++++++++++++------- .../components/Library/lists/folders/index.js | 9 ++++--- src/webapp/src/config.js | 7 ++--- 8 files changed, 43 insertions(+), 33 deletions(-) diff --git a/src/jukebox/jukebox/playlistgenerator.py b/src/jukebox/jukebox/playlistgenerator.py index b9f0223c6..e7f5b50d3 100755 --- a/src/jukebox/jukebox/playlistgenerator.py +++ b/src/jukebox/jukebox/playlistgenerator.py @@ -275,7 +275,12 @@ def get_directory_content(self, path='.'): logger.error(f" {e.__class__.__name__}: {e}") else: for m in content: - self.playlist.append({'type': TYPE_DECODE[m.filetype], 'name': m.name, 'path': m.path}) + self.playlist.append({ + 'type': TYPE_DECODE[m.filetype], + 'name': m.name, + 'path': m.path, + 'relpath': os.path.relpath(m.path, self._music_library_base_path) + }) def _parse_nonrecusive(self, path='.'): return [x.path for x in self._get_directory_content(path) if x.filetype != TYPE_DIR] @@ -294,7 +299,7 @@ def _parse_recursive(self, path='.'): return recursive_playlist def parse(self, path='.', recursive=False): - """Parse the folder ``path`` and create a playlist from it's content + """Parse the folder ``path`` and create a playlist from its content :param path: Path to folder **relative** to ``music_library_base_path`` :param recursive: Parse folder recursivley, or stay in top-level folder diff --git a/src/webapp/public/locales/de/translation.json b/src/webapp/public/locales/de/translation.json index f3bd89782..d1a4391d6 100644 --- a/src/webapp/public/locales/de/translation.json +++ b/src/webapp/public/locales/de/translation.json @@ -138,8 +138,8 @@ "title": "Wähle ein Album, einen Ordner oder einen Song aus" }, "folders": { - "no-music": "Keine Musik vorhanden!", - "empty-folder": "Dieser Ordner ist leer!", + "no-music": "☝️ Keine Musik vorhanden!", + "empty-folder": "Dieser Ordner ist leer! 🙈", "show-folder-content": "Zeige den Ordnerinhalt an", "back-button-label": "Zurück" }, diff --git a/src/webapp/public/locales/en/translation.json b/src/webapp/public/locales/en/translation.json index 348d3771d..74fd9a696 100644 --- a/src/webapp/public/locales/en/translation.json +++ b/src/webapp/public/locales/en/translation.json @@ -138,8 +138,8 @@ "title": "Select an album, folder or song" }, "folders": { - "no-music": "No music found!", - "empty-folder": "This folder is empty!", + "no-music": "☝️ No music found!", + "empty-folder": "This folder is empty! 🙈", "show-folder-content": "Show folder content", "back-button-label": "Back" }, diff --git a/src/webapp/src/components/Cards/controls/actions/play-music/selected-folder.js b/src/webapp/src/components/Cards/controls/actions/play-music/selected-folder.js index bf1976fac..3191c36cf 100644 --- a/src/webapp/src/components/Cards/controls/actions/play-music/selected-folder.js +++ b/src/webapp/src/components/Cards/controls/actions/play-music/selected-folder.js @@ -8,7 +8,6 @@ import { import NoMusicSelected from './no-music-selected'; import FolderTypeAvatar from '../../../../Library/lists/folders/folder-type-avatar'; -import { DEFAULT_AUDIO_DIR } from '../../../../../config'; const SelectedFolder = ({ values: [folder] }) => { // TODO: Implement type correctly @@ -19,7 +18,7 @@ const SelectedFolder = ({ values: [folder] }) => { - + ); diff --git a/src/webapp/src/components/Library/lists/folders/folder-list-item.js b/src/webapp/src/components/Library/lists/folders/folder-list-item.js index 755feef15..3be77fbbc 100644 --- a/src/webapp/src/components/Library/lists/folders/folder-list-item.js +++ b/src/webapp/src/components/Library/lists/folders/folder-list-item.js @@ -13,7 +13,6 @@ import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import request from '../../../../utils/request'; import FolderLink from './folder-link'; import FolderTypeAvatar from './folder-type-avatar'; -import { DEFAULT_AUDIO_DIR } from '../../../../config'; const FolderListItem = ({ folder, @@ -21,12 +20,12 @@ const FolderListItem = ({ registerMusicToCard, }) => { const { t } = useTranslation(); - const { type, name, path } = folder; + const { type, name, relpath } = folder; const playItem = () => { switch(type) { - case 'directory': return request('play_folder', { folder: path, recursive: true }); - case 'file': return request('play_single', { song_url: path.replace(`${DEFAULT_AUDIO_DIR}/`, '') }); + case 'directory': return request('play_folder', { folder: relpath, recursive: true }); + case 'file': return request('play_single', { song_url: relpath }); // TODO: Add missing Podcast // TODO: Add missing Stream default: return; @@ -35,8 +34,8 @@ const FolderListItem = ({ const registerItemToCard = () => { switch(type) { - case 'directory': return registerMusicToCard('play_folder', { folder: path, recursive: true }); - case 'file': return registerMusicToCard('play_single', { song_url: path.replace(`${DEFAULT_AUDIO_DIR}/`, '') }); + case 'directory': return registerMusicToCard('play_folder', { folder: relpath, recursive: true }); + case 'file': return registerMusicToCard('play_single', { song_url: relpath }); // TODO: Add missing Podcast // TODO: Add missing Stream default: return; @@ -50,7 +49,7 @@ const FolderListItem = ({ type === 'directory' ? diff --git a/src/webapp/src/components/Library/lists/folders/folder-list.js b/src/webapp/src/components/Library/lists/folders/folder-list.js index 36a9bcebd..3222e4234 100644 --- a/src/webapp/src/components/Library/lists/folders/folder-list.js +++ b/src/webapp/src/components/Library/lists/folders/folder-list.js @@ -1,12 +1,17 @@ import React, { memo } from 'react'; import { dropLast } from "ramda"; +import { useTranslation } from 'react-i18next'; -import { List } from '@mui/material'; +import { + List, + ListItem, + Typography, +} from '@mui/material'; import FolderListItem from './folder-list-item'; import FolderListItemBack from './folder-list-item-back'; -import { ROOT_DIRS } from '../../../../config'; +import { ROOT_DIR } from '../../../../config'; const FolderList = ({ dir, @@ -14,13 +19,14 @@ const FolderList = ({ isSelecting, registerMusicToCard, }) => { + const { t } = useTranslation(); + const getParentDir = (dir) => { - // TODO: ROOT_DIRS should be removed after paths are relative const decodedDir = decodeURIComponent(dir); - if (ROOT_DIRS.includes(decodedDir)) return undefined; + if (decodedDir === ROOT_DIR) return undefined; - const parentDir = dropLast(1, decodedDir.split('/')).join('/'); + const parentDir = dropLast(1, decodedDir.split('/')).join('/') || ROOT_DIR; return parentDir; } @@ -29,11 +35,14 @@ const FolderList = ({ return ( {parentDir && - + + } + {folders.length === 0 && + + {t('library.folders.empty-folder')} + } - {folders.map((folder, key) => + {folders.length > 0 && folders.map((folder, key) => { const { t } = useTranslation(); - const { dir = './' } = useParams(); + const { dir = ROOT_DIR } = useParams(); const [folders, setFolders] = useState([]); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -49,9 +51,8 @@ const Folders = ({ if (isLoading) return ; if (error) return {t('library.loading-error')}; - if (!filteredFolders.length) { - if (musicFilter) return {`☝️ ${t('library.folders.no-music')}`}; - return {`${t('library.folders.empty-folder')} 🙈`}; + if (musicFilter && !filteredFolders.length) { + return {t('library.folders.no-music')}; } return ( diff --git a/src/webapp/src/config.js b/src/webapp/src/config.js index 482db205e..46a6ec1df 100644 --- a/src/webapp/src/config.js +++ b/src/webapp/src/config.js @@ -17,9 +17,7 @@ const SUBSCRIPTIONS = [ 'volume.level', ]; -const DEFAULT_AUDIO_DIR = '../../shared/audiofolders'; -const ROOT_DIRS = ['./', DEFAULT_AUDIO_DIR]; - +const ROOT_DIR = './'; // TODO: The reason why thos commands are empty objects is due to a legacy // situation where titles associated with those commands were stored here @@ -83,11 +81,10 @@ const JUKEBOX_ACTIONS_MAP = { const TIMER_STEPS = [0, 2, 5, 10, 15, 20, 30, 45, 60, 120, 180, 240]; export { - DEFAULT_AUDIO_DIR, JUKEBOX_ACTIONS_MAP, PUBSUB_ENDPOINT, REQRES_ENDPOINT, - ROOT_DIRS, + ROOT_DIR, SUBSCRIPTIONS, TIMER_STEPS, } From cfd72b2f825c69f2d81247d47483c5c6b461d27f Mon Sep 17 00:00:00 2001 From: Alvin Schiller <103769832+AlvinSchiller@users.noreply.github.com> Date: Tue, 19 Mar 2024 14:03:47 +0100 Subject: [PATCH 09/37] Reactivate delete artifact (#2297) * activated delete-artifacts again with v5 * deactivate failOnError for deletion --- .../test_docker_debian_codename_sub_v3.yml | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test_docker_debian_codename_sub_v3.yml b/.github/workflows/test_docker_debian_codename_sub_v3.yml index 6deedb478..e3ef02bbe 100644 --- a/.github/workflows/test_docker_debian_codename_sub_v3.yml +++ b/.github/workflows/test_docker_debian_codename_sub_v3.yml @@ -138,7 +138,7 @@ jobs: with: name: ${{ steps.vars.outputs.image_file_name }} path: ${{ steps.vars.outputs.image_file_path }} - retention-days: 1 + retention-days: 2 # Run tests with build image @@ -177,15 +177,16 @@ jobs: args: | ./${{ matrix.test_script }} - ## cleanup after test execution - #cleanup: - # # run only if tests didn't fail: keep the artifact to make job reruns possible - # if: ${{ !failure() }} - # needs: [build, test] - # runs-on: ${{ inputs.runs_on }} - # - # steps: - # - name: Artifact Delete Docker Image - # uses: geekyeggo/delete-artifact@v4 - # with: - # name: ${{ needs.build.outputs.image_file_name }} + # cleanup after test execution + cleanup: + # run only if tests didn't fail: keep the artifact to make job reruns possible + if: ${{ !failure() }} + needs: [build, test] + runs-on: ${{ inputs.runs_on }} + + steps: + - name: Artifact Delete Docker Image + uses: geekyeggo/delete-artifact@v5 + with: + name: ${{ needs.build.outputs.image_file_name }} + failOnError: false From 2b5ef6ed2beaa6e5aa8a9cd65b42367dca4de398 Mon Sep 17 00:00:00 2001 From: Christian Hoffmann Date: Wed, 20 Mar 2024 22:01:40 +0000 Subject: [PATCH 10/37] (docs) fix gpio shutdown button naming (#2301) --- documentation/builders/gpio.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/builders/gpio.md b/documentation/builders/gpio.md index 9d496c6b7..d0d82b206 100644 --- a/documentation/builders/gpio.md +++ b/documentation/builders/gpio.md @@ -112,7 +112,7 @@ A button to shutdown the Jukebox if it is pressed for more than 3 seconds. Note ```yml input_devices: - IncreaseVolume: + Shutdown: type: LongPressButton kwargs: pin: 3 From c3b622c393267f308d17b807ea6bb4d2639f24e7 Mon Sep 17 00:00:00 2001 From: s-martin Date: Thu, 21 Mar 2024 10:49:04 +0100 Subject: [PATCH 11/37] Add markdown linting (#2284) * fix docs * add markdown action * use different action * change linter * add checkout * change globs * change wildcard * Aktualisieren von markdown_v3.yml * fix ignore * comment ignore * change version * use master * new action used for linting * Fix config * Remove ignore * Ignore docstring dir * ignore GitHub * Aktualisieren von markdown_v3.yml * use dot true * Aktualisieren von markdown_v3.yml * Use new action * Fix continue-on-error * Remove unnecessary code * Add markdownlint config * Rename . markdownlint-cli2.yaml to .markdownlint-cli2.yaml * Use config file * Simplify globs * Use globs and ignore in config file * Ignore $ errors * Ignore src and .github * Use only config file * Fix general glob * Ignore inline html * Disable MD010 * Fix language * dont warn trailing punctuation * fix warnings * fix a warning * reduce ignore * fix warnings * fail check, if markdownlint fails * add runner script and hook * Update documentation/builders/components/power/onoff-shim.md Co-authored-by: Alvin Schiller <103769832+AlvinSchiller@users.noreply.github.com> * Update playlists-livestreams-podcasts.md * add documentation for documentation with md * get rid of docker * fix comments * incorporate comments * Update documentation/developers/documentation.md Co-authored-by: Alvin Schiller <103769832+AlvinSchiller@users.noreply.github.com> * check, if cmd does not exisr --------- Co-authored-by: Alvin Schiller <103769832+AlvinSchiller@users.noreply.github.com> --- .githooks/pre-commit | 17 ++ .github/ISSUE_TEMPLATE/bug_template.md | 2 - .github/workflows/markdown_v3.yml | 28 +++ .markdownlint-cli2.yaml | 52 +++++ CODE_OF_CONDUCT.md | 14 +- CONTRIBUTING.md | 15 +- documentation/README.md | 2 +- documentation/builders/audio.md | 3 + documentation/builders/autohotspot.md | 15 +- .../builders/components/power/onoff-shim.md | 7 +- .../components/soundcards/hifiberry.md | 1 - documentation/builders/configuration.md | 5 +- documentation/builders/event-devices.md | 12 +- documentation/builders/installation.md | 21 +- documentation/builders/samba.md | 5 +- documentation/builders/troubleshooting.md | 8 +- .../webapp/playlists-livestreams-podcasts.md | 16 +- documentation/developers/README.md | 1 + documentation/developers/docker.md | 52 +++-- documentation/developers/documentation.md | 39 ++++ documentation/developers/rfid/README.md | 1 - .../developers/rfid/generic_nfcpy.md | 7 +- documentation/developers/rfid/mfrc522_spi.md | 3 +- documentation/developers/rfid/pn532_i2c.md | 12 +- .../developers/rfid/template_reader.md | 3 +- documentation/developers/webapp.md | 17 +- run_markdownlint.sh | 13 ++ src/webapp/package-lock.json | 211 ++++++++++++++++++ src/webapp/package.json | 3 + 29 files changed, 492 insertions(+), 93 deletions(-) create mode 100644 .github/workflows/markdown_v3.yml create mode 100644 .markdownlint-cli2.yaml create mode 100644 documentation/developers/documentation.md create mode 100755 run_markdownlint.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit index b1b0c0348..8ffc7de0c 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -9,6 +9,9 @@ # Note: This only checks the modified files # - docs build of if any python file is staged # Note: This builds the entire documentation if a changed file goes into the documentation +# - Markdownlint if any markdown file is staged +# Note: This checks all markdown files as configured in .markdownlint-cli2.yaml + # # If there are problem with this script, commit may still be done with # git commit --no-verify @@ -40,6 +43,20 @@ fi code=$(( flake8_code + doc_code )) +# Pass all staged markdown files through markdownlint-cli2 +MD_FILES="$(git diff --diff-filter=d --staged --name-only -- **/*.md)" +markdownlint_code=0 +if [[ -n $MD_FILES ]]; then + echo -e "\n**************************************************************" + echo "Modified Markdown files. Running markdownlint-cli2 ... " + echo -e "**************************************************************\n" + ./run_markdownlint.sh + markdownlint_code=$? + echo "Markdownlint-cli2 return code: $markdownlint_code" +fi + +code=$(( flake8_code + doc_code + markdownlint_code)) + if [[ code -gt 0 ]]; then echo -e "\n**************************************************************" echo -e "ERROR(s) during pre-commit checks. Aborting commit!" diff --git a/.github/ISSUE_TEMPLATE/bug_template.md b/.github/ISSUE_TEMPLATE/bug_template.md index 508cf50b9..afcb9bd0c 100644 --- a/.github/ISSUE_TEMPLATE/bug_template.md +++ b/.github/ISSUE_TEMPLATE/bug_template.md @@ -33,7 +33,6 @@ Please post here the output of 'tail -n 500 /var/log/syslog' or 'journalctl -u m i.e. `find logfiles at https://paste.ubuntu.com/p/cRS7qM8ZmP/` --> - ## Software ### Base image and version @@ -59,7 +58,6 @@ the following command will help with that i.e. `scripts/installscripts/buster-install-default.sh` --> - ## Hardware ### RaspberryPi version diff --git a/.github/workflows/markdown_v3.yml b/.github/workflows/markdown_v3.yml new file mode 100644 index 000000000..38284038e --- /dev/null +++ b/.github/workflows/markdown_v3.yml @@ -0,0 +1,28 @@ +name: Markdown Linting + +on: + push: + branches: + - 'future3/**' + paths: + - '**.md' + pull_request: + branches: + - 'future3/**' + paths: + - '**.md' + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Linting markdown + uses: DavidAnson/markdownlint-cli2-action@v15 + with: + config: .markdownlint-cli2.yaml + #continue-on-error: true diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 000000000..2fd1409d8 --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,52 @@ +# +# markdownlint-cli2 configuration, see https://github.com/DavidAnson/markdownlint-cli2?tab=readme-ov-file#configuration +# + +# rules, see https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md +config: + line-length: false + # ignore dollar signs + commands-show-output: false + no-trailing-punctuation: false + +# Include a custom rule package +#customRules: +# - markdownlint-rule-titlecase + +# Fix no fixable errors +fix: false + +# Define a custom front matter pattern +#frontMatter: "[^]*<\/head>" + +# Define glob expressions to use (only valid at root) +globs: + - "**.md" + +# Define glob expressions to ignore +ignores: + - "documentation/developers/docstring/*" + - "src/**" + +# Use a plugin to recognize math +#markdownItPlugins: +# - +# - "@iktakahiro/markdown-it-katex" + +# Additional paths to resolve module locations from +#modulePaths: +# - "./modules" + +# Enable inline config comments +noInlineConfig: false + +# Disable progress on stdout (only valid at root) +noProgress: true + +# Use a specific formatter (only valid at root) +#outputFormatters: +# - +# - markdownlint-cli2-formatter-default + +# Show found files on stdout (only valid at root) +showFound: true diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 332baee88..a28c343f0 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,14 +1,14 @@ +# Contributor Covenant Code of Conduct -Dear Phonieboxians, - -As the Phoniebox community is growing, somebody suggested a pull request with the below document. I was hesitant to include it right away, but at the same time I thought: it might be good to have some kind of document to formulate the foundation this project is built on. To tell you the truth, this document is not it. However, it is a start and I thought: why not open this in the spirit of open source, sharing and pull requests and see if and how you or you or you want to change or add parts of this very *standard and corporate* document. Like most of you, I also have a small kid and my time is scarce, I might find some time though to add a bit. - -All the best, Micz +> [!NOTE] +> Dear Phonieboxians, +> +> As the Phoniebox community is growing, somebody suggested a pull request with the below document. I was hesitant to include it right away, but at the same time I thought: it might be good to have some kind of document to formulate the foundation this project is built on. To tell you the truth, this document is not it. However, it is a start and I thought: why not open this in the spirit of open source, sharing and pull requests and see if and how you or you or you want to change or add parts of this very *standard and corporate* document. Like most of you, I also have a small kid and my time is scarce, I might find some time though to add a bit. +> +> All the best, Micz 2018-08-21 -# Contributor Covenant Code of Conduct - This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] ## Our Pledge diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4ac9cd16..feb47f2c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,15 +45,16 @@ as local, temporary scratch areas. Contributors have played a bigger role over time to keep Phoniebox on the edge of innovation :) -Our goal is to make it simple for you to contribute changes that improve functionality in your specific environment. -To achieve this, we have a set of guidelines that we kindly request contributors to adhere to. +Our goal is to make it simple for you to contribute changes that improve functionality in your specific environment. +To achieve this, we have a set of guidelines that we kindly request contributors to adhere to. These guidelines help us maintain a streamlined process and stay on top of incoming contributions. To report bug fixes and improvements, please follow the steps outlined below: + 1. For bug fixes and minor improvements, simply open a new issue or pull request (PR). 2. If you intend to port a feature from Version 2.x to future3 or wish to implement a new feature, we recommend reaching out to us beforehand. - - In such cases, please create an issue outlining your plans and intentions. - - We will ensure that there are no ongoing efforts on the same topic. + * In such cases, please create an issue outlining your plans and intentions. + * We will ensure that there are no ongoing efforts on the same topic. We eagerly await your contributions! You can review the current [feature list](documentation/developers/status.md) to check for available features and ongoing work. @@ -108,7 +109,7 @@ Run the checks below on the code. Fix those issues! Or you are running in delays We provide git hooks for those checks for convenience. To activate ~~~bash -cp .githooks/pre-commit` .git/hooks/. +cp .githooks/pre-commit .git/hooks/. ~~~ ### Python Code @@ -152,7 +153,7 @@ to detect in advance. If the code change results in a test failure, we will make our best effort to correct the error. If a fix cannot be determined and committed within 24 hours -of its discovery, the commit(s) responsible _may_ be reverted, at the +of its discovery, the commit(s) responsible *may* be reverted, at the discretion of the committer and Phonie maintainers. The original contributor will be notified of the revert. @@ -163,7 +164,7 @@ The original contributor will be notified of the revert. ## Guidelines -* Phoniebox runs on Raspberry Pi OS. +* Phoniebox runs on Raspberry Pi OS. * Minimum python version is currently **Python 3.9**. ## Additional Resources diff --git a/documentation/README.md b/documentation/README.md index bb11dd6f4..cbfb6276a 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -42,7 +42,7 @@ project check out the [documentation of Version 2](https://github.com/MiczFlor/R Version 3 has reached a mature state and will soon be the default version. However, some features may still be missing. Please check the [Feature Status](./developers/status.md), if YOUR feature is already implemented. -> [!NOTE] +> [!NOTE] > If version 3 has all the features you need, we recommend using Version 3. If there is a feature missing, please open an issue. diff --git a/documentation/builders/audio.md b/documentation/builders/audio.md index 93c46dd54..765c6a08e 100644 --- a/documentation/builders/audio.md +++ b/documentation/builders/audio.md @@ -24,6 +24,7 @@ to setup the configuration for the Jukebox Core App. Run the following steps in a console: + ```bash # Check available PulseAudio sinks $ pactl list sinks short @@ -45,6 +46,7 @@ $ paplay /usr/share/sounds/alsa/Front_Center.wav # This must also work when using an ALSA device $ aplay /usr/share/sounds/alsa/Front_Center.wav ``` + You can also try different PulseAudio sinks without setting the default sink. In this case the volume is the last used volume level for this sink: @@ -86,6 +88,7 @@ Pairing successful .... [PowerLocus Buddy]# exit ``` + If `bluetoothctl` has trouble to execute due to permission issue, try `sudo bluetoothctl`. Wait for a few seconds and then with `$ pactl list sinks short`, check wether the Bluetooth device shows up as an output. diff --git a/documentation/builders/autohotspot.md b/documentation/builders/autohotspot.md index 5a62a37ca..8efde605a 100644 --- a/documentation/builders/autohotspot.md +++ b/documentation/builders/autohotspot.md @@ -7,11 +7,12 @@ The Auto-Hotspot function enables the Jukebox to switch its connection between a ## How to connect -When the Jukebox cannot connect to a known WiFi, it will automatically create a hotspot. +When the Jukebox cannot connect to a known WiFi, it will automatically create a hotspot. You can connect to this hotspot using the password set during installation. Afterwards, you can access the Web App or connect via SSH as before, using the IP from the configuration. The default configuration is + ``` text * SSID : Phoniebox_Hotspot_ * Password : PlayItLoud! @@ -23,8 +24,7 @@ The default configuration is Auto-Hotspot can be enabled or disabled using the Web App or RPC Commands. -> [!NOTE] -> Disabling the Auto-Hotspot will run the WiFi check again and maintain the last connection state until reboot. +Disabling the Auto-Hotspot will run the WiFi check again and maintain the last connection state until reboot. > [!IMPORTANT] > If you disable this feature, you will lose access to the Jukebox if you are not near a known WiFi after reboot! @@ -34,11 +34,13 @@ Auto-Hotspot can be enabled or disabled using the Web App or RPC Commands. ### AutoHotspot functionality is not working Check the `autohotspot.service` status + ``` bash sudo systemctl status autohotspot.service ``` and logs + ``` bash sudo journalctl -u autohotspot.service -n 50 ``` @@ -52,12 +54,13 @@ Check your WiFi configuration. ### You need to add a new WiFi network to the Raspberry Pi #### Using the command line + Connect to the hotspot and open a terminal. Use the [raspi-config](https://www.raspberrypi.com/documentation/computers/configuration.html#wireless-lan) tool to add the new WiFi. ## Resources * [Raspberry Connect - Auto WiFi Hotspot Switch](https://www.raspberryconnect.com/projects/65-raspberrypi-hotspot-accesspoints/158-raspberry-pi-auto-wifi-hotspot-switch-direct-connection) * [Raspberry Pi - Configuring networking](https://www.raspberrypi.com/documentation/computers/configuration.html#using-the-command-line) -* [dhcpcd / wpa_supplicant]() - * [hostapd](http://w1.fi/hostapd/) - * [dnsmasq](https://thekelleys.org.uk/dnsmasq/doc.html) +* dhcpcd / wpa_supplicant + * [hostapd](http://w1.fi/hostapd/) + * [dnsmasq](https://thekelleys.org.uk/dnsmasq/doc.html) diff --git a/documentation/builders/components/power/onoff-shim.md b/documentation/builders/components/power/onoff-shim.md index b83ea6140..e9a2849c9 100644 --- a/documentation/builders/components/power/onoff-shim.md +++ b/documentation/builders/components/power/onoff-shim.md @@ -9,7 +9,7 @@ To install the software, open a terminal and type the following command to run t > [!NOTE] > The installation will ask you a few questions. You can safely answer with the default response. -``` +```bash curl https://get.pimoroni.com/onoffshim | bash ``` @@ -28,9 +28,8 @@ The OnOff SHIM comes with a 12-PIN header which needs soldering. If you want to | GPLCLK0 | 7 | 7 | GPIO4 | | GPIO17 | 11 | 11 | GPIO17 | -* More information can be found here: https://pinout.xyz/pinout/onoff_shim +* More information can be found here: ## Assembly options -![](https://cdn.review-images.pimoroni.com/upload-b6276a310ccfbeae93a2d13ec19ab83b-1617096824.jpg?width=640) - +![OnOffShim soldered on a Raspberry Pi](https://cdn.review-images.pimoroni.com/upload-b6276a310ccfbeae93a2d13ec19ab83b-1617096824.jpg?width=640) diff --git a/documentation/builders/components/soundcards/hifiberry.md b/documentation/builders/components/soundcards/hifiberry.md index 1f19fa96d..f663abb42 100644 --- a/documentation/builders/components/soundcards/hifiberry.md +++ b/documentation/builders/components/soundcards/hifiberry.md @@ -19,7 +19,6 @@ If you know you HifiBerry Board identifier, you can run the script as a 1-liner If you like to disable your HiFiberry Sound card and enable onboard sound, run the following command - ```bash ./setup_hifiberry.sh disable ``` diff --git a/documentation/builders/configuration.md b/documentation/builders/configuration.md index dd4c1fd53..c75f53683 100644 --- a/documentation/builders/configuration.md +++ b/documentation/builders/configuration.md @@ -27,9 +27,10 @@ $ ./run_jukebox.sh # Restart the service $ systemctl --user start jukebox-daemon ``` -To try different configurations, you can start the Jukebox with a custom config file. + +To try different configurations, you can start the Jukebox with a custom config file. This could be useful if you want your Jukebox to only allow a lower volume when started -at nighttime, signaling it's time to go to bed. :-) +at nighttime, signaling it's time to go to bed. :-) The path to the custom config file must be either absolute or relative to the folder `src/jukebox/`. ```bash diff --git a/documentation/builders/event-devices.md b/documentation/builders/event-devices.md index 8fe5d08e0..95e871430 100644 --- a/documentation/builders/event-devices.md +++ b/documentation/builders/event-devices.md @@ -1,6 +1,7 @@ # Event devices ## Background + Event devices are generic input devices that are exposed in `/dev/input`. This includes USB peripherals (Keyboards, Controllers, Joysticks or Mouse) as well as potentially bluetooth devices. @@ -23,6 +24,7 @@ modules: ``` And add the following section with the plugin specific configuration: + ``` yaml evdev: enabled: true @@ -49,6 +51,7 @@ devices: # list of devices to listen for on_press: # Currently only the on_press action is supported {rpc_command_definition} # eg `alias: toggle` ``` + The `{device nickname}` is only for your own orientation and can be choosen freely. For each device you need to figure out the `{device_name}` and the `{event_id}` corresponding to key strokes, as indicated in the sections below. @@ -65,7 +68,7 @@ for device in devices: The output could be in the style of: -``` +```text /dev/input/event1 Dell Dell USB Keyboard usb-0000:00:12.1-2/input0 /dev/input/event0 Dell USB Optical Mouse usb-0000:00:12.0-2/input0 ``` @@ -96,8 +99,10 @@ for event in dev.read_loop(): if event.type == ecodes.EV_KEY: print(categorize(event)) ``` + The output could be of the form: -``` + +```text device /dev/input/event1, name "DragonRise Inc. Generic USB Joystick ", phys "usb-3f980000.usb-1.2/input0" key event at 1672569673.124168, 297 (BTN_BASE4), down key event at 1672569673.385170, 297 (BTN_BASE4), up @@ -114,7 +119,6 @@ Look for entries like `No callback registered for button ...`. The RPC command follows the regular RPC command rules as defined in the [following documentation](./rpc-commands.md). - ## Full example config -A complete configuration example for a USB Joystick controller can be found in the [examples](../../resources/default-settings/evdev.example.yaml). \ No newline at end of file +A complete configuration example for a USB Joystick controller can be found in the [examples](../../resources/default-settings/evdev.example.yaml). diff --git a/documentation/builders/installation.md b/documentation/builders/installation.md index 7cd321b7a..e12514350 100644 --- a/documentation/builders/installation.md +++ b/documentation/builders/installation.md @@ -3,7 +3,7 @@ ## Install Raspberry Pi OS Lite > [!IMPORTANT] -> All Raspberry Pi models are supported. For sufficient performance, **we recommend Pi 2, 3 or Zero 2** (`ARMv7` models). Because Pi 1 or Zero 1 (`ARMv6` models) have limited resources, they are slower (during installation and start up procedure) and might require a bit more work! Pi 4 and 5 are an excess ;-) +> All Raspberry Pi models are supported. For sufficient performance, **we recommend Pi 2, 3 or Zero 2** (`ARMv7` models). Because Pi 1 or Zero 1 (`ARMv6` models) have limited resources, they are slower (during installation and start up procedure) and might require a bit more work! Pi 4 and 5 are an excess ;-) Before you can install the Phoniebox software, you need to prepare your Raspberry Pi. @@ -27,8 +27,9 @@ Before you can install the Phoniebox software, you need to prepare your Raspberr 8. Confirm the next warning about erasing the SD card with `Yes` 9. Wait for the imaging process to be finished (it'll take a few minutes) - ### Pre-boot preparation + +
In case you forgot to customize the OS settings, follow these instructions after RPi OS has been written to the SD card. @@ -81,8 +82,9 @@ You will need a terminal, like PuTTY for Windows or the Terminal app for Mac to ### Pre-install preparation / workarounds #### Network management since Bookworm +
-With Bookworm, network management has changed. Now, "NetworkManager" is used instead of "dhcpcd". +With Bookworm, network management has changed. Now, "NetworkManager" is used instead of "dhcpcd". Both methods are supported during installation, but "NetworkManager" is recommended as it is simpler to set up and use. For Bullseye, this can also be activated, though it requires a manual process before running the installation. @@ -91,32 +93,38 @@ If the settings are changed, your network will reset, and WiFi will not be confi Therefore, make sure you use a wired connection or perform the following steps in a local terminal with a connected monitor and keyboard. Change network config + * run `sudo raspi-config` * select `6 - Advanced Options` * select `AA - Network Config` * select `NetworkManager` If you need Wifi, add the information now + * select `1 - System Options` * select `1 - Wireless LAN` * enter Wifi information +
#### Workaround for 64-bit Kernels (Pi 4 and newer) +
The installation process checks if a 32-bit OS is running, as 64-bit is currently not supported. This check also fails if the kernel is running in 64-bit mode. This is the default for Raspberry Pi models 4 and newer. -To be able to run the installation, you have to switch to the 32-bit mode by modifying the `config.txt` and add/change the line `arm_64bit=0`. +To be able to run the installation, you have to switch to the 32-bit mode by modifying the `config.txt` and add/change the line `arm_64bit=0`. Up to Bullseye, the `config.txt` file is located at `/boot/`. Since Bookworm, the location changed to `/boot/firmware/` ([see here](https://www.raspberrypi.com/documentation/computers/config_txt.html)). Reboot before you proceed.
+ ## Install Phoniebox software Choose a version, run the corresponding install command in your SSH terminal and follow the instructions. + * [Stable Release](#stable-release) * [Pre-Release](#pre-release) * [Development](#development) @@ -127,6 +135,7 @@ After a successful installation, [configure your Phoniebox](configuration.md). > Depending on your hardware, this installation might last around 60 minutes (usually it's faster, 20-30 min). It updates OS packages, installs Phoniebox dependencies and applies settings. Be patient and don't let your computer go to sleep. It might disconnect your SSH connection causing the interruption of the installation process. Consider starting the installation in a terminal multiplexer like 'screen' or 'tmux' to avoid this. ### Stable Release + This will install the latest **stable release** from the *future3/main* branch. ```bash @@ -134,6 +143,7 @@ cd; bash <(wget -qO- https://raw.githubusercontent.com/MiczFlor/RPi-Jukebox-RFID ``` ### Pre-Release + This will install the latest **pre-release** from the *future3/develop* branch. ```bash @@ -141,6 +151,7 @@ cd; GIT_BRANCH='future3/develop' bash <(wget -qO- https://raw.githubusercontent. ``` ### Development + You can also install a specific branch and/or a fork repository. Update the variables to refer to your desired location. (The URL must not necessarily be updated, unless you have actually updated the file being downloaded.) > [!IMPORTANT] @@ -155,9 +166,9 @@ cd; GIT_USER='MiczFlor' GIT_BRANCH='future3/develop' bash <(wget -qO- https://ra > If you install another branch or from a fork repository, the Web App needs to be built locally. This is part of the installation process. See the the developers [Web App](../developers/webapp.md) documentation for further details. ### Logs + To follow the installation closely, use this command in another terminal. ```bash cd; tail -f INSTALL-.log ``` - diff --git a/documentation/builders/samba.md b/documentation/builders/samba.md index ac9a93bbc..8e486a181 100644 --- a/documentation/builders/samba.md +++ b/documentation/builders/samba.md @@ -4,15 +4,14 @@ To conveniently copy files to your Phoniebox via network `samba` can be configur ## Connect -To access the share open your OS network environment and select your Phoniebox device. +To access the share open your OS network environment and select your Phoniebox device. Alternatively directly access it via url with the file explorer (e.g. Windows `\\`, MacOS `smb://`). See also + * [MacOS](https://support.apple.com/lt-lt/guide/mac-help/mchlp1140/mac) ## User name / Password As login credentials use the same username you used to run the installation with. The password is `raspberry`. You can change the password anytime using the command `sudo smbpasswd -a ""`. - - diff --git a/documentation/builders/troubleshooting.md b/documentation/builders/troubleshooting.md index 5b4061aa8..a18272afb 100644 --- a/documentation/builders/troubleshooting.md +++ b/documentation/builders/troubleshooting.md @@ -47,10 +47,10 @@ The default logging config does 2 things: 1. It writes 2 log files: -```bash -shared/logs/app.log : Complete Debug Messages -shared/logs/errors.log : Only Errors and Warnings -``` + ```bash + shared/logs/app.log : Complete Debug Messages + shared/logs/errors.log : Only Errors and Warnings + ``` 2. Prints logging messages to the console. If run as a service, only error messages are emitted to console to avoid spamming the system log files. diff --git a/documentation/builders/webapp/playlists-livestreams-podcasts.md b/documentation/builders/webapp/playlists-livestreams-podcasts.md index 2b8be79a1..f45969e6e 100644 --- a/documentation/builders/webapp/playlists-livestreams-podcasts.md +++ b/documentation/builders/webapp/playlists-livestreams-podcasts.md @@ -3,11 +3,13 @@ By default, the Jukebox represents music based on its metadata like album name, artist or song name. The hierarchy and order of songs is determined by their original definition, e.g. order of songs within an album. If you prefer a specific list of songs to be played, you can use playlists (files ending with `*.m3u`). Jukebox also supports livestreams and podcasts (if connected to the internet) through playlists. ## Playlists + If you like the Jukebox to play songs in a pre-defined order, you can use .m3u playlists. A .m3u playlist is a plain text file that contains a list of file paths or URLs to multimedia files. Each entry in the playlist represents a single song, and they are listed in the order in which they should be played. ### Structure of a .m3u playlist + A .m3u playlist is a simple text document with each song file listed on a separate line. Each entry is optionally preceded by a comment line that starts with a '#' symbol. The actual file paths or URLs of the media files come after the comment. ### Creating a .m3u playlist @@ -18,7 +20,7 @@ A .m3u playlist is a simple text document with each song file listed on a separa 1. On the following lines, list the file paths or URLs of the media files you want to include in the playlist, one per line. They must refer to true files paths on your Jukebox. They can be relative or absolute paths. 1. Save the file with the .m3u extension, e.g. `my_playlist.m3u`. -``` +```text # Absolute /home//RPi-Jukebox-RFID/shared/audiofolders/Simone Sommerland/Die 30 besten Kindergartenlieder/08 - Pitsch, patsch, Pinguin.mp3 /home//RPi-Jukebox-RFID/shared/audiofolders/Simone Sommerland/Die 30 besten Spiel- Und Bewegungslieder/12 - Das rote Pferd.mp3 @@ -42,7 +44,7 @@ Based on the note above, we suggest to use m3u playlists like this, especially i #### Example folder structure -``` +```text └── audiofolders ├── wake-up-songs │ └── playlist.m3u @@ -74,9 +76,9 @@ In order to play radio livestreams on your Jukebox, you use playlists to registe You can now assign livestreams to cards [following the example](#assigning-a-m3u-playlist-to-a-card) of playlists. -#### Example folder structure and playlist names +#### Example folder structure and playlist names for livestreams -``` +```text └── audiofolders ├── wdr-kids │ └── wdr-kids-livestream.txt @@ -108,13 +110,13 @@ We will explain options 1 and 2 more closely. ### Using podcast.txt playlist in Jukebox 1. [Follow the steps above](#using-m3u-playlists-in-jukebox) to add a playlist to your Jukebox (make sure you have created individual folders). -1. When creating the playlist file, make sure it's called or at least ends with `podcasts.txt` instead of `.m3u`. (Examples: `awesome-podcast.txt`, `podcast.txt`). +1. When creating the playlist file, make sure it's called or at least ends with `podcast.txt` instead of `.m3u`. (Examples: `awesome-podcast.txt`, `podcast.txt`). 1. Add links to your individual podcast episodes just like you would with songs in .m3u playlists 1. As an alternative, you can provide a single RSS feed (XML). Jukebox will expand the file and refer to all episodes listed within this file. -#### Example folder structure and playlist names +#### Example folder structure and playlist names for podcasts -``` +```text └── audiofolders ├── die-maus │ └── die-maus-podcast.txt diff --git a/documentation/developers/README.md b/documentation/developers/README.md index 6addeaff1..ff21a8ceb 100644 --- a/documentation/developers/README.md +++ b/documentation/developers/README.md @@ -4,6 +4,7 @@ * [Development Environment](./development-environment.md) * [Python Development Notes](python.md) +* [Documentation (with Markdown)](documentatíon.md) ## Reference diff --git a/documentation/developers/docker.md b/documentation/developers/docker.md index 19a57e414..9b4db4697 100644 --- a/documentation/developers/docker.md +++ b/documentation/developers/docker.md @@ -49,17 +49,23 @@ They can be run individually or in combination. To do that, we use 1. [Install Docker & Compose (Mac)](https://docs.docker.com/docker-for-mac/install/) 2. Install pulseaudio 1. Use Homebrew to install - ``` - $ brew install pulseaudio - ``` - 2. Enable pulseaudio network capabilities. In an editor, open `/opt/homebrew/Cellar/pulseaudio/16.1/etc/pulse/default.pa` (you might need to adapt this path to your own system settings). Uncomment the following line. - ``` - load-module module-native-protocol-tcp - ``` + + ```bash + $ brew install pulseaudio + ``` + + 2. Enable pulseaudio network capabilities. In an editor, open `/opt/homebrew/Cellar/pulseaudio/16.1/etc/pulse/default.pa` (you might need to adapt this path to your own system settings). Uncomment the following line: + + ```text + load-module module-native-protocol-tcp + ``` + 3. Restart the pulseaudio service - ``` - $ brew services restart pulseaudio - ``` + + ```bash + $ brew services restart pulseaudio + ``` + 4. If you have trouble with your audio, try these resources to troubleshoot: [[1]](https://gist.github.com/seongyongkim/b7d630a03e74c7ab1c6b53473b592712), [[2]](https://devops.datenkollektiv.de/running-a-docker-soundbox-on-mac.html), [[3]](https://stackoverflow.com/a/50939994/1062438) > [!NOTE] @@ -189,7 +195,7 @@ details. If you notice the following exception while running MPD in Docker, it refers to a incorrect setup of your Mac host Pulseaudio. -``` +```text mpd | ALSA lib pulse.c:242:(pulse_connect) PulseAudio: Unable to connect: Connection refused mpd | exception: Failed to read mixer for 'Global ALSA->Pulse stream': failed to attach to pulse: Connection refused ``` @@ -197,15 +203,20 @@ mpd | exception: Failed to read mixer for 'Global ALSA->Pulse stream': fail To fix the issue, try the following. 1. Stop your Pulseaudio service - ``` + + ```bash brew service stop pulseaudio ``` + 2. Start Pulseaudio with this command - ``` + + ```bash pulseaudio --load=module-native-protocol-tcp --exit-idle-time=-1 --daemon ``` + 3. Check if daemon is working - ``` + + ```bash pulseaudio --check -v ``` @@ -213,8 +224,6 @@ Everything else should have been set up properly as a [prerequisite](#mac) * [Source](https://gist.github.com/seongyongkim/b7d630a03e74c7ab1c6b53473b592712) - - #### Other error messages When starting the `mpd` container, you will see the following errors. @@ -265,12 +274,11 @@ jukebox | 319:server.py - jb.pub.server - host.timer.cputemp If you encounter the following error, refer to [Pulseaudio issues on Mac](#pulseaudio-issue-on-mac). -``` +```text jukebox | 21.12.2023 08:50:09 - 629:plugs.py - jb.plugin - MainThread - ERROR - Ignoring failed package load finalizer: 'volume.finalize()' jukebox | 21.12.2023 08:50:09 - 630:plugs.py - jb.plugin - MainThread - ERROR - Reason: NameError: name 'pulse_control' is not defined ``` - ## Appendix ### Individual Docker Image @@ -291,10 +299,10 @@ $ docker run -it --rm \ --name jukebox jukebox ``` -## Testing EVDEV devices in Linux +## Testing ``evdev`` devices in Linux + To test the [event device capabilities](../builders/event-devices.md) in docker, the device needs to be made available to the container. -### Linux Mount the device into the container by configuring the appropriate device in a `devices` section of the `jukebox` service in the docker compose file. For example: ```yaml @@ -304,9 +312,9 @@ Mount the device into the container by configuring the appropriate device in a ` - /dev/input/event3:/dev/input/event3 ``` - ### Resources + #### Mac * @@ -320,6 +328,8 @@ Mount the device into the container by configuring the appropriate device in a ` * * + + #### Audio * diff --git a/documentation/developers/documentation.md b/documentation/developers/documentation.md new file mode 100644 index 000000000..51cd8cda2 --- /dev/null +++ b/documentation/developers/documentation.md @@ -0,0 +1,39 @@ +# Documentation with Markdown + +We use markdown for documentation. Please add/update documentation in `documentation`. + +## Linting + +To ensure a consistent documentation we lint markdown files. + +We use [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) for linting. + +`.markdownlint-cli2.yaml` configures linting consistently for the Github Action, the pre-commit hook, manual linting and the [markdownlint extension](https://github.com/DavidAnson/vscode-markdownlint) for Visual Studio Code. + +You can start a manual check, if you call `run_markdownlint.sh`. + +If markdown files are changed and the pre-commit hook is enabled, `run_markdownlint.sh` is triggered on commits. + +After creating a PR or pushing to the repo a Github Action triggers the linter, if markdown files are changed (see `.github/workflows/markdown_v3.yml`). + +### Disabling Rules + +> [!NOTE] +> Please use disabling rules with caution and always try to fix the violation first. + +A few rules are globally disabled in `.markdownlint-cli2.yaml` (see section `config`). + +If you want to disable a rule for a specific section of a markdown file you can use + +```markdown + +section where MD010 should be ignored + +``` + +### References + +* +* Rules: + * + * diff --git a/documentation/developers/rfid/README.md b/documentation/developers/rfid/README.md index 0b2df4db3..f0db4fd6a 100644 --- a/documentation/developers/rfid/README.md +++ b/documentation/developers/rfid/README.md @@ -11,4 +11,3 @@ * [Generic Readers without HID (NFCpy)](generic_nfcpy.md) * [Mock Reader](mock_reader.md) * [Template Reader](template_reader.md) - diff --git a/documentation/developers/rfid/generic_nfcpy.md b/documentation/developers/rfid/generic_nfcpy.md index 76de98d88..27c6ececb 100644 --- a/documentation/developers/rfid/generic_nfcpy.md +++ b/documentation/developers/rfid/generic_nfcpy.md @@ -11,13 +11,14 @@ driver, and thus cannot be used with the [genericusb](genericusb.md) module. Als > The setup will do this automatically, so make sure the device is connected > before running the [RFID reader configuration tool](../coreapps.md#RFID-Reader). -# Configuration +## Configuration -The installation script will scan for compatible devices and will assist in configuration. +The installation script will scan for compatible devices and will assist in configuration. By setting `rfid > readers > generic_nfcpy > config > device_path` in `shared/settings/rfid.yaml` you can override the device location. By specifying an explicit device location it is possible to use multiple readers compatible with NFCpy. Example configuration for a usb-device with vendor ID 072f and product ID 2200: + ```yaml rfid: readers: @@ -33,4 +34,4 @@ rfid: alias: pause ``` -For possible values see the `path` parameter in this [nfcpy documentation](https://nfcpy.readthedocs.io/en/latest/modules/clf.html#nfc.clf.ContactlessFrontend.open) \ No newline at end of file +For possible values see the `path` parameter in this [nfcpy documentation](https://nfcpy.readthedocs.io/en/latest/modules/clf.html#nfc.clf.ContactlessFrontend.open) diff --git a/documentation/developers/rfid/mfrc522_spi.md b/documentation/developers/rfid/mfrc522_spi.md index 8a04f729e..363df3836 100644 --- a/documentation/developers/rfid/mfrc522_spi.md +++ b/documentation/developers/rfid/mfrc522_spi.md @@ -57,7 +57,8 @@ If true all card read-outs will be logged, even when card is permanently on read The following pin-out is for the default SPI Bus 0 on Raspberry Pins. -*MFRC522 default wiring (spi_bus=0, spi_ce=0)* +### MFRC522 default wiring (spi_bus=0, spi_ce=0) + |Pin Board Name |Function |RPI GPIO |RPI Pin | |----------------|----------|----------|---------| |SDA |CE |GPIO8 |24 | diff --git a/documentation/developers/rfid/pn532_i2c.md b/documentation/developers/rfid/pn532_i2c.md index d60cb2e54..33b7e3f51 100644 --- a/documentation/developers/rfid/pn532_i2c.md +++ b/documentation/developers/rfid/pn532_i2c.md @@ -29,7 +29,7 @@ You can usually pick up a board at ## Board Connections -*Default wiring* +### Default wiring | PN532 | RPI GPIO | RPI Pin | |-------|--------------|---------| @@ -45,9 +45,9 @@ PI's own voltage regulator. ## Jumpers -*Jumper settings for I2C protocol* +### Jumper settings for I2C protocol -Jumper | Position --------|---------- -SEL0 | ON -SEL1 | OFF +| Jumper | Position | +|--------|----------| +|SEL0 | ON | +|SEL1 | OFF | diff --git a/documentation/developers/rfid/template_reader.md b/documentation/developers/rfid/template_reader.md index 5b8458691..e02f56c69 100644 --- a/documentation/developers/rfid/template_reader.md +++ b/documentation/developers/rfid/template_reader.md @@ -1,9 +1,8 @@ # Template Reader -*Template for creating and integrating a new RFID Reader* - > [!NOTE] +> Template for creating and integrating a new RFID Reader. > For developers only This template provides the skeleton API for a new Reader. If you follow diff --git a/documentation/developers/webapp.md b/documentation/developers/webapp.md index 2e8504337..0406566fa 100644 --- a/documentation/developers/webapp.md +++ b/documentation/developers/webapp.md @@ -19,7 +19,7 @@ sudo apt-get -y update && sudo apt-get -y install nodejs The Web App is a React application based on [Create React App](https://create-react-app.dev/). To start a development server, run the following command: -``` +```bash cd ~/RPi-Jukebox-RFID/src/webapp npm install # Just the first time or when dependencies change npm start @@ -37,12 +37,14 @@ cd ~/RPi-Jukebox-RFID/src/webapp; \ After a successfull build you might need to restart the web server. -``` +```bash sudo systemctl restart nginx.service ``` ## Known Issues while building + + ### JavaScript heap out of memory While (re-) building the Web App, you get the following output: @@ -71,6 +73,7 @@ Use the [provided script](#build-the-web-app) to rebuild the Web App. It sets th If you need to run the commands manually, make sure to have enough memory available (min. 512 MB). The following commands might help. Set the swapsize to 512 MB (and deactivate swapfactor). Adapt accordingly if you have a SD Card with small capacity. + ```bash sudo dphys-swapfile swapoff sudo sed -i "s|.*CONF_SWAPSIZE=.*|CONF_SWAPSIZE=512|g" /etc/dphys-swapfile @@ -80,6 +83,7 @@ sudo dphys-swapfile swapon ``` Set Node's maximum amount of memory. Memory must be available. + ``` bash export NODE_OPTIONS=--max-old-space-size=512 npm run build @@ -105,7 +109,6 @@ Node tried to allocate more memory than available on the system. See [JavaScript heap out of memory](#javascript-heap-out-of-memory) - ### Client network socket disconnected ``` {.bash emphasize-lines="8,9"} @@ -122,12 +125,12 @@ npm ERR! network 'proxy' config is set properly. See: 'npm help config' #### Reason -The network connection is too slow or has issues. -This tends to happen on `armv6l` devices where building takes significantly more time due to limited resources. +The network connection is too slow or has issues. +This tends to happen on `armv6l` devices where building takes significantly more time due to limited resources. #### Solution -Try to use an ethernet connection. A reboot and/or running the script multiple times might also help ([Build produces EOF errors](#build-produces-eof-errors) might occur). +Try to use an ethernet connection. A reboot and/or running the script multiple times might also help ([Build produces EOF errors](#build-produces-eof-errors) might occur). If the error still persists, try to raise the timeout for npm package resolution. @@ -144,6 +147,8 @@ A previous run failed during installation and left a package corrupted. #### Solution Remove the mode packages and rerun again the script. + ``` {.bash emphasize-lines="8,9"} rm -rf node_modules ``` + diff --git a/run_markdownlint.sh b/run_markdownlint.sh new file mode 100755 index 000000000..0f890590e --- /dev/null +++ b/run_markdownlint.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# Runner script to ensure +# - independent from working directory + +# Change working directory to project root +SOURCE=${BASH_SOURCE[0]} +SCRIPT_DIR="$(dirname "$SOURCE")" +PROJECT_ROOT="$SCRIPT_DIR" +cd "$PROJECT_ROOT" || { echo "Could not change directory"; exit 1; } + +# Run markdownlint-cli2 +./src/webapp/node_modules/.bin/markdownlint-cli2 --config .markdownlint-cli2.yaml "#node_modules" || { echo "ERROR: markdownlint-cli2 not found"; exit 1; } diff --git a/src/webapp/package-lock.json b/src/webapp/package-lock.json index 814090555..218e67ab7 100644 --- a/src/webapp/package-lock.json +++ b/src/webapp/package-lock.json @@ -28,6 +28,9 @@ "react-scripts": "^5.0.1", "url": "^0.11.3", "uuid": "^9.0.1" + }, + "devDependencies": { + "markdownlint-cli2": "^0.12.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -4042,6 +4045,18 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" }, + "node_modules/@sindresorhus/merge-streams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-1.0.0.tgz", + "integrity": "sha512-rUV5WyJrJLoloD4NDN1V1+LDMDWOa4OTsT4yYJwQNpTU6FWxkxHpL7eu4w+DmiH8x/EAM1otkPE1+LaspIbplw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "1.8.6", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", @@ -12284,6 +12299,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -12525,6 +12546,15 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -12663,11 +12693,165 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.0.0.tgz", + "integrity": "sha512-seFjF0FIcPt4P9U39Bq1JYblX0KZCjDLFFQPHpL5AzHpqPEKtosxmdq/LTVZnjfH7tjt9BxStm+wXcDBNuYmzw==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/markdownlint": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.33.0.tgz", + "integrity": "sha512-4lbtT14A3m0LPX1WS/3d1m7Blg+ZwiLq36WvjQqFGsX3Gik99NV+VXp/PW3n+Q62xyPdbvGOCfjPqjW+/SKMig==", + "dev": true, + "dependencies": { + "markdown-it": "14.0.0", + "markdownlint-micromark": "0.1.8" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.12.1.tgz", + "integrity": "sha512-RcK+l5FjJEyrU3REhrThiEUXNK89dLYNJCYbvOUKypxqIGfkcgpz8g08EKqhrmUbYfYoLC5nEYQy53NhJSEtfQ==", + "dev": true, + "dependencies": { + "globby": "14.0.0", + "jsonc-parser": "3.2.0", + "markdownlint": "0.33.0", + "markdownlint-cli2-formatter-default": "0.0.4", + "micromatch": "4.0.5", + "yaml": "2.3.4" + }, + "bin": { + "markdownlint-cli2": "markdownlint-cli2.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2-formatter-default": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.4.tgz", + "integrity": "sha512-xm2rM0E+sWgjpPn1EesPXx5hIyrN2ddUnUwnbCsD/ONxYtw3PX6LydvdH6dciWAoFDpwzbHM1TO7uHfcMd6IYg==", + "dev": true, + "peerDependencies": { + "markdownlint-cli2": ">=0.0.4" + } + }, + "node_modules/markdownlint-cli2/node_modules/globby": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.0.tgz", + "integrity": "sha512-/1WM/LNHRAOH9lZta77uGbq0dAEQM+XjNesWwhlERDVenqothRbnzTrL3/LrIoEPPjeUHC3vrS6TwoyxeHs7MQ==", + "dev": true, + "dependencies": { + "@sindresorhus/merge-streams": "^1.0.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdownlint-cli2/node_modules/path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdownlint-cli2/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdownlint-cli2/node_modules/yaml": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", + "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/markdownlint-micromark": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/markdownlint-micromark/-/markdownlint-micromark-0.1.8.tgz", + "integrity": "sha512-1ouYkMRo9/6gou9gObuMDnvZM8jC/ly3QCFQyoSPCS2XV1ZClU0xpKbL1Ar3bWWRT1RnBZkWUEiNKrI2CwiBQA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, "node_modules/mdn-data": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==" }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -14889,6 +15073,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", @@ -17220,6 +17413,12 @@ "node": ">=4.2.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -17280,6 +17479,18 @@ "node": ">=4" } }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", diff --git a/src/webapp/package.json b/src/webapp/package.json index 115b66852..1619a8c60 100644 --- a/src/webapp/package.json +++ b/src/webapp/package.json @@ -47,5 +47,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "markdownlint-cli2": "^0.12.1" } } From de40661897c93bc9f5ae2d3199ead4781c99bfe2 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Fri, 22 Mar 2024 09:46:46 +0100 Subject: [PATCH 12/37] docs: Manually upgrade to the latest version (#2300) * docs: Manually upgrade to the latest version * Fix typo * Fix Typos * Solve last issue * fix: Align with comments * fix: indentation was off * fix: markdown lint * fix: one small bugfix * fix: remove last linting issues * fix: adding info about missing upgrade feature --- documentation/builders/update.md | 94 ++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/documentation/builders/update.md b/documentation/builders/update.md index cb919492a..6a9004625 100644 --- a/documentation/builders/update.md +++ b/documentation/builders/update.md @@ -12,6 +12,100 @@ Restore your old files after the new installation was successful and check if ne $ diff shared/settings/jukebox.yaml resources/default-settings/jukebox.default.yaml ``` +## Manually upgrade to the latest version + +> [!CAUTION] +> This documentation is only recommended for users running on `future3/develop` branch. For optimal system updates, it is strongly recommended to utilize the upgrade feature when transitioning to the next version (The Upgrade Feature will come in the future [#2304](https://github.com/MiczFlor/RPi-Jukebox-RFID/issues/2304)). Manual updates may necessitate specific migration steps and, if overlooked, could result in system failure. Please use these steps with caution. + +If you only want to update a few recent commits, this following explanation outlines the steps to do so + +Typically, 4 steps need to be considered + +1. Backup Local Changes (Optional) +1. Pull the latest version from Github +1. Replace the Web App with the most recent build +1. Optional: Update the config files + +### Fetch the most recent version from Github + +First, SSH into your Phoniebox. + +```bash +cd ~/RPi-Jukebox-RFID/ +``` + +Second, get the latest version from Github. Depending on your proficiency with Git, you can also checkout a specific branch or version. +Be aware, in case you have made changes to the software, stash them to keep them safe. + +1. Backup Local Changes (Optional): + - Stash your local changes: + + ```bash + git stash push -m "Backup before pull" + ``` + + - Create a Backup Branch (and potentially delete it in case it already exists): + + ```bash + git branch -D backup-before-pull + git branch backup-before-pull + ``` + +1. Pull Latest Changes: + + ```bash + git pull + ``` + +1. Update Web App: + 1. Backup the current webapp build + + ```bash + cd ~/RPi-Jukebox-RFID/src/webapp + rm -rf build-backup + mv build build-backup + ``` + + 1. Go to the [Github Release page](https://github.com/MiczFlor/RPi-Jukebox-RFID/releases) find the latest `Pre-release` release (typically Alpha). + 1. Under "Assets", find the latest Web App release called "webapp-build-latest.tar.gz" and copy the URL. + 1. On your Phoniebox, download the file and extract the archive. Afterwards, delete the archive + + ```bash + wget {URL} + tar -xzf webapp-build-latest.tar.gz + rm -rf webapp-build-latest.tar.gz + ``` + +1. Reboot the Phoniebox: + + ```bash + sudo reboot + ``` + +1. Verify the version of your Phoniebox in the settings tab. + +Revert to Backup If Needed: + +- Checkout the backup branch: + + ```bash + git checkout backup-before-pull + ``` + +- Reapply stashed changes (if any): + + ```bash + git stash pop + ``` + +- Revert Web App: + + ```bash + cd ~/RPi-Jukebox-RFID/src/webapp + rm -rf build + mv build-backup build + ``` + ## Migration Path from Version 2 There is no update path coming from Version 2.x of the Jukebox. From c319ca9ed1d452b73a10d262e92088aff38f62d1 Mon Sep 17 00:00:00 2001 From: s-martin Date: Wed, 27 Mar 2024 21:03:25 +0100 Subject: [PATCH 13/37] Customize markdownlint (#2310) * Customize markdownlint * Remove ignore in file * Remove ignore in file --- .markdownlint-cli2.yaml | 5 +++++ documentation/builders/installation.md | 2 -- documentation/developers/webapp.md | 3 --- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index 2fd1409d8..88c67e576 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -8,6 +8,11 @@ config: # ignore dollar signs commands-show-output: false no-trailing-punctuation: false + no-duplicate-heading: + siblings_only: true + # allow some tags we use for formatting + no-inline-html: + allowed_elements: [ "details", "summary" ] # Include a custom rule package #customRules: diff --git a/documentation/builders/installation.md b/documentation/builders/installation.md index e12514350..2228ee47f 100644 --- a/documentation/builders/installation.md +++ b/documentation/builders/installation.md @@ -29,7 +29,6 @@ Before you can install the Phoniebox software, you need to prepare your Raspberr ### Pre-boot preparation -
In case you forgot to customize the OS settings, follow these instructions after RPi OS has been written to the SD card. @@ -119,7 +118,6 @@ Up to Bullseye, the `config.txt` file is located at `/boot/`. Since Bookworm, th Reboot before you proceed.
- ## Install Phoniebox software diff --git a/documentation/developers/webapp.md b/documentation/developers/webapp.md index 0406566fa..30b791816 100644 --- a/documentation/developers/webapp.md +++ b/documentation/developers/webapp.md @@ -43,8 +43,6 @@ sudo systemctl restart nginx.service ## Known Issues while building - - ### JavaScript heap out of memory While (re-) building the Web App, you get the following output: @@ -151,4 +149,3 @@ Remove the mode packages and rerun again the script. ``` {.bash emphasize-lines="8,9"} rm -rf node_modules ``` - From 62ebd27be077f79eeae5c9731e92839f7110b5f0 Mon Sep 17 00:00:00 2001 From: s-martin Date: Sat, 30 Mar 2024 19:09:45 +0100 Subject: [PATCH 14/37] Update CodeQL to v3 (#2312) * use codeql v3 * make sure there's always an update --- .github/workflows/codeql-analysis_v3.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis_v3.yml b/.github/workflows/codeql-analysis_v3.yml index 89693df2a..d06cce1a5 100644 --- a/.github/workflows/codeql-analysis_v3.yml +++ b/.github/workflows/codeql-analysis_v3.yml @@ -44,6 +44,7 @@ jobs: - name: Install dependencies run: | # Install necessary packages + sudo apt-get update sudo apt-get install libasound2-dev pulseaudio python3 -m venv .venv source ".venv/bin/activate" @@ -56,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -68,7 +69,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -82,4 +83,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 From 27b23cbf2590cb6058753a68f1b4fcafc622dcf2 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sat, 6 Apr 2024 09:49:40 +0200 Subject: [PATCH 15/37] fix: Build libzmq locally to work on all host types (#2307) * Externalize libzmq build * Update documentation * Use dedicated build directory for libzmq * improve: update make command to be more efficient * Update docker/Dockerfile.libzmq Co-authored-by: notapirate * fix: Remove unneccesary MD linting rule * fix: Remove another markdown linter * Remove uncessary file * refactor: Rename docker.pulse.mpd.conf --------- Co-authored-by: notapirate --- ...{jukebox.Dockerfile => Dockerfile.jukebox} | 25 +- docker/Dockerfile.libzmq | 25 ++ docker/{mpd.Dockerfile => Dockerfile.mpd} | 0 .../{webapp.Dockerfile => Dockerfile.webapp} | 0 docker/config/docker.mpd.conf | 25 +- docker/config/docker.pulse.mpd.conf | 412 ------------------ docker/docker-compose.linux.yml | 4 +- docker/docker-compose.yml | 10 +- documentation/developers/docker.md | 195 ++++++--- installation/routines/setup_jukebox_core.sh | 2 +- 10 files changed, 183 insertions(+), 515 deletions(-) rename docker/{jukebox.Dockerfile => Dockerfile.jukebox} (67%) create mode 100644 docker/Dockerfile.libzmq rename docker/{mpd.Dockerfile => Dockerfile.mpd} (100%) rename docker/{webapp.Dockerfile => Dockerfile.webapp} (100%) delete mode 100644 docker/config/docker.pulse.mpd.conf diff --git a/docker/jukebox.Dockerfile b/docker/Dockerfile.jukebox similarity index 67% rename from docker/jukebox.Dockerfile rename to docker/Dockerfile.jukebox index 0936f5d46..3d63a4a02 100644 --- a/docker/jukebox.Dockerfile +++ b/docker/Dockerfile.jukebox @@ -1,3 +1,4 @@ +FROM libzmq:local as libzmq FROM debian:bullseye-slim # These are only dependencies that are required to get as close to the @@ -6,8 +7,7 @@ RUN apt-get update && apt-get install -y \ libasound2-dev \ pulseaudio \ pulseaudio-utils \ - --no-install-recommends \ - && rm -rf /var/lib/apt/lists/* + --no-install-recommends ARG UID ARG USER @@ -21,7 +21,7 @@ RUN usermod -aG pulse ${USER} # Install all Jukebox dependencies RUN apt-get update && apt-get install -qq -y \ --allow-downgrades --allow-remove-essential --allow-change-held-packages \ - g++ at wget \ + build-essential at wget \ espeak mpc mpg123 git ffmpeg spi-tools netcat \ python3 python3-venv python3-dev python3-mutagen @@ -37,21 +37,14 @@ ENV VIRTUAL_ENV=${INSTALLATION_PATH}/.venv RUN python3 -m venv $VIRTUAL_ENV ENV PATH="$VIRTUAL_ENV/bin:$PATH" - +# Install all Python dependencies RUN pip install --no-cache-dir -r ${INSTALLATION_PATH}/requirements.txt -ENV ZMQ_TMP_DIR libzmq -ENV ZMQ_VERSION 4.3.5 -ENV ZMQ_PREFIX /usr/local - -RUN [ "$(uname -m)" = "aarch64" ] && ARCH="arm64" || ARCH="$(uname -m)"; \ - wget https://github.com/pabera/libzmq/releases/download/v${ZMQ_VERSION}/libzmq5-${ARCH}-${ZMQ_VERSION}.tar.gz -O libzmq.tar.gz; \ - tar -xzf libzmq.tar.gz -C ${ZMQ_PREFIX}; \ - rm -f libzmq.tar.gz; - -RUN export ZMQ_PREFIX=${PREFIX} && export ZMQ_DRAFT_API=1 -RUN pip install -v --no-binary pyzmq pyzmq +# Install pyzmq Python dependency separately +ENV ZMQ_PREFIX /opt/libzmq +ENV ZMQ_DRAFT_API 1 +COPY --from=libzmq ${ZMQ_PREFIX} ${ZMQ_PREFIX} +RUN pip install -v pyzmq --no-binary pyzmq EXPOSE 5555 5556 - WORKDIR ${INSTALLATION_PATH}/src/jukebox diff --git a/docker/Dockerfile.libzmq b/docker/Dockerfile.libzmq new file mode 100644 index 000000000..78cf52368 --- /dev/null +++ b/docker/Dockerfile.libzmq @@ -0,0 +1,25 @@ +FROM debian:bullseye-slim + +# Install necessary build dependencies +RUN apt-get update && apt-get install -y \ + build-essential wget tar + +# Define environment variables for libzmq +ENV ZMQ_VERSION 4.3.5 +ENV ZMQ_PREFIX /opt/libzmq + +# Download, compile, and install libzmq +RUN mkdir -p ${ZMQ_PREFIX}; \ + wget https://github.com/zeromq/libzmq/releases/download/v${ZMQ_VERSION}/zeromq-${ZMQ_VERSION}.tar.gz -O libzmq.tar.gz; \ + tar -xzf libzmq.tar.gz; \ + cd zeromq-${ZMQ_VERSION}; \ + ./configure --prefix=${ZMQ_PREFIX} --enable-drafts; \ + make -j$(nproc) && make install + +# Cleanup unnecessary files +RUN rm -rf /zeromq-${ZMQ_VERSION} libzmq.tar.gz + +# Create final image with only the libzmq build fragments +FROM scratch +ENV ZMQ_PREFIX /opt/libzmq +COPY --from=0 ${ZMQ_PREFIX} ${ZMQ_PREFIX} diff --git a/docker/mpd.Dockerfile b/docker/Dockerfile.mpd similarity index 100% rename from docker/mpd.Dockerfile rename to docker/Dockerfile.mpd diff --git a/docker/webapp.Dockerfile b/docker/Dockerfile.webapp similarity index 100% rename from docker/webapp.Dockerfile rename to docker/Dockerfile.webapp diff --git a/docker/config/docker.mpd.conf b/docker/config/docker.mpd.conf index 4ec64890e..ad7713d0d 100644 --- a/docker/config/docker.mpd.conf +++ b/docker/config/docker.mpd.conf @@ -11,7 +11,7 @@ # be disabled and audio files will only be accepted over ipc socket (using # file:// protocol) or streaming files over an accepted protocol. # -music_directory "/home/pi/RPi-Jukebox-RFID/shared/audiofolders" +music_directory "~/RPi-Jukebox-RFID/shared/audiofolders" # # This setting sets the MPD internal playlist directory. The purpose of this # directory is storage for playlists created by MPD. The server will use @@ -67,7 +67,7 @@ sticker_file "~/.config/mpd/sticker.sql" # initialization. This setting is disabled by default and MPD is run as the # current user. # -user "root" +# user "root" # # This setting specifies the group that MPD will run as. If not specified # primary group of user specified with "user" setting will be used (if set). @@ -225,6 +225,10 @@ decoder { # gapless "no" } +decoder { + plugin "wildmidi" + enabled "no" +} # ############################################################################### @@ -239,12 +243,11 @@ decoder { # audio_output { type "alsa" - name "My ALSA Device" -# device "pulse" # optional - mixer_type "software" # optional -# mixer_device "default" # optional -# mixer_control "Master" # optional -# mixer_index "0" # optional + name "Global ALSA->Pulse stream" +# mixer_type "hardware" + mixer_control "Master" + mixer_device "pulse" + device "pulse" } # # An example of an OSS output: @@ -311,9 +314,9 @@ audio_output { # Please see README.Debian if you want mpd to play through the pulseaudio # daemon started as part of your graphical desktop session! # -# audio_output { - # type "pulse" - # name "My Pulse Output" +#audio_output { +# type "pulse" +# name "My Pulse Output" # server "remote_server" # optional # sink "remote_server_sink" # optional # } diff --git a/docker/config/docker.pulse.mpd.conf b/docker/config/docker.pulse.mpd.conf deleted file mode 100644 index ad7713d0d..000000000 --- a/docker/config/docker.pulse.mpd.conf +++ /dev/null @@ -1,412 +0,0 @@ -# An example configuration file for MPD. -# Read the user manual for documentation: http://www.musicpd.org/doc/user/ -# or /usr/share/doc/mpd/html/user.html - - -# Files and directories ####################################################### -# -# This setting controls the top directory which MPD will search to discover the -# available audio files and add them to the daemon's online database. This -# setting defaults to the XDG directory, otherwise the music directory will be -# be disabled and audio files will only be accepted over ipc socket (using -# file:// protocol) or streaming files over an accepted protocol. -# -music_directory "~/RPi-Jukebox-RFID/shared/audiofolders" -# -# This setting sets the MPD internal playlist directory. The purpose of this -# directory is storage for playlists created by MPD. The server will use -# playlist files not created by the server but only if they are in the MPD -# format. This setting defaults to playlist saving being disabled. -# -# playlists are inside the Phoniebox path: -playlist_directory "~/.config/mpd/playlists" -# -# This setting sets the location of the MPD database. This file is used to -# load the database at server start up and store the database while the -# server is not up. This setting defaults to disabled which will allow -# MPD to accept files over ipc socket (using file:// protocol) or streaming -# files over an accepted protocol. -# -db_file "~/.config/mpd/database" -# -# These settings are the locations for the daemon log files for the daemon. -# These logs are great for troubleshooting, depending on your log_level -# settings. -# -# The special value "syslog" makes MPD use the local syslog daemon. This -# setting defaults to logging to syslog, or to journal if mpd was started as -# a systemd service. -# -log_file "~/.config/mpd/log" -# -# This setting sets the location of the file which stores the process ID -# for use of mpd --kill and some init scripts. This setting is disabled by -# default and the pid file will not be stored. -# -pid_file "~/.config/mpd/pid" -# -# This setting sets the location of the file which contains information about -# most variables to get MPD back into the same general shape it was in before -# it was brought down. This setting is disabled by default and the server -# state will be reset on server start up. -# -state_file "~/.config/mpd/state" -# -# The location of the sticker database. This is a database which -# manages dynamic information attached to songs. -# -sticker_file "~/.config/mpd/sticker.sql" -# -############################################################################### - - -# General music daemon options ################################################ -# -# This setting specifies the user that MPD will run as. MPD should never run as -# root and you may use this setting to make MPD change its user ID after -# initialization. This setting is disabled by default and MPD is run as the -# current user. -# -# user "root" -# -# This setting specifies the group that MPD will run as. If not specified -# primary group of user specified with "user" setting will be used (if set). -# This is useful if MPD needs to be a member of group such as "audio" to -# have permission to use sound card. -# -#group "nogroup" -# -# This setting sets the address for the daemon to listen on. Careful attention -# should be paid if this is assigned to anything other then the default, any. -# This setting can deny access to control of the daemon. Choose any if you want -# to have mpd listen on every address. Not effective if systemd socket -# activation is in use. -# -# For network -bind_to_address "any" -# -# And for Unix Socket -#bind_to_address "/run/mpd/socket" -# -# This setting is the TCP port that is desired for the daemon to get assigned -# to. -# -port "6600" -# -# This setting controls the type of information which is logged. Available -# setting arguments are "default", "secure" or "verbose". The "verbose" setting -# argument is recommended for troubleshooting, though can quickly stretch -# available resources on limited hardware storage. -# -log_level "default" -# -# Setting "restore_paused" to "yes" puts MPD into pause mode instead -# of starting playback after startup. -# -#restore_paused "no" -# -# This setting enables MPD to create playlists in a format usable by other -# music players. -# -#save_absolute_paths_in_playlists "no" -# -# This setting defines a list of tag types that will be extracted during the -# audio file discovery process. The complete list of possible values can be -# found in the user manual. -#metadata_to_use "artist,album,title,track,name,genre,date,composer,performer,disc" -# -# This example just enables the "comment" tag without disabling all -# the other supported tags: -#metadata_to_use "+comment" -# -# This setting enables automatic update of MPD's database when files in -# music_directory are changed. -# -auto_update "yes" -# -# Limit the depth of the directories being watched, 0 means only watch -# the music directory itself. There is no limit by default. -# -auto_update_depth "10" -# -############################################################################### - - -# Symbolic link behavior ###################################################### -# -# If this setting is set to "yes", MPD will discover audio files by following -# symbolic links outside of the configured music_directory. -# -#follow_outside_symlinks "yes" -# -# If this setting is set to "yes", MPD will discover audio files by following -# symbolic links inside of the configured music_directory. -# -#follow_inside_symlinks "yes" -# -############################################################################### - - -# Zeroconf / Avahi Service Discovery ########################################## -# -# If this setting is set to "yes", service information will be published with -# Zeroconf / Avahi. -# -#zeroconf_enabled "yes" -# -# The argument to this setting will be the Zeroconf / Avahi unique name for -# this MPD server on the network. %h will be replaced with the hostname. -# -#zeroconf_name "Music Player @ %h" -# -############################################################################### - - -# Permissions ################################################################# -# -# If this setting is set, MPD will require password authorization. The password -# setting can be specified multiple times for different password profiles. -# -#password "password@read,add,control,admin" -# -# This setting specifies the permissions a user has who has not yet logged in. -# -#default_permissions "read,add,control,admin" -# -############################################################################### - - -# Database ####################################################################### -# - -#database { -# plugin "proxy" -# host "other.mpd.host" -# port "6600" -#} - -# Input ####################################################################### -# - -input { - plugin "curl" -# proxy "proxy.isp.com:8080" -# proxy_user "user" -# proxy_password "password" -} - -# QOBUZ input plugin -input { - enabled "no" - plugin "qobuz" -# app_id "ID" -# app_secret "SECRET" -# username "USERNAME" -# password "PASSWORD" -# format_id "N" -} - -# TIDAL input plugin -input { - enabled "no" - plugin "tidal" -# token "TOKEN" -# username "USERNAME" -# password "PASSWORD" -# audioquality "Q" -} - -# Decoder ##################################################################### -# - -decoder { - plugin "hybrid_dsd" - enabled "no" -# gapless "no" -} - -decoder { - plugin "wildmidi" - enabled "no" -} -# -############################################################################### - -# Audio Output ################################################################ -# -# MPD supports various audio output types, as well as playing through multiple -# audio outputs at the same time, through multiple audio_output settings -# blocks. Setting this block is optional, though the server will only attempt -# autodetection for one sound card. -# -# An example of an ALSA output: -# -audio_output { - type "alsa" - name "Global ALSA->Pulse stream" -# mixer_type "hardware" - mixer_control "Master" - mixer_device "pulse" - device "pulse" -} -# -# An example of an OSS output: -# -#audio_output { -# type "oss" -# name "My OSS Device" -# device "/dev/dsp" # optional -# mixer_type "hardware" # optional -# mixer_device "/dev/mixer" # optional -# mixer_control "PCM" # optional -#} -# -# An example of a shout output (for streaming to Icecast): -# -#audio_output { -# type "shout" -# encoder "vorbis" # optional -# name "My Shout Stream" -# host "localhost" -# port "8000" -# mount "/mpd.ogg" -# password "hackme" -# quality "5.0" -# bitrate "128" -# format "44100:16:1" -# protocol "icecast2" # optional -# user "source" # optional -# description "My Stream Description" # optional -# url "http://example.com" # optional -# genre "jazz" # optional -# public "no" # optional -# timeout "2" # optional -# mixer_type "software" # optional -#} -# -# An example of a recorder output: -# -#audio_output { -# type "recorder" -# name "My recorder" -# encoder "vorbis" # optional, vorbis or lame -# path "/var/lib/mpd/recorder/mpd.ogg" -## quality "5.0" # do not define if bitrate is defined -# bitrate "128" # do not define if quality is defined -# format "44100:16:1" -#} -# -# An example of a httpd output (built-in HTTP streaming server): -# -#audio_output { -# type "httpd" -# name "My HTTP Stream" -# encoder "vorbis" # optional, vorbis or lame -# port "8000" -# bind_to_address "0.0.0.0" # optional, IPv4 or IPv6 -# quality "5.0" # do not define if bitrate is defined -# bitrate "128" # do not define if quality is defined -# format "44100:16:1" -# max_clients "0" # optional 0=no limit -#} -# -# An example of a pulseaudio output (streaming to a remote pulseaudio server) -# Please see README.Debian if you want mpd to play through the pulseaudio -# daemon started as part of your graphical desktop session! -# -#audio_output { -# type "pulse" -# name "My Pulse Output" -# server "remote_server" # optional -# sink "remote_server_sink" # optional -# } -# -# An example of a winmm output (Windows multimedia API). -# -#audio_output { -# type "winmm" -# name "My WinMM output" -# device "Digital Audio (S/PDIF) (High Definition Audio Device)" # optional -# or -# device "0" # optional -# mixer_type "hardware" # optional -#} -# -# An example of an openal output. -# -#audio_output { -# type "openal" -# name "My OpenAL output" -# device "Digital Audio (S/PDIF) (High Definition Audio Device)" # optional -#} -# -## Example "pipe" output: -# -#audio_output { -# type "pipe" -# name "my pipe" -# command "aplay -f cd 2>/dev/null" -## Or if you're want to use AudioCompress -# command "AudioCompress -m | aplay -f cd 2>/dev/null" -## Or to send raw PCM stream through PCM: -# command "nc example.org 8765" -# format "44100:16:2" -#} -# -## An example of a null output (for no audio output): -# -#audio_output { -# type "null" -# name "My Null Output" -# mixer_type "none" # optional -#} -# -############################################################################### - - -# Normalization automatic volume adjustments ################################## -# -# This setting specifies the type of ReplayGain to use. This setting can have -# the argument "off", "album", "track" or "auto". "auto" is a special mode that -# chooses between "track" and "album" depending on the current state of -# random playback. If random playback is enabled then "track" mode is used. -# See for more details about ReplayGain. -# This setting is off by default. -# -#replaygain "album" -# -# This setting sets the pre-amp used for files that have ReplayGain tags. By -# default this setting is disabled. -# -#replaygain_preamp "0" -# -# This setting sets the pre-amp used for files that do NOT have ReplayGain tags. -# By default this setting is disabled. -# -#replaygain_missing_preamp "0" -# -# This setting enables or disables ReplayGain limiting. -# MPD calculates actual amplification based on the ReplayGain tags -# and replaygain_preamp / replaygain_missing_preamp setting. -# If replaygain_limit is enabled MPD will never amplify audio signal -# above its original level. If replaygain_limit is disabled such amplification -# might occur. By default this setting is enabled. -# -#replaygain_limit "yes" -# -# This setting enables on-the-fly normalization volume adjustment. This will -# result in the volume of all playing audio to be adjusted so the output has -# equal "loudness". This setting is disabled by default. -# -volume_normalization "yes" -# -############################################################################### - -# Character Encoding ########################################################## -# -# If file or directory names do not display correctly for your locale then you -# may need to modify this setting. -# -filesystem_charset "UTF-8" -# -############################################################################### diff --git a/docker/docker-compose.linux.yml b/docker/docker-compose.linux.yml index 9609dc18d..6285484f8 100755 --- a/docker/docker-compose.linux.yml +++ b/docker/docker-compose.linux.yml @@ -12,7 +12,7 @@ services: volumes: - ../shared/audiofolders:/home/pi/RPi-Jukebox-RFID/shared/audiofolders - ../shared/playlists:/home/pi/.config/mpd/playlists - - ./config/docker.pulse.mpd.conf:/home/pi/.config/mpd/mpd.conf + - ./config/docker.mpd.conf:/home/pi/.config/mpd/mpd.conf - $XDG_RUNTIME_DIR/pulse/native:/tmp/pulse-sock jukebox: @@ -25,5 +25,5 @@ services: - PULSE_SERVER=unix:/tmp/pulse-sock volumes: - ../shared:/home/pi/RPi-Jukebox-RFID/shared - - ./config/docker.pulse.mpd.conf:/home/pi/.config/mpd/mpd.conf + - ./config/docker.mpd.conf:/home/pi/.config/mpd/mpd.conf - $XDG_RUNTIME_DIR/pulse/native:/tmp/pulse-sock diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 03191dbd5..38a112551 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -8,7 +8,7 @@ services: - USER=root - HOME=/root context: ../ - dockerfile: ./docker/mpd.Dockerfile + dockerfile: ./docker/Dockerfile.mpd container_name: mpd image: phoniebox/mpd environment: @@ -17,7 +17,7 @@ services: volumes: - ../shared/audiofolders:/root/RPi-Jukebox-RFID/shared/audiofolders - ../shared/playlists:/root/.config/mpd/playlists - - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf + - ./config/docker.mpd.conf:/root/.config/mpd/mpd.conf jukebox: build: @@ -26,7 +26,7 @@ services: - USER=root - HOME=/root context: ../ - dockerfile: ./docker/jukebox.Dockerfile + dockerfile: ./docker/Dockerfile.jukebox container_name: jukebox image: phoniebox/jukebox depends_on: @@ -43,13 +43,13 @@ services: - ../src/jukebox:/root/RPi-Jukebox-RFID/src/jukebox - ../src/webapp/public/cover-cache:/root/RPi-Jukebox-RFID/src/webapp/build/cover-cache - ../shared:/root/RPi-Jukebox-RFID/shared - - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf + - ./config/docker.mpd.conf:/root/.config/mpd/mpd.conf command: python run_jukebox.py webapp: build: context: ../ - dockerfile: ./docker/webapp.Dockerfile + dockerfile: ./docker/Dockerfile.webapp container_name: webapp image: phoniebox/webapp depends_on: diff --git a/documentation/developers/docker.md b/documentation/developers/docker.md index 9b4db4697..74d95c112 100644 --- a/documentation/developers/docker.md +++ b/documentation/developers/docker.md @@ -20,14 +20,14 @@ need to adapt some of those commands to your needs. 2. Pull the Jukebox repository: ```bash - $ git clone https://github.com/MiczFlor/RPi-Jukebox-RFID.git + git clone https://github.com/MiczFlor/RPi-Jukebox-RFID.git ``` 3. Create a jukebox.yaml file * Copy the `./resources/default-settings/jukebox.default.yaml` to `./shared/settings` and rename the file to `jukebox.yaml`. ```bash - $ cp ./resources/default-settings/jukebox.default.yaml ./shared/settings/jukebox.yaml + cp ./resources/default-settings/jukebox.default.yaml ./shared/settings/jukebox.yaml ``` * Override/Merge the values from the following [Override file](../../docker/config/jukebox.overrides.yaml) in your `jukebox.yaml`. @@ -39,127 +39,168 @@ need to adapt some of those commands to your needs. ## Run development environment -In contrary to how everything is set up on the Raspberry Pi, it\'s good +In contrary to how everything is set up on the Raspberry Pi, it's good practice to isolate different components in different Docker images. They can be run individually or in combination. To do that, we use `docker-compose`. ### Mac +
+ +See details + 1. [Install Docker & Compose (Mac)](https://docs.docker.com/docker-for-mac/install/) -2. Install pulseaudio +1. Install pulseaudio 1. Use Homebrew to install ```bash - $ brew install pulseaudio + brew install pulseaudio ``` - 2. Enable pulseaudio network capabilities. In an editor, open `/opt/homebrew/Cellar/pulseaudio/16.1/etc/pulse/default.pa` (you might need to adapt this path to your own system settings). Uncomment the following line: + 1. Enable pulseaudio network capabilities. In an editor, open `/opt/homebrew/Cellar/pulseaudio/16.1/etc/pulse/default.pa` (you might need to adapt this path to your own system settings). Uncomment the following line: ```text load-module module-native-protocol-tcp ``` - 3. Restart the pulseaudio service + 1. Restart the pulseaudio service ```bash - $ brew services restart pulseaudio + brew services restart pulseaudio ``` - 4. If you have trouble with your audio, try these resources to troubleshoot: [[1]](https://gist.github.com/seongyongkim/b7d630a03e74c7ab1c6b53473b592712), [[2]](https://devops.datenkollektiv.de/running-a-docker-soundbox-on-mac.html), [[3]](https://stackoverflow.com/a/50939994/1062438) + 1. If you have trouble with your audio, try these resources to troubleshoot: [[1]](https://gist.github.com/seongyongkim/b7d630a03e74c7ab1c6b53473b592712), [[2]](https://devops.datenkollektiv.de/running-a-docker-soundbox-on-mac.html), [[3]](https://stackoverflow.com/a/50939994/1062438) -> [!NOTE] -> In order for Pulseaudio to work properly with Docker on your Mac, you need to start Pulseaudio in a specific way. Otherwise MPD will throw an exception. See [Pulseaudio issues on Mac](#pulseaudio-issue-on-mac) for more info. +1. Run `docker-compose` -``` bash -// Build Images -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.mac.yml build + > [!NOTE] + > In order for Pulseaudio to work properly with Docker on your Mac, you need to start Pulseaudio in a specific way. Otherwise MPD will throw an exception. See [Pulseaudio issues on Mac](#pulseaudio-issue-on-mac) for more info. -// Run Docker Environment -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.mac.yml up + 1. Build libzmq for your host machine -// Shuts down Docker containers and Docker network -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.mac.yml down -``` + ```bash + docker build -f docker/Dockerfile.libzmq -t libzmq:local . + ``` + + 1. Build Images + + ```bash + docker-compose -f docker/docker-compose.yml -f docker/docker-compose.mac.yml build + ``` + + 1. Run Docker Environment -> Runs the entire Phoniebox environment + + ```bash + docker-compose -f docker/docker-compose.yml -f docker/docker-compose.mac.yml up + ``` + + * Shuts down Docker containers and Docker network + + ```bash + docker-compose -f docker/docker-compose.yml -f docker/docker-compose.mac.yml down + ``` + +
### Windows +
+ +See details + 1. Install [Docker & Compose (Windows)](https://docs.docker.com/docker-for-windows/install/) -2. Download [pulseaudio](https://www.freedesktop.org/wiki/Software/PulseAudio/Ports/Windows/Support/) +1. Download [pulseaudio](https://www.freedesktop.org/wiki/Software/PulseAudio/Ports/Windows/Support/) -3. Uncompress somewhere in your user folder +1. Uncompress somewhere in your user folder -4. Edit `$INSTALL_DIR/etc/pulse/default.pa` +1. Edit `$INSTALL_DIR/etc/pulse/default.pa` -5. Add the following line +1. Add the following line ``` bash load-module module-native-protocol-tcp auth-ip-acl=127.0.0.1 ``` -6. Edit `$INSTALL_DIR/etc/pulse//etc/pulse/daemon.conf`, find the +1. Edit `$INSTALL_DIR/etc/pulse//etc/pulse/daemon.conf`, find the following line and change it to: ``` bash exit-idle-time = -1 ``` -7. Execute `$INSTALL_DIR/bin/pulseaudio.exe` +1. Execute `$INSTALL_DIR/bin/pulseaudio.exe` -8. Make sure Docker is running (e.g. start Docker Desktop) +1. Make sure Docker is running (e.g. start Docker Desktop) -9. Run `docker-compose` +1. Run `docker-compose` - ``` bash - // Build Images - $ docker-compose -f docker/docker-compose.yml build + 1. Build libzmq for your host machine - // Run Docker Environment - $ docker-compose -f docker/docker-compose.yml up + ```bash + docker build -f docker/Dockerfile.libzmq -t libzmq:local . + ``` - // Shuts down Docker containers and Docker network - $ docker-compose -f docker/docker-compose.yml down - ``` + 1. Build Images + + ```bash + docker-compose -f docker/docker-compose.yml build + ``` + + 1. Run Docker Environment -> Runs the entire Phoniebox environment + + ```bash + docker-compose -f docker/docker-compose.yml up + ``` + + * Shuts down Docker containers and Docker network + + ```bash + docker-compose -f docker/docker-compose.yml down + ``` + +
### Linux +
+ +See details + 1. Install Docker & Compose * [Docker](https://docs.docker.com/engine/install/debian/) * [Compose](https://docs.docker.com/compose/install/) -2. Make sure you don\'t use `sudo` to run your `docker-compose`. Check out +1. Make sure you don\'t use `sudo` to run your `docker-compose`. Check out Docker\'s [post-installation guide](https://docs.docker.com/engine/install/linux-postinstall/) for more information. -```bash -// Build Images -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.linux.yml build +1. Run `docker-compose` -// Run Docker Environment -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.linux.yml up + 1. Build libzmq for your host machine -// Shuts down Docker containers and Docker network -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.linux.yml down -``` + ```bash + docker build -f docker/Dockerfile.libzmq -t libzmq:local . + ``` -Note: if you have `mpd` running on your system, you need to stop it -using: + 1. Build Images -``` bash -$ sudo systemctl stop mpd.socket -$ sudo mpd --kill -``` + ```bash + docker-compose -f docker/docker-compose.yml -f docker/docker-compose.linux.yml build + ``` -Otherwise you might get the error message: + 1. Run Docker Environment -> Runs the entire Phoniebox environment -``` bash -$ docker-compose -f docker-compose.yml -f docker-compose.linux.yml up -Starting mpd ... -Starting mpd ... error -(...) -Error starting userland proxy: listen tcp4 0.0.0.0:6600: bind: address already in use -``` + ```bash + docker-compose -f docker/docker-compose.yml -f docker/docker-compose.linux.yml up + ``` -Read these threads for details: [thread 1](https://unix.stackexchange.com/questions/456909/socket-already-in-use-but-is-not-listed-mpd) and [thread 2](https://stackoverflow.com/questions/5106674/error-address-already-in-use-while-binding-socket-with-address-but-the-port-num/5106755#5106755) + * Shuts down Docker containers and Docker network + + ```bash + docker-compose -f docker/docker-compose.yml -f docker/docker-compose.linux.yml down + ``` + +
## Test & Develop @@ -175,7 +216,7 @@ restart your `jukebox` container. Update the below path with your specific host environment. ``` bash -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.[ENVIRONMENT].yml restart jukebox +docker-compose -f docker/docker-compose.yml -f docker/docker-compose.[ENVIRONMENT].yml restart jukebox ``` ## Known issues @@ -208,13 +249,13 @@ To fix the issue, try the following. brew service stop pulseaudio ``` -2. Start Pulseaudio with this command +1. Start Pulseaudio with this command ```bash pulseaudio --load=module-native-protocol-tcp --exit-idle-time=-1 --daemon ``` -3. Check if daemon is working +1. Check if daemon is working ```bash pulseaudio --check -v @@ -224,6 +265,27 @@ Everything else should have been set up properly as a [prerequisite](#mac) * [Source](https://gist.github.com/seongyongkim/b7d630a03e74c7ab1c6b53473b592712) +#### `mpd` issues on Linux + +If you have `mpd` running on your system, you need to stop it using: + +``` bash +sudo systemctl stop mpd.socket +sudo mpd --kill +``` + +Otherwise you might get the error message: + +``` bash +docker-compose -f docker-compose.yml -f docker-compose.linux.yml up +Starting mpd ... +Starting mpd ... error +(...) +Error starting userland proxy: listen tcp4 0.0.0.0:6600: bind: address already in use +``` + +Read these threads for details: [thread 1](https://unix.stackexchange.com/questions/456909/socket-already-in-use-but-is-not-listed-mpd) and [thread 2](https://stackoverflow.com/questions/5106674/error-address-already-in-use-while-binding-socket-with-address-but-the-port-num/5106755#5106755) + #### Other error messages When starting the `mpd` container, you will see the following errors. @@ -274,7 +336,7 @@ jukebox | 319:server.py - jb.pub.server - host.timer.cputemp If you encounter the following error, refer to [Pulseaudio issues on Mac](#pulseaudio-issue-on-mac). -```text +``` bash jukebox | 21.12.2023 08:50:09 - 629:plugs.py - jb.plugin - MainThread - ERROR - Ignoring failed package load finalizer: 'volume.finalize()' jukebox | 21.12.2023 08:50:09 - 630:plugs.py - jb.plugin - MainThread - ERROR - Reason: NameError: name 'pulse_control' is not defined ``` @@ -289,8 +351,8 @@ run `mpd` or `webapp`. The following command can be run on a Mac. ``` bash -$ docker build -f docker/jukebox.Dockerfile -t jukebox . -$ docker run -it --rm \ +docker build -f docker/Dockerfile.jukebox -t jukebox . +docker run -it --rm \ -v $(PWD)/src/jukebox:/home/pi/RPi-Jukebox-RFID/src/jukebox \ -v $(PWD)/shared/audiofolders:/home/pi/RPi-Jukebox-RFID/shared/audiofolders \ -v ~/.config/pulse:/root/.config/pulse \ @@ -314,7 +376,6 @@ Mount the device into the container by configuring the appropriate device in a ` ### Resources - #### Mac * @@ -328,8 +389,6 @@ Mount the device into the container by configuring the appropriate device in a ` * * - - #### Audio * diff --git a/installation/routines/setup_jukebox_core.sh b/installation/routines/setup_jukebox_core.sh index cb85198be..9dcca2256 100644 --- a/installation/routines/setup_jukebox_core.sh +++ b/installation/routines/setup_jukebox_core.sh @@ -86,7 +86,7 @@ _jukebox_core_build_and_install_pyzmq() { fi ZMQ_PREFIX="${JUKEBOX_ZMQ_PREFIX}" ZMQ_DRAFT_API=1 \ - pip install -v --no-binary pyzmq pyzmq + pip install -v pyzmq --no-binary pyzmq else print_lc " Skipping. pyzmq already installed" fi From aadff23662399a7388f9881e6da170cf6c17b24f Mon Sep 17 00:00:00 2001 From: Christian Hoffmann Date: Mon, 8 Apr 2024 07:14:00 +0000 Subject: [PATCH 16/37] Fix PlayerMPD.rewind to start with the first song (#2323) rewind() is documented to jump to the first song of the playlist. Instead, it jumped to the second song as SONGPOS in MPDClient.play(SONGPOS) is zero-indexed [1], so mpd.play(1) started the second song. [1] https://mpd.readthedocs.io/en/latest/protocol.html#the-queue "The position is a 0-based index" Related: #2294 --- src/jukebox/components/playermpd/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py index 49f630224..4ae9458ec 100644 --- a/src/jukebox/components/playermpd/__init__.py +++ b/src/jukebox/components/playermpd/__init__.py @@ -350,7 +350,7 @@ def rewind(self): Note: Will not re-read folder config, but leave settings untouched""" logger.debug("Rewind") with self.mpd_lock: - self.mpd_client.play(1) + self.mpd_client.play(0) @plugs.tag def replay(self): From 7c7024c918cd5afb091264e2616f29e6c7b70ed6 Mon Sep 17 00:00:00 2001 From: s-martin Date: Mon, 8 Apr 2024 20:55:29 +0200 Subject: [PATCH 17/37] Add Python 3.12 to Action (#2320) * Try python 3.12 * Trigger py file for action * Revert previous commit * fix flake8 warnings for python 3.12 * fix e126 * fix e126 * ignoring e126 as it is too strict * revert e126 attempts --- .flake8 | 2 ++ .github/workflows/pythonpackage_future3.yml | 2 +- src/jukebox/components/controls/common/evdev_listener.py | 2 +- src/jukebox/components/volume/__init__.py | 2 +- src/jukebox/run_configure_audio.py | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.flake8 b/.flake8 index 7f201b078..6f7bcd443 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,8 @@ [flake8] max-line-length = 127 ignore = + # continuation line over-indented for hanging indent + E126, # continuation line over-indented for visual indent E127, # continuation line under-indented for visual indent diff --git a/.github/workflows/pythonpackage_future3.yml b/.github/workflows/pythonpackage_future3.yml index 2236a5a47..da7007b97 100644 --- a/.github/workflows/pythonpackage_future3.yml +++ b/.github/workflows/pythonpackage_future3.yml @@ -19,7 +19,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ['3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v3 diff --git a/src/jukebox/components/controls/common/evdev_listener.py b/src/jukebox/components/controls/common/evdev_listener.py index a4279afda..a1f336758 100644 --- a/src/jukebox/components/controls/common/evdev_listener.py +++ b/src/jukebox/components/controls/common/evdev_listener.py @@ -160,7 +160,7 @@ def run(self): self._connect() except FileNotFoundError as e: # This error occurs, if opening the bluetooth input device fails - logger.debug(f"{e} (attempt: {idx+1}/{self.open_retry_cnt}). Retrying in {self.open_retry_delay}") + logger.debug(f"{e} (attempt: {idx + 1}/{self.open_retry_cnt}). Retrying in {self.open_retry_delay}") time.sleep(self.open_retry_delay) except AttributeError as e: # This error occurs, when the device can be found, but does not have the mandatory keys diff --git a/src/jukebox/components/volume/__init__.py b/src/jukebox/components/volume/__init__.py index 9dd827e4a..b99e94616 100644 --- a/src/jukebox/components/volume/__init__.py +++ b/src/jukebox/components/volume/__init__.py @@ -424,7 +424,7 @@ def _publish_outputs(self, pulse_inst: pulsectl.Pulse): def _set_output(self, pulse_inst: pulsectl.Pulse, sink_index: int): error_state = 1 if not 0 <= sink_index < len(self._sink_list): - logger.error(f"Sink index '{sink_index}' out of range (0..{len(self._sink_list)-1}). " + logger.error(f"Sink index '{sink_index}' out of range (0..{len(self._sink_list) - 1}). " f"Did you configure your secondary output device?") else: # Before we switch the sink, check the new sinks volume levels... diff --git a/src/jukebox/run_configure_audio.py b/src/jukebox/run_configure_audio.py index 93f0a4c6a..191a25e5d 100644 --- a/src/jukebox/run_configure_audio.py +++ b/src/jukebox/run_configure_audio.py @@ -192,7 +192,7 @@ def query_sinks(pulse_config: PaConfigClass): # noqa: C901 if sink_is_equalizer(primary_signal_chain[sidx - 1]): pulse_config.enable_equalizer = False print(f"\n*** Equalizer already configured for '{pulse_config.primary}' with name\n" - f" '{primary_signal_chain[sidx-1].name}'. Shifting entry point...") + f" '{primary_signal_chain[sidx - 1].name}'. Shifting entry point...") pulse_config.primary = primary_signal_chain[sidx - 1].name sidx -= 1 except ValueError: From 3865c5ab8af929ba05147754a60a29ce1f1bfe22 Mon Sep 17 00:00:00 2001 From: Christian Hoffmann Date: Mon, 8 Apr 2024 19:00:06 +0000 Subject: [PATCH 18/37] Fix CoverartCacheManager (#2325) * Fix CoverartCacheManager for songs with no art Previously, an ERROR was logged for each song without cover art when the Web UI was open. This commit avoids the error, caches the no-cover-art result and saves a roundtrip to mpd for all no-cover-art songs. * refactor: Reducing code and simplifying some logical statements * fix: flake8 error * refactor: reducing complexity for cache filename * refactor: introduce queuing for saving cache files * fix: remove slugify * feat: Use mutagen instead of MPD to retrieve cover art, include cache flush, and thread * fix: flake8 error * Update src/jukebox/components/playermpd/__init__.py Co-authored-by: Christian Hoffmann --------- Co-authored-by: pabera <1260686+pabera@users.noreply.github.com> --- requirements.txt | 2 +- src/jukebox/components/playermpd/__init__.py | 46 +++------- .../playermpd/coverart_cache_manager.py | 90 ++++++++++++++++--- src/jukebox/components/rpc_command_alias.py | 4 + .../albums/album-list/album-list-item.js | 4 +- 5 files changed, 97 insertions(+), 49 deletions(-) diff --git a/requirements.txt b/requirements.txt index c172a7636..8ddfc881a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,11 +10,11 @@ wheel # Jukebox Core # For USB inputs (reader, buttons) and bluetooth buttons evdev +mutagen pyalsaaudio pulsectl python-mpd2 ruamel.yaml -python-slugify # For playlistgenerator requests # For the publisher event reactor loop: diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py index 4ae9458ec..dcbef2ea8 100644 --- a/src/jukebox/components/playermpd/__init__.py +++ b/src/jukebox/components/playermpd/__init__.py @@ -87,7 +87,7 @@ import logging import time import functools -from slugify import slugify +from pathlib import Path import components.player import jukebox.cfghandler import jukebox.utils as utils @@ -521,40 +521,10 @@ def play_card(self, folder: str, recursive: bool = False): @plugs.tag def get_single_coverart(self, song_url): - """ - Saves the album art image to a cache and returns the filename. - """ - base_filename = slugify(song_url) - - try: - metadata_list = self.mpd_client.listallinfo(song_url) - metadata = {} - if metadata_list: - metadata = metadata_list[0] - - if 'albumartist' in metadata and 'album' in metadata: - base_filename = slugify(f"{metadata['albumartist']}-{metadata['album']}") - - cache_filename = self.coverart_cache_manager.find_file_by_hash(base_filename) - - if cache_filename: - return cache_filename - - # Cache file does not exist - # Fetch cover art binary - album_art_data = self.mpd_client.readpicture(song_url) + mp3_file_path = Path(components.player.get_music_library_path(), song_url).expanduser() + cache_filename = self.coverart_cache_manager.get_cache_filename(mp3_file_path) - # Save to cache - cache_filename = self.coverart_cache_manager.save_to_cache(base_filename, album_art_data) - - return cache_filename - - except mpd.base.CommandError as e: - logger.error(f"{e.__class__.__qualname__}: {e} at uri {song_url}") - except Exception as e: - logger.error(f"{e.__class__.__qualname__}: {e} at uri {song_url}") - - return "" + return cache_filename @plugs.tag def get_album_coverart(self, albumartist: str, album: str): @@ -562,6 +532,14 @@ def get_album_coverart(self, albumartist: str, album: str): return self.get_single_coverart(song_list[0]['file']) + @plugs.tag + def flush_coverart_cache(self): + """ + Deletes the Cover Art Cache + """ + + return self.coverart_cache_manager.flush_cache() + @plugs.tag def get_folder_content(self, folder: str): """ diff --git a/src/jukebox/components/playermpd/coverart_cache_manager.py b/src/jukebox/components/playermpd/coverart_cache_manager.py index a7ae12eef..bb2346497 100644 --- a/src/jukebox/components/playermpd/coverart_cache_manager.py +++ b/src/jukebox/components/playermpd/coverart_cache_manager.py @@ -1,26 +1,90 @@ -import os +from mutagen.mp3 import MP3 +from mutagen.id3 import ID3, APIC +from pathlib import Path +import hashlib +import logging +from queue import Queue +from threading import Thread import jukebox.cfghandler +COVER_PREFIX = 'cover' +NO_COVER_ART_EXTENSION = 'no-art' +NO_CACHE = '' +CACHE_PENDING = 'CACHE_PENDING' + +logger = logging.getLogger('jb.CoverartCacheManager') cfg = jukebox.cfghandler.get_handler('jukebox') class CoverartCacheManager: def __init__(self): coverart_cache_path = cfg.setndefault('webapp', 'coverart_cache_path', value='../../src/webapp/build/cover-cache') - self.cache_folder_path = os.path.expanduser(coverart_cache_path) + self.cache_folder_path = Path(coverart_cache_path).expanduser() + self.write_queue = Queue() + self.worker_thread = Thread(target=self.process_write_requests) + self.worker_thread.daemon = True # Ensure the thread closes with the program + self.worker_thread.start() + + def generate_cache_key(self, base_filename: str) -> str: + return f"{COVER_PREFIX}-{hashlib.sha256(base_filename.encode()).hexdigest()}" + + def get_cache_filename(self, mp3_file_path: str) -> str: + base_filename = Path(mp3_file_path).stem + cache_key = self.generate_cache_key(base_filename) + + for path in self.cache_folder_path.iterdir(): + if path.stem == cache_key: + if path.suffix == f".{NO_COVER_ART_EXTENSION}": + return NO_CACHE + return path.name + + self.save_to_cache(mp3_file_path) + return CACHE_PENDING + + def save_to_cache(self, mp3_file_path: str): + self.write_queue.put(mp3_file_path) - def find_file_by_hash(self, hash_value): - for filename in os.listdir(self.cache_folder_path): - if filename.startswith(hash_value): - return filename - return None + def _save_to_cache(self, mp3_file_path: str): + base_filename = Path(mp3_file_path).stem + cache_key = self.generate_cache_key(base_filename) + file_extension, data = self._extract_album_art(mp3_file_path) - def save_to_cache(self, base_filename, album_art_data): - mime_type = album_art_data['type'] - file_extension = 'jpg' if mime_type == 'image/jpeg' else mime_type.split('/')[-1] - cache_filename = f"{base_filename}.{file_extension}" + cache_filename = f"{cache_key}.{file_extension}" + full_path = self.cache_folder_path / cache_filename # Works due to Pathlib - with open(os.path.join(self.cache_folder_path, cache_filename), 'wb') as file: - file.write(album_art_data['binary']) + with full_path.open('wb') as file: + file.write(data) + logger.debug(f"Created file: {cache_filename}") return cache_filename + + def _extract_album_art(self, mp3_file_path: str) -> tuple: + try: + audio_file = MP3(mp3_file_path, ID3=ID3) + except Exception as e: + logger.error(f"Error reading MP3 file {mp3_file_path}: {e}") + return (NO_COVER_ART_EXTENSION, b'') + + for tag in audio_file.tags.values(): + if isinstance(tag, APIC): + mime_type = tag.mime + file_extension = 'jpg' if mime_type == 'image/jpeg' else mime_type.split('/')[-1] + return (file_extension, tag.data) + + return (NO_COVER_ART_EXTENSION, b'') + + def process_write_requests(self): + while True: + mp3_file_path = self.write_queue.get() + try: + self._save_to_cache(mp3_file_path) + except Exception as e: + logger.error(f"Error processing write request: {e}") + self.write_queue.task_done() + + def flush_cache(self): + for path in self.cache_folder_path.iterdir(): + if path.is_file(): + path.unlink() + logger.debug(f"Deleted cached file: {path.name}") + logger.info("Cache flushed successfully.") diff --git a/src/jukebox/components/rpc_command_alias.py b/src/jukebox/components/rpc_command_alias.py index f6e238559..5a7820733 100644 --- a/src/jukebox/components/rpc_command_alias.py +++ b/src/jukebox/components/rpc_command_alias.py @@ -75,6 +75,10 @@ 'method': 'repeat', 'note': 'Repeat', 'ignore_card_removal_action': True}, + 'flush_coverart_cache': { + 'package': 'player', + 'plugin': 'ctrl', + 'method': 'flush_coverart_cache'}, # VOLUME 'set_volume': { diff --git a/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js b/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js index 75882dd0d..2c6d99180 100644 --- a/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js +++ b/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js @@ -29,7 +29,9 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => { album: album }); if (result) { - setCoverImage(`/cover-cache/${result}`); + if(result !== 'CACHE_PENDING') { + setCoverImage(`/cover-cache/${result}`); + } }; } From afd0e474af59f193992865dd7f6190d140c9b5a4 Mon Sep 17 00:00:00 2001 From: s-martin Date: Fri, 12 Apr 2024 23:00:57 +0200 Subject: [PATCH 19/37] maint: Update actions (#2334) * Update action versions * Trigger python action * Update Python action * Use correct env variable * Revert change for triggering action --- .github/workflows/codeql-analysis_v3.yml | 6 +++--- .github/workflows/pythonpackage_future3.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codeql-analysis_v3.yml b/.github/workflows/codeql-analysis_v3.yml index d06cce1a5..a284d7691 100644 --- a/.github/workflows/codeql-analysis_v3.yml +++ b/.github/workflows/codeql-analysis_v3.yml @@ -38,7 +38,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies @@ -51,9 +51,9 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt - # Set the `CODEQL-PYTHON` environment variable to the Python executable + # Set the `CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION` environment variable to the Python executable # that includes the dependencies - echo "CODEQL_PYTHON=$(which python)" >> $GITHUB_ENV + echo "CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION=$(which python)" >> $GITHUB_ENV # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/pythonpackage_future3.yml b/.github/workflows/pythonpackage_future3.yml index da7007b97..3bc2c1174 100644 --- a/.github/workflows/pythonpackage_future3.yml +++ b/.github/workflows/pythonpackage_future3.yml @@ -22,9 +22,9 @@ jobs: python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies From a9ab571ac90cce21b94fef705855b9598c941a30 Mon Sep 17 00:00:00 2001 From: Christian Hoffmann Date: Fri, 12 Apr 2024 21:10:24 +0000 Subject: [PATCH 20/37] Fix PlayerMPD.prev/next() when stopped (#2326) * utils: Add get_config_action This abstracts away the functionality to resolve a given config option to an action in a pre-defined dict. Co-authored-by: Christian Hoffmann * Fix PlayerMPD.prev/next() when stopped * Avoid MPD-related crashes during all prev/next() calls. * Explicitly handle prev() in stopped state, configurable via `playermpd.stopped_prev_action`. * Explicitly handle next() in stopped state, configurable via `playermpd.stopped_next_action`. * Explicitly handle next() when reaching the end of the playlist: jukebox-daemon will now ignore the action by default (similar to v2). It can also be configured to rewind the playlist instead by setting the new config option `playermpd.end_of_playlist_next_action: rewind` or to stop playing. Fixes #2294 Fixes #2327 Co-authored-by: pabera <1260686+pabera@users.noreply.github.com> --------- Co-authored-by: pabera <1260686+pabera@users.noreply.github.com> --- .../default-settings/jukebox.default.yaml | 6 ++ src/jukebox/components/playermpd/__init__.py | 59 ++++++++++++++++++- src/jukebox/jukebox/utils.py | 13 ++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index c087cc024..b8e429333 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -87,6 +87,12 @@ playermpd: update_on_startup: true check_user_rights: true mpd_conf: ~/.config/mpd/mpd.conf + # Must be one of: 'none', 'stop', 'rewind': + end_of_playlist_next_action: none + # Must be one of: 'none', 'prev', 'rewind': + stopped_prev_action: prev + # Must be one of: 'none', 'next', 'rewind': + stopped_next_action: next rpc: tcp_port: 5555 websocket_port: 5556 diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py index dcbef2ea8..772b8c654 100644 --- a/src/jukebox/components/playermpd/__init__.py +++ b/src/jukebox/components/playermpd/__init__.py @@ -156,6 +156,28 @@ def __init__(self): self.second_swipe_action = None self.decode_2nd_swipe_option() + self.end_of_playlist_next_action = utils.get_config_action(cfg, + 'playermpd', + 'end_of_playlist_next_action', + {'rewind': self.rewind, + 'stop': self.stop, + 'none': lambda: None}, + logger) + self.stopped_prev_action = utils.get_config_action(cfg, + 'playermpd', + 'stopped_prev_action', + {'rewind': self.rewind, + 'prev': self._prev_in_stopped_state, + 'none': lambda: None}, + logger) + self.stopped_next_action = utils.get_current_song(cfg, + 'playermpd', + 'stopped_next_action', + {'rewind': self.rewind, + 'next': self._next_in_stopped_state, + 'none': lambda: None}, + logger) + self.mpd_client = mpd.MPDClient() self.coverart_cache_manager = CoverartCacheManager() @@ -327,15 +349,48 @@ def pause(self, state: int = 1): @plugs.tag def prev(self): logger.debug("Prev") + if self.mpd_status['state'] == 'stop': + logger.debug('Player is stopped, calling stopped_prev_action') + return self.stopped_prev_action() + try: + with self.mpd_lock: + self.mpd_client.previous() + except mpd.base.CommandError: + # This shouldn't happen in reality, but we still catch + # this error to avoid crashing the player thread: + logger.warning('Failed to go to previous song, ignoring') + + def _prev_in_stopped_state(self): with self.mpd_lock: - self.mpd_client.previous() + self.mpd_client.play(max(0, int(self.mpd_status['pos']) - 1)) @plugs.tag def next(self): """Play next track in current playlist""" logger.debug("Next") + if self.mpd_status['state'] == 'stop': + logger.debug('Player is stopped, calling stopped_next_action') + return self.stopped_next_action() + playlist_len = int(self.mpd_status.get('playlistlength', -1)) + current_pos = int(self.mpd_status.get('pos', 0)) + if current_pos == playlist_len - 1: + logger.debug(f'next() called during last song ({current_pos}) of ' + f'playlist (len={playlist_len}), running end_of_playlist_next_action.') + return self.end_of_playlist_next_action() + try: + with self.mpd_lock: + self.mpd_client.next() + except mpd.base.CommandError: + # This shouldn't happen in reality, but we still catch + # this error to avoid crashing the player thread: + logger.warning('Failed to go to next song, ignoring') + + def _next_in_stopped_state(self): + pos = int(self.mpd_status['pos']) + 1 + if pos > int(self.mpd_status['playlistlength']) - 1: + return self.end_of_playlist_next_action() with self.mpd_lock: - self.mpd_client.next() + self.mpd_client.play(pos) @plugs.tag def seek(self, new_time): diff --git a/src/jukebox/jukebox/utils.py b/src/jukebox/jukebox/utils.py index dbd647490..4cc0270ae 100644 --- a/src/jukebox/jukebox/utils.py +++ b/src/jukebox/jukebox/utils.py @@ -183,6 +183,19 @@ def rpc_call_to_str(cfg_rpc_call: Dict, with_args=True) -> str: return readable +def get_config_action(cfg, section, option, default, valid_actions_dict, logger): + """ + Looks up the given {section}.{option} config option and returns + the associated entry from valid_actions_dict, if valid. Falls back to the given + default otherwise. + """ + action = cfg.setndefault(section, option, value='').lower() + if action not in valid_actions_dict: + logger.error(f"Config {section}.{option} must be one of {valid_actions_dict.keys()}. Using default '{default}'") + action = default + return valid_actions_dict[action] + + def indent(doc, spaces=4): lines = doc.split('\n') for i in range(0, len(lines)): From fa110b49662cc4309c4e659fd2e2ef553820ea63 Mon Sep 17 00:00:00 2001 From: s-martin Date: Sat, 13 Apr 2024 23:05:42 +0200 Subject: [PATCH 21/37] fix a typo (#2336) --- documentation/developers/docker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/developers/docker.md b/documentation/developers/docker.md index 74d95c112..6a0af80c7 100644 --- a/documentation/developers/docker.md +++ b/documentation/developers/docker.md @@ -123,7 +123,7 @@ They can be run individually or in combination. To do that, we use load-module module-native-protocol-tcp auth-ip-acl=127.0.0.1 ``` -1. Edit `$INSTALL_DIR/etc/pulse//etc/pulse/daemon.conf`, find the +1. Edit `$INSTALL_DIR/etc/pulse/daemon.conf`, find the following line and change it to: ``` bash From 33fec6465a5ca66613172dae0d5d78f8ea87365b Mon Sep 17 00:00:00 2001 From: Christian Hoffmann Date: Mon, 15 Apr 2024 15:53:04 +0000 Subject: [PATCH 22/37] fix: bad utils.get_config_action invocations (#2339) --- src/jukebox/components/playermpd/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py index 772b8c654..86dbc60ab 100644 --- a/src/jukebox/components/playermpd/__init__.py +++ b/src/jukebox/components/playermpd/__init__.py @@ -159,6 +159,7 @@ def __init__(self): self.end_of_playlist_next_action = utils.get_config_action(cfg, 'playermpd', 'end_of_playlist_next_action', + 'none', {'rewind': self.rewind, 'stop': self.stop, 'none': lambda: None}, @@ -166,13 +167,15 @@ def __init__(self): self.stopped_prev_action = utils.get_config_action(cfg, 'playermpd', 'stopped_prev_action', + 'prev', {'rewind': self.rewind, 'prev': self._prev_in_stopped_state, 'none': lambda: None}, logger) - self.stopped_next_action = utils.get_current_song(cfg, + self.stopped_next_action = utils.get_config_action(cfg, 'playermpd', 'stopped_next_action', + 'next', {'rewind': self.rewind, 'next': self._next_in_stopped_state, 'none': lambda: None}, From 6003682479598e51631c79c9cff6e571a89ea2af Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sun, 21 Apr 2024 11:04:41 +0200 Subject: [PATCH 23/37] fix: docker build after pyzmq update (#2351) * fix: Add cmake as requirement to support pyzmq build * fix: Remove cmake and pin pyzmq<26 as cmake does not build well in RPI env --- docker/Dockerfile.jukebox | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile.jukebox b/docker/Dockerfile.jukebox index 3d63a4a02..194e2efdc 100644 --- a/docker/Dockerfile.jukebox +++ b/docker/Dockerfile.jukebox @@ -44,7 +44,7 @@ RUN pip install --no-cache-dir -r ${INSTALLATION_PATH}/requirements.txt ENV ZMQ_PREFIX /opt/libzmq ENV ZMQ_DRAFT_API 1 COPY --from=libzmq ${ZMQ_PREFIX} ${ZMQ_PREFIX} -RUN pip install -v pyzmq --no-binary pyzmq +RUN pip install -v "pyzmq<26" --no-binary pyzmq EXPOSE 5555 5556 WORKDIR ${INSTALLATION_PATH}/src/jukebox From aee32ff5f1653653c07274dc4077d57b5920b5ac Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sun, 21 Apr 2024 21:30:25 +0200 Subject: [PATCH 24/37] feat: Allow to enable/disable Cover Art in Web App & load from filesystem (#2352) * feat: Introduce Web App Settings * feat: Allow to enable/disable Cover Art in Web App * fix: handle Falsy mimetype and data even when APIC tag has been found my mutagen * feat: Try to load cover from filesystem when not found in audio file * fix: flake8 linting error * docs: Add documentation for Cover Art * feat: Allow show_covers setting to be managed in Web App * fix: again flake8 linting errors --- documentation/builders/README.md | 2 + documentation/builders/webapp/cover-art.md | 37 ++++++++++++ .../default-settings/jukebox.default.yaml | 4 ++ src/jukebox/components/misc.py | 19 +++++++ .../playermpd/coverart_cache_manager.py | 23 +++++++- src/webapp/public/locales/de/translation.json | 6 ++ src/webapp/public/locales/en/translation.json | 6 ++ src/webapp/src/App.js | 21 ++++--- src/webapp/src/commands/index.js | 13 +++++ .../albums/album-list/album-list-item.js | 21 +++++-- src/webapp/src/components/Player/index.js | 9 ++- .../src/components/Settings/general/index.js | 39 +++++++++++++ .../Settings/general/show-covers.js | 56 +++++++++++++++++++ src/webapp/src/components/Settings/index.js | 6 +- src/webapp/src/context/appsettings/context.js | 7 +++ src/webapp/src/context/appsettings/index.js | 33 +++++++++++ 16 files changed, 283 insertions(+), 19 deletions(-) create mode 100644 documentation/builders/webapp/cover-art.md create mode 100644 src/webapp/src/components/Settings/general/index.js create mode 100644 src/webapp/src/components/Settings/general/show-covers.js create mode 100644 src/webapp/src/context/appsettings/context.js create mode 100644 src/webapp/src/context/appsettings/index.js diff --git a/documentation/builders/README.md b/documentation/builders/README.md index 512d26ed0..29733e92b 100644 --- a/documentation/builders/README.md +++ b/documentation/builders/README.md @@ -30,6 +30,8 @@ ## Web Application +* Application + * [Cover Art](./webapp/cover-art.md) * Music * [Playlists, Livestreams and Podcasts](./webapp/playlists-livestreams-podcasts.md) diff --git a/documentation/builders/webapp/cover-art.md b/documentation/builders/webapp/cover-art.md new file mode 100644 index 000000000..09bae3a30 --- /dev/null +++ b/documentation/builders/webapp/cover-art.md @@ -0,0 +1,37 @@ +# Cover Art + +## Enable/Disable Cover Art + +The Web App automatically searches for cover art for albums and songs. If it finds cover art, it displays it; if not, it shows a placeholder image. However, you may prefer to disable cover art (e.g. in situations where device performance is low; screen space is limited; etc). There are two ways to do this: + +1. **Web App Settings**: Go to the "Settings" tab. Under the "General" section, find and toggle the "Show Cover Art" option. +1. **Configuration File**: Open the `jukebox.yaml` file. Navigate to `webapp` -> `show_covers`. Set this value to `true` to enable or `false` to disable cover art display. If this option does not exist, it assumes `true` as a default. + +## Providing Additional Cover Art + +Cover art can be provided in two ways: 1) embedded within the audio file itself, or 2) as a separate image file in the same directory as the audio file. The software searches for cover art in the order listed. + +To add cover art using the file system, place a file named `cover.jpg` in the same folder as your audio file or album. Accepted image file types are `jpg` and `png`. + +### Example + +Suppose none of your files currently include embedded cover art, the example below demonstrates how to enable cover art for an entire folder, applying the same cover art to all files within that folder. + +> [!IMPORTANT] +> You cannot assign different cover arts to different tracks within the same folder. + +#### Example Folder Structure + +```text +└── audiofolders + ├── Simone Sommerland + │ ├── 01 Aramsamsam.mp3 + │ ├── 02 Das Rote Pferd.mp3 + │ ├── 03 Hoch am Himmel.mp3 + │ └── cover.jpg <- Cover Art file as JPG + └── Bibi und Tina + ├── 01 Bibi und Tina Song.mp3 + ├── 02 Alles geht.mp3 + ├── 03 Solange dein Herz spricht.mp3 + └── cover.png <- Cover Art file as PNG +``` diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index b8e429333..9bb214f3d 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -153,3 +153,7 @@ sync_rfidcards: config_file: ../../shared/settings/sync_rfidcards.yaml webapp: coverart_cache_path: ../../src/webapp/build/cover-cache + # Load cover arts in Webapp. Change to false in case you have performance issue + # when handling a lot of music + # Defaults to true + show_covers: true diff --git a/src/jukebox/components/misc.py b/src/jukebox/components/misc.py index 9995509aa..2cc260d79 100644 --- a/src/jukebox/components/misc.py +++ b/src/jukebox/components/misc.py @@ -8,8 +8,10 @@ import jukebox.plugs as plugin import jukebox.utils from jukebox.daemon import get_jukebox_daemon +import jukebox.cfghandler logger = logging.getLogger('jb.misc') +cfg = jukebox.cfghandler.get_handler('jukebox') @plugin.register @@ -105,3 +107,20 @@ def empty_rpc_call(msg: str = ''): """ if msg: logger.warning(msg) + + +@plugin.register +def get_app_settings(): + """Return settings for web app stored in jukebox.yaml""" + show_covers = cfg.setndefault('webapp', 'show_covers', value=True) + + return { + 'show_covers': show_covers + } + + +@plugin.register +def set_app_settings(settings={}): + """Set configuration settings for the web app.""" + for key, value in settings.items(): + cfg.setn('webapp', key, value=value) diff --git a/src/jukebox/components/playermpd/coverart_cache_manager.py b/src/jukebox/components/playermpd/coverart_cache_manager.py index bb2346497..f292a2bbe 100644 --- a/src/jukebox/components/playermpd/coverart_cache_manager.py +++ b/src/jukebox/components/playermpd/coverart_cache_manager.py @@ -47,7 +47,10 @@ def save_to_cache(self, mp3_file_path: str): def _save_to_cache(self, mp3_file_path: str): base_filename = Path(mp3_file_path).stem cache_key = self.generate_cache_key(base_filename) + file_extension, data = self._extract_album_art(mp3_file_path) + if file_extension == NO_COVER_ART_EXTENSION: # Check if cover has been added as separate file in folder + file_extension, data = self._get_from_filesystem(mp3_file_path) cache_filename = f"{cache_key}.{file_extension}" full_path = self.cache_folder_path / cache_filename # Works due to Pathlib @@ -67,9 +70,23 @@ def _extract_album_art(self, mp3_file_path: str) -> tuple: for tag in audio_file.tags.values(): if isinstance(tag, APIC): - mime_type = tag.mime - file_extension = 'jpg' if mime_type == 'image/jpeg' else mime_type.split('/')[-1] - return (file_extension, tag.data) + if tag.mime and tag.data: + file_extension = 'jpg' if tag.mime == 'image/jpeg' else tag.mime.split('/')[-1] + return (file_extension, tag.data) + + return (NO_COVER_ART_EXTENSION, b'') + + def _get_from_filesystem(self, mp3_file_path: str) -> tuple: + path = Path(mp3_file_path) + directory = path.parent + cover_files = list(directory.glob('Cover.*')) + list(directory.glob('cover.*')) + + for file in cover_files: + if file.suffix.lower() in ['.jpg', '.jpeg', '.png']: + with file.open('rb') as img_file: + data = img_file.read() + file_extension = file.suffix[1:] # Get extension without dot + return (file_extension, data) return (NO_COVER_ART_EXTENSION, b'') diff --git a/src/webapp/public/locales/de/translation.json b/src/webapp/public/locales/de/translation.json index d1a4391d6..7dbdcf695 100644 --- a/src/webapp/public/locales/de/translation.json +++ b/src/webapp/public/locales/de/translation.json @@ -219,6 +219,12 @@ "why": "Warum?", "control-label": "Auto Hotspot" }, + "general": { + "title": "Allgmeine Einstellungen", + "show_covers": { + "title": "Cover anzeigen" + } + }, "timers": { "option-label-timeslot": "{{value}} min", "option-label-off": "Aus", diff --git a/src/webapp/public/locales/en/translation.json b/src/webapp/public/locales/en/translation.json index 74fd9a696..7ff66ecc4 100644 --- a/src/webapp/public/locales/en/translation.json +++ b/src/webapp/public/locales/en/translation.json @@ -219,6 +219,12 @@ "why": "Why?", "control-label": "Auto Hotspot" }, + "general": { + "title": "General Settings", + "show_covers": { + "title": "Show Cover Art" + } + }, "timers": { "option-label-timeslot": "{{value}} min", "option-label-off": "Off", diff --git a/src/webapp/src/App.js b/src/webapp/src/App.js index 99272db64..a51529381 100644 --- a/src/webapp/src/App.js +++ b/src/webapp/src/App.js @@ -2,6 +2,7 @@ import React, { Suspense } from 'react'; import Grid from '@mui/material/Grid'; +import AppSettingsProvider from './context/appsettings'; import PubSubProvider from './context/pubsub'; import PlayerProvider from './context/player'; import Router from './router'; @@ -10,15 +11,17 @@ function App() { return ( - - - + + + + + ); diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index 8c844d8da..f6f772875 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -120,6 +120,7 @@ const commands = { _package: 'volume', plugin: 'ctrl', method: 'set_volume', + argKeys: ['volume'], }, getVolume: { _package: 'volume', @@ -250,6 +251,18 @@ const commands = { argKeys: ['option'], }, + // Misc + getAppSettings: { + _package: 'misc', + plugin: 'get_app_settings' + }, + + setAppSettings: { + _package: 'misc', + plugin: 'set_app_settings', + argKeys: ['settings'], + }, + // Synchronisation 'sync_rfidcards_all': { _package: 'sync_rfidcards', diff --git a/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js b/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js index 2c6d99180..71f6ba315 100644 --- a/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js +++ b/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js @@ -1,4 +1,4 @@ -import React, { forwardRef, useEffect, useState } from 'react'; +import React, { forwardRef, useContext, useEffect, useState } from 'react'; import { Link, useLocation, @@ -15,6 +15,7 @@ import { import noCover from '../../../../../assets/noCover.jpg'; +import AppSettingsContext from '../../../../../context/appsettings/context'; import request from '../../../../../utils/request'; const AlbumListItem = ({ albumartist, album, isButton = true }) => { @@ -22,6 +23,14 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => { const { search: urlSearch } = useLocation(); const [coverImage, setCoverImage] = useState(noCover); + const { + settings, + } = useContext(AppSettingsContext); + + const { + show_covers, + } = settings; + useEffect(() => { const getCoverArt = async () => { const { result } = await request('getAlbumCoverArt', { @@ -35,7 +44,7 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => { }; } - if (albumartist && album) { + if (albumartist && album && show_covers) { getCoverArt(); } }, [albumartist, album]); @@ -61,9 +70,11 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => { key={album} > - - - + {show_covers && + + + + } { const [coverImage, setCoverImage] = useState(undefined); const [backgroundImage, setBackgroundImage] = useState('none'); + const { + settings, + } = useContext(AppSettingsContext); + + const { show_covers } = settings; + useEffect(() => { const getCoverArt = async () => { const { result } = await request('getSingleCoverArt', { song_url: file }); @@ -30,7 +37,7 @@ const Player = () => { }; } - if (file) { + if (file && show_covers) { getCoverArt(); } }, [file]); diff --git a/src/webapp/src/components/Settings/general/index.js b/src/webapp/src/components/Settings/general/index.js new file mode 100644 index 000000000..790043778 --- /dev/null +++ b/src/webapp/src/components/Settings/general/index.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useTheme } from '@mui/material/styles'; + +import { + Card, + CardContent, + CardHeader, + Divider, + Grid, +} from '@mui/material'; +import ShowCovers from './show-covers'; + +const SettingsGeneral = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const spacer = { marginBottom: theme.spacing(2) } + + return ( + + + + + .MuiGrid-root:not(:last-child)': spacer }} + > + + + + + ); +}; + +export default SettingsGeneral; diff --git a/src/webapp/src/components/Settings/general/show-covers.js b/src/webapp/src/components/Settings/general/show-covers.js new file mode 100644 index 000000000..a3b31f4e0 --- /dev/null +++ b/src/webapp/src/components/Settings/general/show-covers.js @@ -0,0 +1,56 @@ +import React, { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + Box, + Grid, + Switch, + Typography, +} from '@mui/material'; + +import AppSettingsContext from '../../../context/appsettings/context'; +import request from '../../../utils/request'; + +const ShowCovers = () => { + const { t } = useTranslation(); + + const { + settings, + setSettings, + } = useContext(AppSettingsContext); + + const { + show_covers, + } = settings; + + const updateShowCoversSetting = async (show_covers) => { + await request('setAppSettings', { settings: { show_covers }}); + } + + const handleSwitch = (event) => { + setSettings({ show_covers: event.target.checked}); + updateShowCoversSetting(event.target.checked); + } + + return ( + + + + {t(`settings.general.show_covers.title`)} + + + + + + + ); +}; + +export default ShowCovers; diff --git a/src/webapp/src/components/Settings/index.js b/src/webapp/src/components/Settings/index.js index 1bc599fc1..75ce7840f 100644 --- a/src/webapp/src/components/Settings/index.js +++ b/src/webapp/src/components/Settings/index.js @@ -2,9 +2,10 @@ import React from 'react'; import { Grid } from '@mui/material'; +import SettingsAudio from './audio/index'; import SettingsAutoHotspot from './autohotspot'; +import SettingsGeneral from './general'; import SettingsSecondSwipe from './secondswipe'; -import SettingsAudio from './audio/index'; import SettingsStatus from './status/index'; import SettingsTimers from './timers/index'; import SystemControls from './systemcontrols'; @@ -28,6 +29,9 @@ const Settings = () => { + + + diff --git a/src/webapp/src/context/appsettings/context.js b/src/webapp/src/context/appsettings/context.js new file mode 100644 index 000000000..f2650d210 --- /dev/null +++ b/src/webapp/src/context/appsettings/context.js @@ -0,0 +1,7 @@ +import { createContext } from 'react'; + +const AppSettingsContext = createContext({ + showCovers: true, +}); + +export default AppSettingsContext; diff --git a/src/webapp/src/context/appsettings/index.js b/src/webapp/src/context/appsettings/index.js new file mode 100644 index 000000000..1fa34914d --- /dev/null +++ b/src/webapp/src/context/appsettings/index.js @@ -0,0 +1,33 @@ +import React, { useEffect, useState } from 'react'; + +import AppSettingsContext from './context'; +import request from '../../utils/request'; + +const AppSettingsProvider = ({ children }) => { + const [settings, setSettings] = useState({}); + + useEffect(() => { + const loadAppSettings = async () => { + const { result, error } = await request('getAppSettings'); + if(result) setSettings(result); + if(error) { + console.error('Error loading AppSettings'); + } + } + + loadAppSettings(); + }, []); + + const context = { + setSettings, + settings, + }; + + return( + + { children } + + ) +}; + +export default AppSettingsProvider; From 141a05fe1e676bd7951e9396bb2b53e3165d4ac0 Mon Sep 17 00:00:00 2001 From: s-martin Date: Sat, 4 May 2024 00:49:37 +0200 Subject: [PATCH 25/37] Remove setup-python-dependencies (#2363) --- .github/workflows/codeql-analysis_v3.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/codeql-analysis_v3.yml b/.github/workflows/codeql-analysis_v3.yml index a284d7691..574f2b2bd 100644 --- a/.github/workflows/codeql-analysis_v3.yml +++ b/.github/workflows/codeql-analysis_v3.yml @@ -64,7 +64,6 @@ jobs: # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main - setup-python-dependencies: false # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) From a1b2df85e63d81dc83ad47e7a079228aa5580354 Mon Sep 17 00:00:00 2001 From: s-martin Date: Sun, 5 May 2024 23:48:04 +0200 Subject: [PATCH 26/37] Fix links (#2366) --- documentation/developers/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/developers/README.md b/documentation/developers/README.md index ff21a8ceb..412cc66a9 100644 --- a/documentation/developers/README.md +++ b/documentation/developers/README.md @@ -4,7 +4,7 @@ * [Development Environment](./development-environment.md) * [Python Development Notes](python.md) -* [Documentation (with Markdown)](documentatíon.md) +* [Documentation (with Markdown)](documentation.md) ## Reference @@ -12,7 +12,7 @@ * [Web App](./webapp.md) * [RFID Readers](./rfid/README.md) * [Docstring API Docs (from py files)](./docstring/README.md) -* [Plugin Reference](./docstring/README.md#jukeboxplugs) +* [Plugin Reference](./docstring/README.md#jukebox.plugs) * [Feature Status](./status.md) * [Known Issues](./known-issues.md) From 2232a35e0b629a5560577242dc2a9e7cdc8ac3e4 Mon Sep 17 00:00:00 2001 From: s-martin Date: Fri, 17 May 2024 19:39:40 +0200 Subject: [PATCH 27/37] add details about cards for RC522 (#2372) * add details about cards * fix empty line * Update documentation/developers/rfid/mfrc522_spi.md Co-authored-by: Alvin Schiller <103769832+AlvinSchiller@users.noreply.github.com> --------- Co-authored-by: Alvin Schiller <103769832+AlvinSchiller@users.noreply.github.com> --- documentation/developers/rfid/mfrc522_spi.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/documentation/developers/rfid/mfrc522_spi.md b/documentation/developers/rfid/mfrc522_spi.md index 363df3836..be3abeaa8 100644 --- a/documentation/developers/rfid/mfrc522_spi.md +++ b/documentation/developers/rfid/mfrc522_spi.md @@ -78,3 +78,7 @@ MISO. MFRC522 boards can be picked up from many places for little money. Good quality ones can be found e.g. here + +### Cards/Tags + +Cards or tags must support 13.56 MHz. Currently, only cards/tags of the type "NXP Mifare Classic 1k(S50)", "NXP Mifare Classic 4k(S70)" and "NXP Mifare Ultralight (C)" can be used. Type "NXP Mifare NTAG2xx" or others will not work! From d93f07111ec54bf772711aac4208376041be2970 Mon Sep 17 00:00:00 2001 From: s-martin Date: Wed, 5 Jun 2024 08:10:33 +0200 Subject: [PATCH 28/37] Remove unused read of parameter (#2382) --- src/jukebox/components/rfid/hardware/generic_usb/generic_usb.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/jukebox/components/rfid/hardware/generic_usb/generic_usb.py b/src/jukebox/components/rfid/hardware/generic_usb/generic_usb.py index f6d0a5db5..9f176566d 100755 --- a/src/jukebox/components/rfid/hardware/generic_usb/generic_usb.py +++ b/src/jukebox/components/rfid/hardware/generic_usb/generic_usb.py @@ -140,8 +140,6 @@ def __init__(self, reader_cfg_key, logger=None): raise KeyError("Mandatory key 'device_name' not given in configuration!") if 'device_phys' not in config: self._logger.warning("Key 'device_phys' not given in configuration! Trying without...") - if 'key_capability' not in config: - self._logger.warning("Key 'key_capability' not given in configuration! Using default value: 'true'.") if 'name_is_unique' not in config: self._logger.warning("Key 'name_is_unique' not given in configuration! Using default value: 'true'.") if 'key_check_is_unique' not in config: From f2a1730a8df693350aef14aa6df6f563ffe6f786 Mon Sep 17 00:00:00 2001 From: Timm Date: Sat, 8 Jun 2024 09:50:05 +0200 Subject: [PATCH 29/37] feat: Add INA219 battery sensor (#2380) * specify specific pip version wich works with zmq install * add INA219 sensor * average measurement for more accurate results. Use supply voltage for measurement. * Revert "specify specific pip version wich works with zmq install" This reverts commit 48dd1bfc6c73f9a6fb1329ae6528e495b07b36e5. * correct format * Update src/jukebox/components/battery_monitor/batt_mon_i2c_ina219/__init__.py Co-authored-by: s-martin * Update copyright notice * Update license * Update license * Document the INA219 * add error handling and type safety * Update batterymonitor.md * Update __init__.py * fix markdown lint for batterymonitor.md * fix markdown lint batterymonitor.md * Update documentation/builders/components/power/batterymonitor.md Co-authored-by: s-martin * Update documentation/builders/components/power/batterymonitor.md Co-authored-by: s-martin --------- Co-authored-by: Timm Co-authored-by: s-martin --- .../components/power/batterymonitor.md | 59 +++++++++++++++++-- .../batt_mon_i2c_ina219/__init__.py | 56 ++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 src/jukebox/components/battery_monitor/batt_mon_i2c_ina219/__init__.py diff --git a/documentation/builders/components/power/batterymonitor.md b/documentation/builders/components/power/batterymonitor.md index d57e14acb..151010caa 100644 --- a/documentation/builders/components/power/batterymonitor.md +++ b/documentation/builders/components/power/batterymonitor.md @@ -1,13 +1,15 @@ -# Battery Monitor based on a ADS1015 +# Battery Monitor > [!CAUTION] > Lithium and other batteries are dangerous and must be treated with care. > Rechargeable Lithium Ion batteries are potentially hazardous and can -> present a serious **FIRE HAZARD** if damaged, defective or improperly used. -> Do not use this circuit to a lithium ion battery without expertise and -> training in handling and use of batteries of this type. +> present a serious **FIRE HAZARD** if damaged, defective, or improperly used. +> Do not use this circuit for a lithium-ion battery without the expertise and +> training in handling and using batteries of this type. > Use appropriate test equipment and safety protocols during development. -> There is no warranty, this may not work as expected or at all! +> There is no warranty, this may not work as expected! + +## Battery Monitor based on a ADS1015 The script in [src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/\_\_init\_\_.py](../../../../src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/__init__.py) is intended to read out the voltage of a single Cell LiIon Battery using a [CY-ADS1015 Board](https://www.adafruit.com/product/1083): @@ -31,3 +33,50 @@ The script in [src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/\_\_i > > * the circuit is constantly draining the battery! (leak current up to: 2.1µA) > * the time between sample needs to be a minimum 1sec with this high impedance voltage divider don't use the continuous conversion method! + +## Battery Monitor based on an INA219 + +The script in [src/jukebox/components/battery_monitor/batt_mon_i2c_ina219/\_\_init\_\_.py](../../../../src/jukebox/components/battery_monitor/batt_mon_i2c_ina219/__init__.py) is intended to read out the voltage of a single cell or multiple LiIon Battery using a [INA219 Board](https://www.adafruit.com/product/904): + +```text + 3.3V + + + | + .----o----. + | | SDA + .-------------------------------o AIN o------ + | | INA219 | SCL + | .----------o AOUT o------ + --- | | | + Battery - Regulator + Raspi '----o----' + 2.9V-4.2V| | | + | | | + === === === +``` + +## Configuration example + +The battery monitoring is configured in the jukebox.yml file. + +The "battmon" module has to be added to the modules setting. + +```yaml +modules: + named: + # Do not change the order! + publishing: publishing + ... + battmon: battery_monitor.batt_mon_i2c_ina219 +``` + +The battmon module needs further configuration: + +```yaml +battmon: + scale_to_phy_num: 1 + scale_to_phy_denom: 0 + warning_action: + all_clear_action: +``` + +The setting "scale_to_phy_denom" does not influence the INA219. However, the scale can be adjusted to fit multiple LiIon cells. diff --git a/src/jukebox/components/battery_monitor/batt_mon_i2c_ina219/__init__.py b/src/jukebox/components/battery_monitor/batt_mon_i2c_ina219/__init__.py new file mode 100644 index 000000000..c9b51eece --- /dev/null +++ b/src/jukebox/components/battery_monitor/batt_mon_i2c_ina219/__init__.py @@ -0,0 +1,56 @@ +# RPi-Jukebox-RFID Version 3 +# Copyright (c) See file LICENSE in project root folder + +import logging +import jukebox.plugs as plugs +import jukebox.cfghandler +from ina219 import INA219 +from ina219 import DeviceRangeError +from components.battery_monitor import BatteryMonitorBase + +logger = logging.getLogger('jb.battmon.ina219') + +batt_mon = None + + +class battmon_ina219(BatteryMonitorBase.BattmonBase): + '''Battery Monitor based on a INA219 + + See [Battery Monitor documentation](../../builders/components/power/batterymonitor.md) + ''' + + def __init__(self, cfg): + super().__init__(cfg, logger) + + def init_batt_mon_hw(self, num: float, denom: float) -> None: + try: + self.adc = INA219(float(num) / 1000, busnum=1) + self.adc.configure(self.adc.RANGE_16V, self.adc.GAIN_AUTO, self.adc.ADC_32SAMP, self.adc.ADC_32SAMP) + except DeviceRangeError as e: + logger.error(f"Device range error: {e}") + raise + except Exception as e: + logger.error(f"Failed to initialize INA219: {e}") + raise + + def get_batt_voltage(self) -> int: + try: + batt_voltage_mV = self.adc.supply_voltage() * 1000.0 + return int(batt_voltage_mV) + except Exception as e: + logger.error(f"Failed to get supply voltage from INA219: {e}") + raise + + +@plugs.finalize +def finalize(): + global batt_mon + cfg = jukebox.cfghandler.get_handler('jukebox') + batt_mon = battmon_ina219(cfg) + plugs.register(batt_mon, name='batt_mon') + + +@plugs.atexit +def atexit(**ignored_kwargs): + global batt_mon + batt_mon.status_thread.cancel() From a216c786d09b234318db9fae707a1fc641a03a78 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Sun, 3 Nov 2024 17:35:21 +0100 Subject: [PATCH 30/37] Docker semantic fix (#2451) --- docker/Dockerfile.jukebox | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile.jukebox b/docker/Dockerfile.jukebox index 194e2efdc..9cee52d1a 100644 --- a/docker/Dockerfile.jukebox +++ b/docker/Dockerfile.jukebox @@ -1,4 +1,4 @@ -FROM libzmq:local as libzmq +FROM libzmq:local AS libzmq FROM debian:bullseye-slim # These are only dependencies that are required to get as close to the From 32798da97e4e5c857bbdf74c1e608acf50ed4772 Mon Sep 17 00:00:00 2001 From: Nils Mittler <70568139+mittler-works@users.noreply.github.com> Date: Thu, 7 Nov 2024 10:44:36 +0100 Subject: [PATCH 31/37] Future3/lift 64bit restriction (#2425) * Remove 64bit check in installer script * Check presence of raspi.list in raspian check * fix: update check for debian derived os. integrate checks RPiOS: 32-bit = raspbian, 64-bit = debian --------- Co-authored-by: Alvin Schiller <103769832+AlvinSchiller@users.noreply.github.com> --- installation/includes/02_helpers.sh | 18 +++--------------- installation/install-jukebox.sh | 18 ------------------ 2 files changed, 3 insertions(+), 33 deletions(-) diff --git a/installation/includes/02_helpers.sh b/installation/includes/02_helpers.sh index dfad65187..4fd373565 100644 --- a/installation/includes/02_helpers.sh +++ b/installation/includes/02_helpers.sh @@ -75,23 +75,11 @@ get_architecture() { echo $arch } -is_raspbian() { - if [[ $( . /etc/os-release; printf '%s\n' "$ID"; ) == *"raspbian"* ]]; then - echo true - else - echo false - fi -} - -get_debian_version_number() { - source /etc/os-release - echo "$VERSION_ID" -} - _get_boot_file_path() { local filename="$1" - if [ "$(is_raspbian)" = true ]; then - local debian_version_number=$(get_debian_version_number) + local os_release_id=$( . /etc/os-release; printf '%s\n' "$ID"; ) + if [[ "$os_release_id" == *"raspbian"* ]] || [[ "$os_release_id" == *"debian"* ]]; then + local debian_version_number=$( . /etc/os-release; printf '%s\n' "$VERSION_ID"; ) # Bullseye and lower if [ "$debian_version_number" -le 11 ]; then diff --git a/installation/install-jukebox.sh b/installation/install-jukebox.sh index 1ab96f3a1..c3610662b 100755 --- a/installation/install-jukebox.sh +++ b/installation/install-jukebox.sh @@ -86,23 +86,6 @@ Check install log for details:" exit 1 } -# Check if current distro is a 32-bit version -# Support for 64-bit Distros has not been checked (or precisely: is known not to work) -# All Raspberry Pi OS versions report as machine "armv6l" or "armv7l", if 32-bit (even the ARMv8 cores!) -_check_os_type() { - local os_type=$(uname -m) - - print_lc "\nChecking OS type '$os_type'" - - if [[ $os_type == "armv7l" || $os_type == "armv6l" ]]; then - print_lc " ... OK!\n" - else - print_lc "ERROR: Only 32-bit operating systems are supported. Please use a 32-bit version of Raspberry Pi OS!" - print_lc "For Pi 4 models or newer running a 64-bit kernels, also see this: https://github.com/MiczFlor/RPi-Jukebox-RFID/issues/2041" - exit 1 - fi -} - _check_existing_installation() { if [[ -e "${INSTALLATION_PATH}" ]]; then print_lc " @@ -154,7 +137,6 @@ _load_sources() { _setup_logging ### CHECK PREREQUISITE -_check_os_type _check_existing_installation ### RUN INSTALLATION From bdc1a2398f562c728b27abb3720542ae97db5c8c Mon Sep 17 00:00:00 2001 From: s-martin Date: Thu, 7 Nov 2024 22:25:53 +0100 Subject: [PATCH 32/37] Use correct raspi-config command for bookworm (#2450) * use correct raspi-config command for bookworm * simplify _get_boot_file_path * fix path * revert last commit * merge fixes * merge fixes --------- Co-authored-by: Alvin Schiller <103769832+AlvinSchiller@users.noreply.github.com> --- installation/includes/02_helpers.sh | 43 +++++++++++++------ .../rfid/hardware/rdm6300_serial/setup.inc.sh | 10 ++++- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/installation/includes/02_helpers.sh b/installation/includes/02_helpers.sh index 4fd373565..440e10b08 100644 --- a/installation/includes/02_helpers.sh +++ b/installation/includes/02_helpers.sh @@ -75,23 +75,42 @@ get_architecture() { echo $arch } -_get_boot_file_path() { - local filename="$1" +is_debian_based() { local os_release_id=$( . /etc/os-release; printf '%s\n' "$ID"; ) if [[ "$os_release_id" == *"raspbian"* ]] || [[ "$os_release_id" == *"debian"* ]]; then + echo true + else + echo false + fi +} + +_get_debian_version_number() { + if [ "$(is_debian_based)" = true ]; then local debian_version_number=$( . /etc/os-release; printf '%s\n' "$VERSION_ID"; ) + echo "$debian_version_number" + else + echo "-1" + fi +} - # Bullseye and lower - if [ "$debian_version_number" -le 11 ]; then - echo "/boot/${filename}" - # Bookworm and higher - elif [ "$debian_version_number" -ge 12 ]; then - echo "/boot/firmware/${filename}" - else - echo "unknown" - fi +is_debian_version_at_least() { + local expected_version=$1 + local debian_version_number=$(get_debian_version_number) + + if [ "$debian_version_number" -ge "$expected_version" ]; then + echo true + else + echo false + fi +} + +_get_boot_file_path() { + local filename="$1" + local is_debian_version_number_at_least_12=$(is_debian_version_at_least 12) + if [ "$(is_debian_version_number_at_least_12)" = true ]; then + echo "/boot/firmware/${filename}" else - echo "unknown" + echo "/boot/${filename}" fi } diff --git a/src/jukebox/components/rfid/hardware/rdm6300_serial/setup.inc.sh b/src/jukebox/components/rfid/hardware/rdm6300_serial/setup.inc.sh index 364f07cd9..c9935c739 100755 --- a/src/jukebox/components/rfid/hardware/rdm6300_serial/setup.inc.sh +++ b/src/jukebox/components/rfid/hardware/rdm6300_serial/setup.inc.sh @@ -1,9 +1,17 @@ #!/usr/bin/env bash +source ../../../../../../installation/includes/02_helpers.sh + echo "Entering setup.inc.sh" echo "Disabling login shell to be accessible over serial" -sudo raspi-config nonint do_serial 1 + +if [ "$(is_debian_version_at_least 12)" = true ]; then + sudo raspi-config nonint do_serial_hw 1 + sudo raspi-config nonint do_serial_cons 1 +else + sudo raspi-config nonint do_serial 1 +end echo "Enabling serial port hardware" sudo raspi-config nonint set_config_var enable_uart 1 /boot/config.txt From 0df1a0c9d34f7dd454c7f5f617702ecf05a31160 Mon Sep 17 00:00:00 2001 From: Christian Hoffmann Date: Sun, 10 Nov 2024 20:30:58 +0000 Subject: [PATCH 33/37] feat: Add Idle Shutdown Timer support (#2332) * feat: Add Idle Shutdown Timer support This adds an optional idle shutdown timer which can be enabled via timers.idle_shutdown.timeout_sec in the jukebox.yaml config. The system will shut down after the given number of seconds if no activity has been detected during that time. Activity is defined as: - music playing - active SSH sessions - changes in configs or audio content. Fixes: #1970 * refactor: Break down IdleTimer into 2 standard GenericMultiTimerClass and GenericEndlessTimerClass timers * feat: Introducing new Timer UI, including Idle Shutdown * refactor: Abstract into functions * Adding Sleep timer / not functional * Finalize Volume Fadeout Shutdown timer * Fix flake8 * Fix more flake8s * Fix small bugs * Improve multitimer.py suggested by #2386 * Fix flake8 --------- Co-authored-by: pabera <1260686+pabera@users.noreply.github.com> --- documentation/developers/docker.md | 19 +- documentation/developers/status.md | 3 +- .../default-settings/jukebox.default.yaml | 4 + src/jukebox/components/timers/__init__.py | 57 +-- .../components/timers/idle_shutdown_timer.py | 194 ++++++++++ .../timers/volume_fadeout_shutdown_timer.py | 206 ++++++++++ src/jukebox/jukebox/daemon.py | 2 +- src/jukebox/jukebox/multitimer.py | 356 ++++++++++++++---- src/webapp/public/locales/de/translation.json | 37 +- src/webapp/public/locales/en/translation.json | 33 +- src/webapp/src/commands/index.js | 19 + .../src/components/Settings/timers/index.js | 21 +- .../Settings/timers/set-timer-dialog.js | 101 +++++ .../src/components/Settings/timers/timer.js | 208 +++++----- .../src/components/general/Countdown.js | 2 +- 15 files changed, 1029 insertions(+), 233 deletions(-) create mode 100644 src/jukebox/components/timers/idle_shutdown_timer.py create mode 100644 src/jukebox/components/timers/volume_fadeout_shutdown_timer.py create mode 100644 src/webapp/src/components/Settings/timers/set-timer-dialog.js diff --git a/documentation/developers/docker.md b/documentation/developers/docker.md index 6a0af80c7..5f8b6f845 100644 --- a/documentation/developers/docker.md +++ b/documentation/developers/docker.md @@ -230,6 +230,21 @@ would be of course useful to get rid of them, but currently we make a trade-off between a development environment and solving the specific details. +### Error when local libzmq Dockerfile has not been built: + +``` bash +------ + > [jukebox internal] load metadata for docker.io/library/libzmq:local: +------ +failed to solve: libzmq:local: pull access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failed +``` + +Build libzmq for your host machine + +``` bash +docker build -f docker/Dockerfile.libzmq -t libzmq:local . +``` + ### `mpd` container #### Pulseaudio issue on Mac @@ -286,7 +301,7 @@ Error starting userland proxy: listen tcp4 0.0.0.0:6600: bind: address already i Read these threads for details: [thread 1](https://unix.stackexchange.com/questions/456909/socket-already-in-use-but-is-not-listed-mpd) and [thread 2](https://stackoverflow.com/questions/5106674/error-address-already-in-use-while-binding-socket-with-address-but-the-port-num/5106755#5106755) -#### Other error messages +#### MPD issues When starting the `mpd` container, you will see the following errors. You can ignore them, MPD will run. @@ -309,7 +324,7 @@ mpd | alsa_mixer: snd_mixer_handle_events() failed: Input/output error mpd | exception: Failed to read mixer for 'My ALSA Device': snd_mixer_handle_events() failed: Input/output error ``` -### `jukebox` container +#### `jukebox` container Many features of the Phoniebox are based on the Raspberry Pi hardware. This hardware can\'t be mocked in a virtual Docker environment. As a diff --git a/documentation/developers/status.md b/documentation/developers/status.md index 0a40f8125..146e5c50c 100644 --- a/documentation/developers/status.md +++ b/documentation/developers/status.md @@ -166,7 +166,8 @@ Topics marked _in progress_ are already in the process of implementation by comm - [x] Publish mechanism of timer status - [x] Change multitimer function call interface such that endless timer etc. won't pass the `iteration` kwarg - [ ] Make timer settings persistent -- [ ] Idle timer +- [x] Idle timer (basic implementation covering player, SSH, config and audio content changes) +- [ ] Idle timer: Do we need further extensions? - This needs clearer specification: Idle is when no music is playing and no user interaction is taking place - i.e., needs information from RPC AND from player status. Let's do this when we see a little clearer about Spotify diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index 9bb214f3d..d3326ef55 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -108,6 +108,10 @@ gpioz: enable: false config_file: ../../shared/settings/gpio.yaml timers: + idle_shutdown: + # If you want the box to shutdown on inactivity automatically, configure timeout_sec with a number of seconds (at least 60). + # Inactivity is defined as: no music playing, no active SSH sessions, no changes in configs or audio content. + timeout_sec: 0 # These timers are always disabled after start # The following values only give the default values. # These can be changed when enabling the respective timer on a case-by-case basis w/o saving diff --git a/src/jukebox/components/timers/__init__.py b/src/jukebox/components/timers/__init__.py index bef7ccf81..4fe70aa80 100644 --- a/src/jukebox/components/timers/__init__.py +++ b/src/jukebox/components/timers/__init__.py @@ -1,10 +1,12 @@ # RPi-Jukebox-RFID Version 3 # Copyright (c) See file LICENSE in project root folder -from jukebox.multitimer import (GenericTimerClass, GenericMultiTimerClass) import logging import jukebox.cfghandler import jukebox.plugs as plugin +from jukebox.multitimer import GenericTimerClass +from .idle_shutdown_timer import IdleShutdownTimer +from .volume_fadeout_shutdown_timer import VolumeFadoutAndShutdown logger = logging.getLogger('jb.timers') @@ -24,35 +26,18 @@ def stop_player(): plugin.call_ignore_errors('player', 'ctrl', 'stop') -class VolumeFadeOutActionClass: - def __init__(self, iterations): - self.iterations = iterations - # Get the current volume, calculate step size - self.volume = plugin.call('volume', 'ctrl', 'get_volume') - self.step = float(self.volume) / iterations - - def __call__(self, iteration): - self.volume = self.volume - self.step - logger.debug(f"Decrease volume to {self.volume} (Iteration index {iteration}/{self.iterations}-1)") - plugin.call_ignore_errors('volume', 'ctrl', 'set_volume', args=[int(self.volume)]) - if iteration == 0: - logger.debug("Shut down from volume fade out") - plugin.call_ignore_errors('host', 'shutdown') - - # --------------------------------------------------------------------------- # Create the timers # --------------------------------------------------------------------------- timer_shutdown: GenericTimerClass timer_stop_player: GenericTimerClass -timer_fade_volume: GenericMultiTimerClass +timer_fade_volume: VolumeFadoutAndShutdown +timer_idle_shutdown: IdleShutdownTimer @plugin.finalize def finalize(): - # TODO: Example with how to call the timers from RPC? - - # Create the various timers with fitting doc for plugin reference + # Shutdown Timer global timer_shutdown timeout = cfg.setndefault('timers', 'shutdown', 'default_timeout_sec', value=60 * 60) timer_shutdown = GenericTimerClass(f"{plugin.loaded_as(__name__)}.timer_shutdown", @@ -62,6 +47,7 @@ def finalize(): # auto-registration would register it with that module. Manually set package to this plugin module plugin.register(timer_shutdown, name='timer_shutdown', package=plugin.loaded_as(__name__)) + # Stop Playback Timer global timer_stop_player timeout = cfg.setndefault('timers', 'stop_player', 'default_timeout_sec', value=60 * 60) timer_stop_player = GenericTimerClass(f"{plugin.loaded_as(__name__)}.timer_stop_player", @@ -69,14 +55,18 @@ def finalize(): timer_stop_player.__doc__ = "Timer for automatic player stop" plugin.register(timer_stop_player, name='timer_stop_player', package=plugin.loaded_as(__name__)) - global timer_fade_volume - timeout = cfg.setndefault('timers', 'volume_fade_out', 'default_time_per_iteration_sec', value=15 * 60) - steps = cfg.setndefault('timers', 'volume_fade_out', 'number_of_steps', value=10) - timer_fade_volume = GenericMultiTimerClass(f"{plugin.loaded_as(__name__)}.timer_fade_volume", - steps, timeout, VolumeFadeOutActionClass) - timer_fade_volume.__doc__ = "Timer step-wise volume fade out and shutdown" + # Volume Fadeout and Shutdown Timer + timer_fade_volume = VolumeFadoutAndShutdown( + name=f"{plugin.loaded_as(__name__)}.timer_fade_volume" + ) plugin.register(timer_fade_volume, name='timer_fade_volume', package=plugin.loaded_as(__name__)) + # Idle Timer + global timer_idle_shutdown + idle_timeout = cfg.setndefault('timers', 'idle_shutdown', 'timeout_sec', value=0) + timer_idle_shutdown = IdleShutdownTimer(package=plugin.loaded_as(__name__), idle_timeout=idle_timeout) + plugin.register(timer_idle_shutdown, name='timer_idle_shutdown', package=plugin.loaded_as(__name__)) + # The idle Timer does work in a little sneaky way # Idle is when there are no calls through the plugin module # Ahh, but also when music is playing this is not idle... @@ -101,4 +91,15 @@ def atexit(**ignored_kwargs): timer_stop_player.cancel() global timer_fade_volume timer_fade_volume.cancel() - return [timer_shutdown.timer_thread, timer_stop_player.timer_thread, timer_fade_volume.timer_thread] + global timer_idle_shutdown + timer_idle_shutdown.cancel() + global timer_idle_check + timer_idle_check.cancel() + ret = [ + timer_shutdown.timer_thread, + timer_stop_player.timer_thread, + timer_fade_volume.timer_thread, + timer_idle_shutdown.timer_thread, + timer_idle_check.timer_thread + ] + return ret diff --git a/src/jukebox/components/timers/idle_shutdown_timer.py b/src/jukebox/components/timers/idle_shutdown_timer.py new file mode 100644 index 000000000..d1881b522 --- /dev/null +++ b/src/jukebox/components/timers/idle_shutdown_timer.py @@ -0,0 +1,194 @@ +# RPi-Jukebox-RFID Version 3 +# Copyright (c) See file LICENSE in project root folder + +import os +import re +import logging +import jukebox.cfghandler +import jukebox.plugs as plugin +from jukebox.multitimer import (GenericEndlessTimerClass, GenericMultiTimerClass) + + +logger = logging.getLogger('jb.timers.idle_shutdown_timer') +cfg = jukebox.cfghandler.get_handler('jukebox') + +SSH_CHILD_RE = re.compile(r'sshd: [^/].*') +PATHS = ['shared/settings', + 'shared/audiofolders'] + +IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS = 60 +EXTEND_IDLE_TIMEOUT = 60 +IDLE_CHECK_INTERVAL = 10 + + +def get_seconds_since_boot(): + # We may not have a stable clock source when there is no network + # connectivity (yet). As we only need to measure the relative time which + # has passed, we can just calculate based on the seconds since boot. + with open('/proc/uptime') as f: + line = f.read() + seconds_since_boot, _ = line.split(' ', 1) + return float(seconds_since_boot) + + +class IdleShutdownTimer: + def __init__(self, package: str, idle_timeout: int) -> None: + self.private_timer_idle_shutdown = None + self.private_timer_idle_check = None + self.idle_timeout = 0 + self.package = package + self.idle_check_interval = IDLE_CHECK_INTERVAL + + self.set_idle_timeout(idle_timeout) + self.init_idle_shutdown() + self.init_idle_check() + + def set_idle_timeout(self, idle_timeout): + try: + self.idle_timeout = int(idle_timeout) + except ValueError: + logger.warning(f'invalid timers.idle_shutdown.timeout_sec value {repr(idle_timeout)}') + + if self.idle_timeout < IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS: + logger.info('disabling idle shutdown timer; set ' + 'timers.idle_shutdown.timeout_sec to at least ' + f'{IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS} seconds to enable') + self.idle_timeout = 0 + + # Using GenericMultiTimerClass instead of GenericTimerClass as it supports classes rather than functions + # Calling GenericMultiTimerClass with iterations=1 is the same as GenericTimerClass + def init_idle_shutdown(self): + self.private_timer_idle_shutdown = GenericMultiTimerClass( + name=f"{self.package}.private_timer_idle_shutdown", + iterations=1, + wait_seconds_per_iteration=self.idle_timeout, + callee=IdleShutdown + ) + self.private_timer_idle_shutdown.__doc__ = "Timer to shutdown after system is idle for a given time" + plugin.register(self.private_timer_idle_shutdown, name='private_timer_idle_shutdown', package=self.package) + + # Regularly check if player has activity, if not private_timer_idle_check will start/cancel private_timer_idle_shutdown + def init_idle_check(self): + idle_check_timer_instance = IdleCheck() + self.private_timer_idle_check = GenericEndlessTimerClass( + name=f"{self.package}.private_timer_idle_check", + wait_seconds_per_iteration=self.idle_check_interval, + function=idle_check_timer_instance + ) + self.private_timer_idle_check.__doc__ = 'Timer to check if system is idle' + if self.idle_timeout: + self.private_timer_idle_check.start() + + plugin.register(self.private_timer_idle_check, name='private_timer_idle_check', package=self.package) + + @plugin.tag + def start(self, wait_seconds: int): + """Sets idle_shutdown timeout_sec stored in jukebox.yaml""" + cfg.setn('timers', 'idle_shutdown', 'timeout_sec', value=wait_seconds) + plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'start') + + @plugin.tag + def cancel(self): + """Cancels all idle timers and disables idle shutdown in jukebox.yaml""" + plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'cancel') + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel') + cfg.setn('timers', 'idle_shutdown', 'timeout_sec', value=0) + + @plugin.tag + def get_state(self): + """Returns the current state of Idle Shutdown""" + idle_check_state = plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'get_state') + idle_shutdown_state = plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'get_state') + + return { + 'enabled': idle_check_state['enabled'], + 'running': idle_shutdown_state['enabled'], + 'remaining_seconds': idle_shutdown_state['remaining_seconds'], + 'wait_seconds': idle_shutdown_state['wait_seconds_per_iteration'], + } + + +class IdleCheck: + def __init__(self) -> None: + self.last_player_status = plugin.call('player', 'ctrl', 'playerstatus') + logger.debug('Started IdleCheck with initial state: {}'.format(self.last_player_status)) + + # Run function + def __call__(self): + player_status = plugin.call('player', 'ctrl', 'playerstatus') + + if self.last_player_status == player_status: + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'start') + else: + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel') + + self.last_player_status = player_status.copy() + return self.last_player_status + + +class IdleShutdown(): + files_num_entries: int = 0 + files_latest_mtime: float = 0 + + def __init__(self) -> None: + self.base_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', '..') + + def __call__(self): + logger.debug('Last checks before shutting down') + if self._has_active_ssh_sessions(): + logger.debug('Active SSH sessions found, will not shutdown now') + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'set_timeout', args=[int(EXTEND_IDLE_TIMEOUT)]) + return + # if self._has_changed_files(): + # logger.debug('Changes files found, will not shutdown now') + # plugin.call_ignore_errors( + # 'timers', + # 'private_timer_idle_shutdown', + # 'set_timeout', + # args=[int(EXTEND_IDLE_TIMEOUT)]) + # return + + logger.info('No activity, shutting down') + plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'cancel') + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel') + plugin.call_ignore_errors('host', 'shutdown') + + @staticmethod + def _has_active_ssh_sessions(): + logger.debug('Checking for SSH activity') + with os.scandir('/proc') as proc_dir: + for proc_path in proc_dir: + if not proc_path.is_dir(): + continue + try: + with open(os.path.join(proc_path, 'cmdline')) as f: + cmdline = f.read() + except (FileNotFoundError, PermissionError): + continue + if SSH_CHILD_RE.match(cmdline): + return True + + def _has_changed_files(self): + # This is a rather expensive check, but it only runs twice + # when an idle shutdown is initiated. + # Only when there are actual changes (file transfers via + # SFTP, Samba, etc.), the check may run multiple times. + logger.debug('Scanning for file changes') + latest_mtime = 0 + num_entries = 0 + for path in PATHS: + for root, dirs, files in os.walk(os.path.join(self.base_path, path)): + for p in dirs + files: + mtime = os.stat(os.path.join(root, p)).st_mtime + latest_mtime = max(latest_mtime, mtime) + num_entries += 1 + + logger.debug(f'Completed file scan ({num_entries} entries, latest_mtime={latest_mtime})') + if self.files_latest_mtime != latest_mtime or self.files_num_entries != num_entries: + # We compare the number of entries to have a chance to detect file + # deletions as well. + self.files_latest_mtime = latest_mtime + self.files_num_entries = num_entries + return True + + return False diff --git a/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py b/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py new file mode 100644 index 000000000..550378d17 --- /dev/null +++ b/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py @@ -0,0 +1,206 @@ +import logging +import time +import jukebox.cfghandler +import jukebox.plugs as plugin +from jukebox.multitimer import GenericTimerClass, GenericMultiTimerClass + + +logger = logging.getLogger('jb.timers') +cfg = jukebox.cfghandler.get_handler('jukebox') + + +class VolumeFadeoutError(Exception): + """Custom exception for volume fadeout errors""" + pass + + +class VolumeFadeoutAction: + """Handles the actual volume fade out actions""" + def __init__(self, start_volume): + self.start_volume = start_volume + # Use 12 steps for 2 minutes = 10 seconds per step + self.iterations = 12 + self.volume_step = start_volume / (self.iterations - 1) + logger.debug(f"Initialized fadeout from volume {start_volume}") + + def __call__(self, iteration, *args, **kwargs): + """Called for each timer iteration""" + # Calculate target volume for this step + target_volume = max(0, int(self.start_volume - (self.iterations - iteration - 1) * self.volume_step)) + logger.debug(f"Fading volume to {target_volume} (Step {self.iterations - iteration}/{self.iterations})") + plugin.call_ignore_errors('volume', 'ctrl', 'set_volume', args=[target_volume]) + + +class VolumeFadoutAndShutdown: + """Timer system that gracefully fades out volume before shutdown. + + This timer manages three coordinated timers for a smooth shutdown sequence: + 1. Main shutdown timer: Runs for the full duration and triggers the final shutdown + 2. Fadeout start timer: Triggers the volume fadeout 2 minutes before shutdown + 3. Volume fadeout timer: Handles the actual volume reduction in the last 2 minutes + + Example for a 5-minute (300s) timer: + - t=0s: Shutdown timer starts (300s) + Fadeout start timer starts (180s) + - t=180s: Fadeout start timer triggers volume reduction + Volume fadeout begins (12 steps over 120s) + - t=300s: Shutdown timer triggers system shutdown + + The fadeout always takes 2 minutes (120s), regardless of the total timer duration. + The minimum total duration is 2 minutes to accommodate the fadeout period. + All timers can be cancelled together using the cancel() method. + """ + + MIN_TOTAL_DURATION = 120 # 2 minutes minimum + FADEOUT_DURATION = 120 # Last 2 minutes for fadeout + + def __init__(self, name): + self.name = name + self.default_timeout = cfg.setndefault('timers', 'volume_fadeout', 'default_timeout_sec', value=600) + + self.shutdown_timer = None + self.fadeout_start_timer = None + self.fadeout_timer = None + + self._reset_state() + + def _reset_state(self): + """Reset internal state variables""" + self.start_time = None + self.total_duration = None + self.current_volume = None + self.fadeout_started = False + + def _start_fadeout(self): + """Callback for fadeout_start_timer - initiates the volume fadeout""" + logger.info("Starting volume fadeout sequence") + self.fadeout_started = True + + # Get current volume at start of fadeout + self.current_volume = plugin.call('volume', 'ctrl', 'get_volume') + if self.current_volume <= 0: + logger.warning("Volume already at 0, waiting for shutdown") + return + + # Start the fadeout timer + self.fadeout_timer = GenericMultiTimerClass( + name=f"{self.name}_fadeout", + iterations=12, # 12 steps over 2 minutes = 10 seconds per step + wait_seconds_per_iteration=10, + callee=lambda iterations: VolumeFadeoutAction(self.current_volume) + ) + self.fadeout_timer.start() + + def _shutdown(self): + """Callback for shutdown_timer - performs the actual shutdown""" + logger.info("Timer complete, initiating shutdown") + plugin.call_ignore_errors('host', 'shutdown') + + @plugin.tag + def start(self, wait_seconds=None): + """Start the coordinated timer system + + Args: + wait_seconds (float): Total duration until shutdown (optional) + + Raises: + VolumeFadeoutError: If duration too short + """ + # Cancel any existing timers + self.cancel() + + # Use provided duration or default + duration = wait_seconds if wait_seconds is not None else self.default_timeout + + # Validate duration + if duration < self.MIN_TOTAL_DURATION: + raise VolumeFadeoutError(f"Duration must be at least {self.MIN_TOTAL_DURATION} seconds") + + self.start_time = time.time() + self.total_duration = duration + + # Start the main shutdown timer + self.shutdown_timer = GenericTimerClass( + name=f"{self.name}_shutdown", + wait_seconds=duration, + function=self._shutdown + ) + + # Start the fadeout start timer + fadeout_start_time = duration - self.FADEOUT_DURATION + self.fadeout_start_timer = GenericTimerClass( + name=f"{self.name}_fadeout_start", + wait_seconds=fadeout_start_time, + function=self._start_fadeout + ) + + logger.info( + f"Starting timer system: {fadeout_start_time}s until fadeout starts, " + f"total duration {duration}s" + ) + + self.shutdown_timer.start() + self.fadeout_start_timer.start() + + @plugin.tag + def cancel(self): + """Cancel all active timers""" + if self.shutdown_timer and self.shutdown_timer.is_alive(): + logger.info("Cancelling shutdown timer") + self.shutdown_timer.cancel() + + if self.fadeout_start_timer and self.fadeout_start_timer.is_alive(): + logger.info("Cancelling fadeout start timer") + self.fadeout_start_timer.cancel() + + if self.fadeout_timer and self.fadeout_timer.is_alive(): + logger.info("Cancelling volume fadeout") + self.fadeout_timer.cancel() + + self._reset_state() + + @plugin.tag + def is_alive(self): + """Check if any timer is currently active""" + return ( + (self.shutdown_timer and self.shutdown_timer.is_alive()) + or (self.fadeout_start_timer and self.fadeout_start_timer.is_alive()) + or (self.fadeout_timer and self.fadeout_timer.is_alive()) + ) + + @plugin.tag + def get_state(self): + """Get the current state of the timer system""" + if not self.is_alive() or not self.start_time: + return { + 'enabled': False, + 'type': 'VolumeFadoutAndShutdown', + 'total_duration': None, + 'remaining_seconds': 0, + 'progress_percent': 0, + 'error': None + } + + # Use the main shutdown timer for overall progress + elapsed = time.time() - self.start_time + remaining = max(0, self.total_duration - elapsed) + progress = min(100, (elapsed / self.total_duration) * 100 if self.total_duration else 0) + + return { + 'enabled': True, + 'type': 'VolumeFadoutAndShutdown', + 'total_duration': self.total_duration, + 'remaining_seconds': remaining, + 'progress_percent': progress, + 'fadeout_started': self.fadeout_started, + 'error': None + } + + @plugin.tag + def get_config(self): + """Get the current configuration""" + return { + 'default_timeout': self.default_timeout, + 'min_duration': self.MIN_TOTAL_DURATION, + 'fadeout_duration': self.FADEOUT_DURATION + } diff --git a/src/jukebox/jukebox/daemon.py b/src/jukebox/jukebox/daemon.py index e847428e2..1334e89f7 100755 --- a/src/jukebox/jukebox/daemon.py +++ b/src/jukebox/jukebox/daemon.py @@ -70,7 +70,7 @@ def signal_handler(self, esignal, frame): # systemd: By default, a SIGTERM is sent, followed by 90 seconds of waiting followed by a SIGKILL. # Pressing Ctrl-C gives SIGINT self._signal_cnt += 1 - timeout: float = 10.0 + timeout: float = 5.0 time_start = time.time_ns() msg = f"Received signal '{signal.Signals(esignal).name}'. Count = {self._signal_cnt}" print(msg) diff --git a/src/jukebox/jukebox/multitimer.py b/src/jukebox/jukebox/multitimer.py index b03aae83f..facb2cce8 100644 --- a/src/jukebox/jukebox/multitimer.py +++ b/src/jukebox/jukebox/multitimer.py @@ -1,11 +1,28 @@ -# RPi-Jukebox-RFID Version 3 -# Copyright (c) See file LICENSE in project root folder +"""MultiTimer Module -"""Multitimer Module""" +This module provides timer functionality with support for single, multiple, and endless iterations. +It includes three main timer classes: +- MultiTimer: The base timer implementation using threading +- GenericTimerClass: A single-event timer with plugin/RPC support +- GenericEndlessTimerClass: An endless repeating timer +- GenericMultiTimerClass: A multi-iteration timer with callback builder support + +Example usage: + # Single event timer + timer = GenericTimerClass("my_timer", 5.0, my_function) + timer.start() + + # Endless timer + endless_timer = GenericEndlessTimerClass("endless", 1.0, update_function) + endless_timer.start() + + # Multi-iteration timer + multi_timer = GenericMultiTimerClass("counter", 5, 1.0, CounterCallback) + multi_timer.start() +""" import threading -from typing import ( - Callable) +from typing import Callable, Optional, Any, Dict import logging import jukebox.cfghandler import jukebox.plugs as plugin @@ -18,18 +35,40 @@ class MultiTimer(threading.Thread): - """Call a function after a specified number of seconds, repeat that iteration times + """A threaded timer that calls a function after specified intervals. - 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) + This timer supports both limited iterations and endless execution modes. + In limited iteration mode, it counts down from iterations-1 to 0. + In endless mode (iterations < 0), it runs indefinitely until cancelled. - If iterations is negative, an endlessly repeating timer is created (which needs to be cancelled with cancel()) + The timer can be cancelled at any time using the cancel() method. - Initiates start and publishing by calling self.publish_callback + Attributes: + interval (float): Time in seconds between function calls + iterations (int): Number of times to call the function. Use negative for endless mode + function (Callable): Function to call on each iteration + args (list): Positional arguments to pass to the function + kwargs (dict): Keyword arguments to pass to the function + publish_callback (Optional[Callable]): Function to call on timer start/stop for state publishing - Note: Inspired by threading.Timer and generally using the same API""" + Example: + def my_func(iteration, x, y=10): + print(f"Iteration {iteration}: {x} + {y}") - def __init__(self, interval, iterations, function: Callable, args=None, kwargs=None): + timer = MultiTimer(2.0, 5, my_func, args=[5], kwargs={'y': 20}) + timer.start() + """ + + def __init__(self, interval: float, iterations: int, function: Callable, args=None, kwargs=None): + """Initialize the timer. + + Args: + interval: Seconds between function calls + iterations: Number of iterations (-1 for endless) + function: Function to call each iteration + args: Positional arguments for function + kwargs: Keyword arguments for function + """ super().__init__() self.interval = interval self.iterations = iterations @@ -43,14 +82,19 @@ def __init__(self, interval, iterations, function: Callable, args=None, kwargs=N def cancel(self): """Stop the timer if it hasn't finished all iterations yet.""" logger.debug(f"Cancel timer '{self.name}.") - # Assignment to _cmd_cancel is atomic -> OK for threads self._cmd_cancel = True self.event.set() def trigger(self): + """Trigger the next function call immediately.""" self.event.set() def run_endless(self): + """Run the timer in endless mode. + + The function is called every interval seconds with iteration=-1 + until cancelled. + """ while True: self.event.wait(self.interval) if self.event.is_set(): @@ -58,10 +102,14 @@ def run_endless(self): break else: self.event.clear() - # logger.debug(f"Execute timer action of '{self.name}'.") self.function(iteration=-1, *self.args, **self.kwargs) def run_limited(self): + """Run the timer for a limited number of iterations. + + The function is called every interval seconds with iteration + counting down from iterations-1 to 0. + """ for iteration in range(self.iterations - 1, -1, -1): self.event.wait(self.interval) if self.event.is_set(): @@ -69,10 +117,15 @@ def run_limited(self): break else: self.event.clear() - # logger.debug(f"Execute timer action #{iteration} of '{self.name}'.") self.function(*self.args, iteration=iteration, **self.kwargs) def run(self): + """Start the timer execution. + + This is called automatically when start() is called. + The timer runs in either endless or limited mode based on + the iterations parameter. + """ if self.publish_callback is not None: self.publish_callback() if self.iterations < 0: @@ -88,15 +141,43 @@ def run(self): class GenericTimerClass: + """A single-event timer with plugin/RPC support. + + This class provides a high-level interface for creating and managing + single-execution timers. It includes support for: + - Starting/stopping/toggling the timer + - Publishing timer state + - Getting remaining time + - Adjusting timeout duration + + The timer automatically handles the 'iteration' parameter internally, + so callback functions don't need to handle it. + + Attributes: + name (str): Identifier for the timer + _wait_seconds (float): Interval between function calls + _function (Callable): Wrapped function to call + _iterations (int): Number of iterations (1 for single-event) + + Example: + def update_display(message): + print(message) + + timer = GenericTimerClass("display_timer", 5.0, update_display, + args=["Hello World"]) + timer.start() """ - Interface for plugin / RPC accessibility for a single event timer - """ - def __init__(self, name, wait_seconds: float, function, args=None, kwargs=None): - """ - :param wait_seconds: The time in seconds to wait before calling function - :param function: The function to call with args and kwargs. - :param args: Parameters for function call - :param kwargs: Parameters for function call + + def __init__(self, name: str, wait_seconds: float, function: Callable, + args: Optional[list] = None, kwargs: Optional[dict] = None): + """Initialize the timer. + + Args: + name: Timer identifier + wait_seconds: Time to wait before function call + function: Function to call + args: Positional arguments for function + kwargs: Keyword arguments for function """ self.timer_thread = None self.args = args if args is not None else [] @@ -104,21 +185,25 @@ def __init__(self, name, wait_seconds: float, function, args=None, kwargs=None): self._wait_seconds = wait_seconds self._start_time = 0 # Hide away the argument 'iteration' that is passed from MultiTimer to function - # for a single event Timer (and also endless timers, as the inherit from here) self._function = lambda iteration, *largs, **lkwargs: function(*largs, **lkwargs) self._iterations = 1 self._name = name self._publish_core() @plugin.tag - def start(self, wait_seconds=None): - """Start the timer (with default or new parameters)""" + def start(self, wait_seconds: Optional[float] = None): + """Start the timer with optional new wait time. + + Args: + wait_seconds: Optional new interval to use + """ if self.is_alive(): - logger.error(f"Timer '{self._name}' started! Ignoring start command.") + logger.info(f"Timer '{self._name}' started! Ignoring start command.") return if wait_seconds is not None: self._wait_seconds = wait_seconds - self.timer_thread = MultiTimer(self._wait_seconds, self._iterations, self._function, *self.args, **self.kwargs) + self.timer_thread = MultiTimer(self._wait_seconds, self._iterations, + self._function, self.args, self.kwargs) self.timer_thread.daemon = True self.timer_thread.publish_callback = self._publish_core if self._name is not None: @@ -128,13 +213,13 @@ def start(self, wait_seconds=None): @plugin.tag def cancel(self): - """Cancel the timer""" + """Cancel the timer if it's running.""" if self.is_alive(): self.timer_thread.cancel() @plugin.tag def toggle(self): - """Toggle the activation of the timer""" + """Toggle between started and stopped states.""" if self.is_alive(): self.timer_thread.cancel() else: @@ -142,27 +227,42 @@ def toggle(self): @plugin.tag def trigger(self): - """Trigger the next target execution before the time is up""" + """Trigger the function call immediately.""" if self.is_alive(): self.timer_thread.trigger() @plugin.tag - def is_alive(self): - """Check if timer is active""" + def is_alive(self) -> bool: + """Check if timer is currently running. + + Returns: + bool: True if timer is active, False otherwise + """ if self.timer_thread is None: return False return self.timer_thread.is_alive() @plugin.tag - def get_timeout(self): - """Get the configured time-out + def get_timeout(self) -> float: + """Get the configured timeout interval. - :return: The total wait time. (Not the remaining wait time!)""" + Returns: + float: The wait time in seconds + """ return self._wait_seconds @plugin.tag - def set_timeout(self, wait_seconds: float): - """Set a new time-out in seconds. Re-starts the timer if already running!""" + def set_timeout(self, wait_seconds: float) -> float: + """Set a new timeout interval. + + If the timer is running, it will be restarted with the new interval. + + Args: + wait_seconds: New interval in seconds + + Returns: + float: The new wait time + """ self._wait_seconds = wait_seconds if self.is_alive(): self.cancel() @@ -173,85 +273,181 @@ def set_timeout(self, wait_seconds: float): @plugin.tag def publish(self): - """Publish the current state and config""" + """Publish current timer state.""" self._publish_core() @plugin.tag - def get_state(self): - """Get the current state and config as dictionary""" + def get_state(self) -> Dict[str, Any]: + """Get the current timer state. + + Returns: + dict: Timer state including: + - enabled: Whether timer is running + - remaining_seconds: Time until next function call + - wait_seconds: Configured interval + - type: Timer class name + """ remaining_seconds = max( 0, self.get_timeout() - (int(time()) - self._start_time) ) - return {'enabled': self.is_alive(), - 'remaining_seconds': remaining_seconds, - 'wait_seconds': self.get_timeout(), - 'type': 'GenericTimerClass'} + return { + 'enabled': self.is_alive(), + 'remaining_seconds': remaining_seconds, + 'wait_seconds': self.get_timeout(), + 'type': 'GenericTimerClass' + } - def _publish_core(self, enabled=None): - """Internal publish function with override for enabled + def _publish_core(self, enabled: Optional[bool] = None): + """Internal method to publish timer state. - Enable override is required as this is called from inside the timer when it finishes - This means the timer is still running, but it is the last thing it does. - Otherwise it is not possible to detect the timer change at the end""" + Args: + enabled: Override for enabled state + """ if self._name is not None: state = self.get_state() if enabled is not None: state['enabled'] = enabled logger.debug(f"{self._name}: State = {state}") - # This function may be called from different threads, - # so always freshly get the correct publisher instance publishing.get_publisher().send(self._name, state) class GenericEndlessTimerClass(GenericTimerClass): + """An endless repeating timer. + + This timer runs indefinitely until explicitly cancelled. + It inherits all functionality from GenericTimerClass but + sets iterations to -1 for endless mode. + + Example: + def heartbeat(): + print("Ping") + + timer = GenericEndlessTimerClass("heartbeat", 1.0, heartbeat) + timer.start() """ - Interface for plugin / RPC accessibility for an event timer call function endlessly every m seconds - """ - def __init__(self, name, wait_seconds_per_iteration: float, function, args=None, kwargs=None): - # Remove the necessity for the 'iterations' keyword that is added by GenericTimerClass + + def __init__(self, name: str, wait_seconds_per_iteration: float, + function: Callable, args=None, kwargs=None): + """Initialize endless timer. + + Args: + name: Timer identifier + wait_seconds_per_iteration: Interval between calls + function: Function to call repeatedly + args: Positional arguments for function + kwargs: Keyword arguments for function + """ super().__init__(name, wait_seconds_per_iteration, function, args, kwargs) # Negative iteration count causes endless looping self._iterations = -1 - def get_state(self): - return {'enabled': self.is_alive(), - 'wait_seconds_per_iteration': self.get_timeout(), - 'type': 'GenericEndlessTimerClass'} + @plugin.tag + def get_state(self) -> Dict[str, Any]: + """Get current timer state. + + Returns: + dict: Timer state including: + - enabled: Whether timer is running + - wait_seconds_per_iteration: Interval between calls + - type: Timer class name + """ + return { + 'enabled': self.is_alive(), + 'wait_seconds_per_iteration': self.get_timeout(), + 'type': 'GenericEndlessTimerClass' + } class GenericMultiTimerClass(GenericTimerClass): + """A multi-iteration timer with callback builder support. + + This timer executes a specified number of iterations with a callback + that's created for each full cycle. It's useful when you need stateful + callbacks or complex iteration handling. + + The callee parameter should be a class or function that: + 1. Takes iterations as a parameter during construction + 2. Returns a callable that accepts an iteration parameter + + Example: + class CountdownCallback: + def __init__(self, iterations): + self.total = iterations + + def __call__(self, iteration): + print(f"{iteration} of {self.total} remaining") + + timer = GenericMultiTimerClass("countdown", 5, 1.0, CountdownCallback) + timer.start() """ - Interface for plugin / RPC accessibility for an event timer that performs an action n times every m seconds - """ - def __init__(self, name, iterations: int, wait_seconds_per_iteration: float, callee, args=None, kwargs=None): - """ - :param iterations: Number of times callee is called - :param wait_seconds_per_iteration: Wait in seconds before each iteration - :param 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! - :param args: - :param kwargs: + + def __init__(self, name: str, iterations: int, wait_seconds_per_iteration: float, + callee: Callable, args=None, kwargs=None): + """Initialize multi-iteration timer. + + Args: + name: Timer identifier + iterations: Total number of iterations + wait_seconds_per_iteration: Interval between calls + callee: Callback builder class/function + args: Positional arguments for callee + kwargs: Keyword arguments for callee """ - super().__init__(name, wait_seconds_per_iteration, None, None, None) + # Initialize with a placeholder function - we'll set the real one in start() + super().__init__(name, wait_seconds_per_iteration, lambda: None, None, None) self.class_args = args if args is not None else [] self.class_kwargs = kwargs if kwargs is not None else {} self._iterations = iterations self._callee = callee @plugin.tag - def start(self, iterations=None, wait_seconds_per_iteration=None): - """Start the timer (with default or new parameters)""" + def start(self, iterations: Optional[int] = None, + wait_seconds_per_iteration: Optional[float] = None): + """Start the timer with optional new parameters. + + Args: + iterations: Optional new iteration count + wait_seconds_per_iteration: Optional new interval + """ if iterations is not None: self._iterations = iterations - self._function = self._callee(*self.class_args, iterations=self._iterations, **self.class_kwargs) + + def create_callback(): + instance = self._callee(*self.class_args, iterations=self._iterations, + **self.class_kwargs) + return lambda iteration, *args, **kwargs: instance(*args, + iteration=iteration, + **kwargs) + + self._function = create_callback() super().start(wait_seconds_per_iteration) @plugin.tag - def get_state(self): - return {'enabled': self.is_alive(), - 'wait_seconds_per_iteration': self.get_timeout(), - 'iterations': self._iterations, - 'type': 'GenericMultiTimerClass'} + def get_state(self) -> Dict[str, Any]: + """Get current timer state. + + Returns: + dict: Timer state including: + - enabled: Whether timer is running + - wait_seconds_per_iteration: Interval between calls + - remaining_seconds_current_iteration: Time until next call + - remaining_seconds: Total time remaining + - iterations: Total iteration count + - type: Timer class name + """ + remaining_seconds_current_iteration = max( + 0, + self.get_timeout() - (int(time()) - self._start_time) + ) + remaining_seconds = (self.get_timeout() * self._iterations + remaining_seconds_current_iteration) + + return { + 'enabled': self.is_alive(), + 'wait_seconds_per_iteration': self.get_timeout(), + 'remaining_seconds_current_iteration': remaining_seconds_current_iteration, + 'remaining_seconds': remaining_seconds, + 'iterations': self._iterations, + 'type': 'GenericMultiTimerClass' + } diff --git a/src/webapp/public/locales/de/translation.json b/src/webapp/public/locales/de/translation.json index 7dbdcf695..1c6729415 100644 --- a/src/webapp/public/locales/de/translation.json +++ b/src/webapp/public/locales/de/translation.json @@ -18,9 +18,9 @@ "shutdown": "Herunterfahren", "reboot": "Neustarten", "say_my_ip": "IP Addresse vorlesen", - "timer_shutdown": "Shut Down", - "timer_stop_player": "Stop player", - "timer_fade_volume": "Fade volume", + "timer_shutdown": "Herunterfahren", + "timer_stop_player": "Player stoppen", + "timer_fade_volume": "Lautstärke ausblenden und herunterfahren", "toggle_output": "Audio-Ausgang umschalten", "sync_rfidcards_all": "Alle Audiodateien und Karteneinträge synchronisieren", "sync_rfidcards_change_on_rfid_scan": "Aktivierung ändern für 'on RFID scan'", @@ -229,11 +229,32 @@ "option-label-timeslot": "{{value}} min", "option-label-off": "Aus", "title": "Timer", - "stop-player": "Wiedergabe stoppen", - "shutdown": "Herunterfahren", - "fade-volume": "Lautstärke ausblenden", - "idle-shutdown": "Leerlaufabschaltung", - "ended": "Beendet" + "stop-player": { + "title": "Wiedergabe stoppen", + "label": "Stoppt die Wiedergabe nach Ablauf des Timers." + }, + "shutdown": { + "title": "Herunterfahren", + "label": "Fährt die Phoniebox nach Ablauf des Timers herunter." + }, + "fade-volume": { + "title": "Lautstärke ausblenden", + "label": "Blendet die Lautstärke zum Ende des Timers langsam aus und fährt dann die Phoniebox herunter." + }, + "idle-shutdown": { + "title": "Leerlaufabschaltung", + "label": "Fährt die Phoniebox herunter, nachdem sie für eine bestimmte Zeit im Leerlauf war." + }, + "set": "Timer erstellen", + "cancel": "Abbrechen", + "paused": "Pausiert", + "ended": "Beendet", + "dialog": { + "title": "{{value}} Timer erstellen", + "description": "Wähle die Anzahl der Minuten nachdem die Aktion ausgeführt werden soll.", + "start": "Timer starten", + "cancel": "Abbrechen" + } }, "secondswipe": { "title": "Erneute Aktivierung (Second Swipe)", diff --git a/src/webapp/public/locales/en/translation.json b/src/webapp/public/locales/en/translation.json index 7ff66ecc4..20a28bdac 100644 --- a/src/webapp/public/locales/en/translation.json +++ b/src/webapp/public/locales/en/translation.json @@ -20,7 +20,7 @@ "say_my_ip": "Say IP address", "timer_shutdown": "Shut Down", "timer_stop_player": "Stop player", - "timer_fade_volume": "Fade volume", + "timer_fade_volume": "Fade volume and Shut Down", "toggle_output": "Toggle audio output", "sync_rfidcards_all": "Sync all audiofiles and card entries", "sync_rfidcards_change_on_rfid_scan": "Change activation of 'on RFID scan'", @@ -229,11 +229,32 @@ "option-label-timeslot": "{{value}} min", "option-label-off": "Off", "title": "Timers", - "stop-player": "Stop player", - "shutdown": "Shut Down", - "fade-volume": "Fade volume", - "idle-shutdown": "Idle Shut Down", - "ended": "Done" + "stop-player": { + "title": "Stop player", + "label": "Stops playback after the timer has ended." + }, + "shutdown": { + "title": "Shut Down", + "label": "Forces the Phoniebox to shut down after the timer has ended." + }, + "fade-volume": { + "title": "Fade volume and Shut Down", + "label": "Slowly fades out volume towards the end of the timer and then shuts down the Phoniebox." + }, + "idle-shutdown": { + "title": "Idle Shut Down", + "label": "Shuts down the Phoniebox after the Phoniebox was idle for a given time." + }, + "ended": "Done", + "set": "Set timer", + "cancel": "Cancel", + "paused": "Paused", + "dialog": { + "title": "Set {{value}} timer", + "description": "Choose the amount of minutes you want the action to be performed.", + "start": "Start timer", + "cancel": "Cancel" + } }, "secondswipe": { "title": "Second Swipe", diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index f6f772875..1e984997e 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -216,6 +216,25 @@ const commands = { }, + 'timer_idle_shutdown.cancel': { + _package: 'timers', + plugin: 'timer_idle_shutdown', + method: 'cancel', + }, + 'timer_idle_shutdown.get_state': { + _package: 'timers', + plugin: 'timer_idle_shutdown', + method: 'get_state', + }, + 'timer_idle_shutdown': { + _package: 'timers', + plugin: 'timer_idle_shutdown', + method: 'start', + argKeys: ['wait_seconds'], + }, + + + // Host getAutohotspotStatus: { _package: 'host', diff --git a/src/webapp/src/components/Settings/timers/index.js b/src/webapp/src/components/Settings/timers/index.js index c47a42573..a143a3557 100644 --- a/src/webapp/src/components/Settings/timers/index.js +++ b/src/webapp/src/components/Settings/timers/index.js @@ -1,21 +1,18 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useTheme } from '@mui/material/styles'; - import { Card, CardContent, CardHeader, Divider, Grid, + List, } from '@mui/material'; import Timer from './timer'; const SettingsTimers = () => { const { t } = useTranslation(); - const theme = useTheme(); - const spacer = { marginBottom: theme.spacing(2) } return ( @@ -24,15 +21,13 @@ const SettingsTimers = () => { /> - .MuiGrid-root:not(:last-child)': spacer }} - > - - - - {/* */} + + + + + + + diff --git a/src/webapp/src/components/Settings/timers/set-timer-dialog.js b/src/webapp/src/components/Settings/timers/set-timer-dialog.js new file mode 100644 index 000000000..c948916e8 --- /dev/null +++ b/src/webapp/src/components/Settings/timers/set-timer-dialog.js @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Grid, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +import { + SliderTimer +} from '../../general'; + +export default function SetTimerDialog({ + type, + enabled, + setTimer, + cancelTimer, + waitSeconds, + setWaitSeconds, +}) { + const { t } = useTranslation(); + const theme = useTheme(); + + const [dialogOpen, setDialogOpen] = useState(false); + + const handleClickOpen = () => { + setWaitSeconds(0); + setDialogOpen(true); + }; + + const handleCancel = () => { + setDialogOpen(false); + }; + + const handleSetTimer = () => { + setTimer(waitSeconds) + setDialogOpen(false); + } + + return ( + + {!enabled && + + } + {enabled && + + } + + + {t('settings.timers.dialog.title', { value: t(`settings.timers.${type}.title`)} )} + + + + {t('settings.timers.dialog.description')} + + + { setWaitSeconds(value) }} + /> + + + + + + + + + ); +} diff --git a/src/webapp/src/components/Settings/timers/timer.js b/src/webapp/src/components/Settings/timers/timer.js index 20b990edc..28625f86a 100644 --- a/src/webapp/src/components/Settings/timers/timer.js +++ b/src/webapp/src/components/Settings/timers/timer.js @@ -1,116 +1,138 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; - -import { - Box, - Grid, - Switch, - Typography, -} from '@mui/material'; -import { useTheme } from '@mui/material/styles'; - +import { Box, ListItem, ListItemText, Typography } from '@mui/material'; +import { Countdown } from '../../general'; +import SetTimerDialog from './set-timer-dialog'; import request from '../../../utils/request'; -import { - Countdown, - SliderTimer -} from '../../general'; - -const Timer = ({ type }) => { - const { t } = useTranslation(); - const theme = useTheme(); - // Constants +// Custom hook to manage timer state and logic +const useTimer = (type) => { const pluginName = `timer_${type.replace('-', '_')}`; + const [timerState, setTimerState] = useState({ + error: null, + enabled: false, + isLoading: true, + status: { enabled: false }, + waitSeconds: 0, + running: true + }); - // State - const [error, setError] = useState(null); - const [enabled, setEnabled] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [status, setStatus] = useState({ enabled: false }); - const [waitSeconds, setWaitSeconds] = useState(0); - - // Requests - const cancelTimer = async () => { - await request(`${pluginName}.cancel`); - setStatus({ enabled: false }); - }; + const fetchTimerStatus = useCallback(async () => { + try { + const { result: timerStatus, error: timerStatusError } = await request(`${pluginName}.get_state`); - const setTimer = async (event, wait_seconds) => { - await cancelTimer(); + if (timerStatusError) { + throw timerStatusError; + } - if (wait_seconds > 0) { - await request(pluginName, { wait_seconds } ); - fetchTimerStatus(); + setTimerState(prev => ({ + ...prev, + status: timerStatus, + enabled: timerStatus?.enabled, + running: timerStatus.running ?? true, + error: null, + isLoading: false + })); + } catch (error) { + setTimerState(prev => ({ + ...prev, + enabled: false, + error, + isLoading: false + })); } - } - - const fetchTimerStatus = useCallback(async () => { - const { - result: timerStatus, - error: timerStatusError - } = await request(`${pluginName}.get_state`); + }, [pluginName]); - if(timerStatusError) { - setEnabled(false); - return setError(timerStatusError); + const cancelTimer = async () => { + try { + await request(`${pluginName}.cancel`); + setTimerState(prev => ({ ...prev, enabled: false })); + } catch (error) { + setTimerState(prev => ({ ...prev, error })); } + }; - setStatus(timerStatus); - setEnabled(timerStatus?.enabled); - setWaitSeconds(timerStatus?.wait_seconds || 0); - }, [pluginName]); - + const setTimer = async (wait_seconds) => { + try { + await cancelTimer(); + if (wait_seconds > 0) { + await request(pluginName, { wait_seconds }); + await fetchTimerStatus(); + } + } catch (error) { + setTimerState(prev => ({ ...prev, error })); + } + }; - // Event Handlers - const handleSwitch = (event) => { - setEnabled(event.target.checked); - setWaitSeconds(0); // Always start the slider at 0 - cancelTimer(); - } + const setWaitSeconds = (seconds) => { + setTimerState(prev => ({ ...prev, waitSeconds: seconds })); + }; - // Effects useEffect(() => { fetchTimerStatus(); - setIsLoading(false); }, [fetchTimerStatus]); + return { + ...timerState, + setTimer, + cancelTimer, + setWaitSeconds + }; +}; + +// Separate component for timer actions +const TimerActions = ({ enabled, running, status, error, isLoading, type, onSetTimer, onCancelTimer, waitSeconds, onSetWaitSeconds }) => { + const { t } = useTranslation(); + + return ( + + {enabled && running && ( + onCancelTimer()} + stringEnded={t('settings.timers.ended')} + /> + )} + {enabled && !running && ( + {t('settings.timers.paused')} + )} + {error && ⚠️} + {!isLoading && ( + + )} + + ); +}; + +const Timer = ({ type }) => { + const { t } = useTranslation(); + const timer = useTimer(type); + return ( - - - - {t(`settings.timers.${type}`)} - - - {status?.enabled && - setEnabled(false)} - stringEnded={t('settings.timers.ended')} - /> - } - {error && - ⚠️ - } - - - - {enabled && - - - + } - + > + + ); }; diff --git a/src/webapp/src/components/general/Countdown.js b/src/webapp/src/components/general/Countdown.js index 84e91cb88..a3eab33e1 100644 --- a/src/webapp/src/components/general/Countdown.js +++ b/src/webapp/src/components/general/Countdown.js @@ -23,7 +23,7 @@ const Countdown = ({ onEnd, seconds, stringEnded = undefined }) => { } }, [onEndCallback, time]); - if (time) return toHHMMSS(time); + if (time) return toHHMMSS(Math.round(time)); if (stringEnded) return stringEnded; return toHHMMSS(0); } From ea40be3e89de0b728b45aac09909e8d10276cc30 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 23 Nov 2024 15:53:09 +0100 Subject: [PATCH 34/37] ignore LICENSE in markdown linter --- .markdownlint-cli2.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index 88c67e576..7ee48734a 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -32,6 +32,7 @@ globs: ignores: - "documentation/developers/docstring/*" - "src/**" + - "LICENSE" # Use a plugin to recognize math #markdownItPlugins: From d845bd34e1c1e2fd5ae93ad3274dec0505c8e981 Mon Sep 17 00:00:00 2001 From: pabera <1260686+pabera@users.noreply.github.com> Date: Mon, 9 Dec 2024 18:21:55 +0100 Subject: [PATCH 35/37] fix: correct function name reference in is_debian_version_at_least() (#2464) --- installation/includes/02_helpers.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installation/includes/02_helpers.sh b/installation/includes/02_helpers.sh index 440e10b08..ac6d434a0 100644 --- a/installation/includes/02_helpers.sh +++ b/installation/includes/02_helpers.sh @@ -95,7 +95,7 @@ _get_debian_version_number() { is_debian_version_at_least() { local expected_version=$1 - local debian_version_number=$(get_debian_version_number) + local debian_version_number=$(_get_debian_version_number) if [ "$debian_version_number" -ge "$expected_version" ]; then echo true From ca3c799edce472434c66b5023b352c306acadf43 Mon Sep 17 00:00:00 2001 From: Alvin Schiller <103769832+AlvinSchiller@users.noreply.github.com> Date: Mon, 30 Dec 2024 14:18:04 +0100 Subject: [PATCH 36/37] fix: fix wrong usage of variable (#2473) --- installation/includes/02_helpers.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/installation/includes/02_helpers.sh b/installation/includes/02_helpers.sh index ac6d434a0..dfc880754 100644 --- a/installation/includes/02_helpers.sh +++ b/installation/includes/02_helpers.sh @@ -106,8 +106,7 @@ is_debian_version_at_least() { _get_boot_file_path() { local filename="$1" - local is_debian_version_number_at_least_12=$(is_debian_version_at_least 12) - if [ "$(is_debian_version_number_at_least_12)" = true ]; then + if [ "$(is_debian_version_at_least 12)" = true ]; then echo "/boot/firmware/${filename}" else echo "/boot/${filename}" From 4ce6e89e25b350cbdd078427de75f850fd1fb81f Mon Sep 17 00:00:00 2001 From: s-martin Date: Sun, 19 Jan 2025 15:36:29 +0100 Subject: [PATCH 37/37] Fix the url to Autohotspot docs --- src/webapp/src/components/Settings/autohotspot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webapp/src/components/Settings/autohotspot.js b/src/webapp/src/components/Settings/autohotspot.js index 0bcbf97db..b9d04199f 100644 --- a/src/webapp/src/components/Settings/autohotspot.js +++ b/src/webapp/src/components/Settings/autohotspot.js @@ -16,7 +16,7 @@ import { SwitchWithLoader } from '../general'; import request from '../../utils/request'; -const helpUrl = 'https://rpi-jukebox-rfid.readthedocs.io/en/latest/userguide/autohotspot.html'; +const helpUrl = 'https://github.com/MiczFlor/RPi-Jukebox-RFID/blob/future3/main/documentation/builders/autohotspot.md'; const SettingsAutoHotpot = () => { const { t } = useTranslation();