diff --git a/.dockerignore b/.dockerignore index a587de477..da30b5a97 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,3 +14,4 @@ shared src/webapp/node_modules src/webapp/npm-debug.log src/webapp/build +src/webapp/build.bak diff --git a/.githooks/post-merge b/.githooks/post-merge index 3b838c87c..6fc6e4f54 100755 --- a/.githooks/post-merge +++ b/.githooks/post-merge @@ -5,7 +5,7 @@ # TO ACTIVATE: cp .githooks/post-merge .git/hooks/. # # Checks: -# - Changes to web app +# - Changes to Web App # - Changes to web dependency # - Changes to python requirements # @@ -20,7 +20,7 @@ warn_npm_dependency() { echo "************************************************************" echo "ATTENTION: npm dependencies have changed since last pull!" echo "" - echo "To update dependencies and rebuilt WebApp run:" + echo "To update dependencies and rebuilt Web App run:" echo "$ cd src/webapp && ./run_rebuild.sh -u" echo "************************************************************" echo -e "\n" @@ -31,7 +31,7 @@ warn_webapp() { echo "************************************************************" echo "ATTENTION: Web App sources have changed since last pull!" echo "" - echo "To rebuilt the WebApp run:" + echo "To rebuilt the Web App run:" echo "$ cd src/webapp && ./run_rebuild.sh" echo "************************************************************" echo -e "\n" diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 95901bcda..b1b0c0348 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -7,7 +7,7 @@ # Checks # - flake8 on staged python files # Note: This only checks the modified files -# - docs build of if any python file or any doc file is staged +# - docs build of if any python file is staged # Note: This builds the entire documentation if a changed file goes into the documentation # # If there are problem with this script, commit may still be done with @@ -28,6 +28,18 @@ fi code=$(( flake8_code )) +doc_code=0 +if [[ -n $PY_FILES ]]; then + echo -e "\n**************************************************************" + echo -e "Modified Python source files. Generation markdown docs from docstring ... \n" + echo -e "**************************************************************\n" + ./run_docgeneration.sh -c + doc_code=$? + echo "pydoc_markdown return code: $doc_code" +fi + +code=$(( flake8_code + doc_code )) + if [[ code -gt 0 ]]; then echo -e "\n**************************************************************" echo -e "ERROR(s) during pre-commit checks. Aborting commit!" diff --git a/.github/actions/build-webapp/action.yml b/.github/actions/build-webapp/action.yml new file mode 100644 index 000000000..9f192105f --- /dev/null +++ b/.github/actions/build-webapp/action.yml @@ -0,0 +1,25 @@ +name: Build Web App +description: 'Build Web App with Node' +inputs: + webapp-root-path: + description: 'root path of the Web App sources' + required: false + default: './src/webapp' +outputs: + webapp-root-path: + description: 'used root path of the Web App sources' + value: ${{ inputs.webapp-root-path }} + +runs: + using: "composite" + steps: + - name: Setup Node.js 20.x + uses: actions/setup-node@v3 + with: + node-version: 20.x + - name: run build + working-directory: ${{ inputs.webapp-root-path }} + shell: bash + env: + CI: false + run: ./run_rebuild.sh -u \ No newline at end of file diff --git a/.github/workflows/bundle_webapp_and_release_v3.yml b/.github/workflows/bundle_webapp_and_release_v3.yml index 548f2b47a..13bffe472 100644 --- a/.github/workflows/bundle_webapp_and_release_v3.yml +++ b/.github/workflows/bundle_webapp_and_release_v3.yml @@ -1,4 +1,4 @@ -name: Bundle Webapp and Release +name: Bundle Web App and Release on: push: @@ -18,7 +18,7 @@ jobs: check_abort: ${{ steps.vars.outputs.check_abort }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set Output vars id: vars @@ -72,9 +72,6 @@ jobs: if: ${{ needs.check.outputs.check_abort == 'false' }} runs-on: ubuntu-latest - env: - WEBAPP_ROOT_PATH: ./src/webapp - outputs: tag_name: ${{ needs.check.outputs.tag_name }} release_type: ${{ needs.check.outputs.release_type }} @@ -83,7 +80,7 @@ jobs: webapp_bundle_name_latest: ${{ steps.vars.outputs.webapp_bundle_name_latest }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set Output vars id: vars @@ -94,21 +91,12 @@ jobs: echo "webapp_bundle_name=webapp-build-${COMMIT_SHA:0:10}.tar.gz" >> $GITHUB_OUTPUT echo "webapp_bundle_name_latest=webapp-build-latest.tar.gz" >> $GITHUB_OUTPUT - - name: Setup Node.js 20.x - uses: actions/setup-node@v3 - with: - node-version: 20.x - - name: npm install - working-directory: ${{ env.WEBAPP_ROOT_PATH }} - run: npm install - - name: npm build - working-directory: ${{ env.WEBAPP_ROOT_PATH }} - env: - CI: false - run: npm run build + - name: Build Web App + id: build-webapp + uses: ./.github/actions/build-webapp - name: Create Bundle - working-directory: ${{ env.WEBAPP_ROOT_PATH }} + working-directory: ${{ steps.build-webapp.outputs.webapp-root-path }} run: | tar -czvf ${{ steps.vars.outputs.webapp_bundle_name }} build @@ -116,7 +104,7 @@ jobs: uses: actions/upload-artifact@v3 with: name: ${{ steps.vars.outputs.webapp_bundle_name }} - path: ${{ env.WEBAPP_ROOT_PATH }}/${{ steps.vars.outputs.webapp_bundle_name }} + path: ${{ steps.build-webapp.outputs.webapp-root-path }}/${{ steps.vars.outputs.webapp_bundle_name }} retention-days: 5 release: diff --git a/.github/workflows/test_build_webapp_v3.yml b/.github/workflows/test_build_webapp_v3.yml new file mode 100644 index 000000000..426d01f04 --- /dev/null +++ b/.github/workflows/test_build_webapp_v3.yml @@ -0,0 +1,33 @@ +name: Test Build Web App v3 + +on: + schedule: + # run at 18:00 every sunday + - cron: '0 18 * * 0' + push: + branches: + - 'future3/**' + paths: + - '.github/workflows/test_build_webapp_v3.yml' + - '.github/actions/build-webapp/**' + - 'src/webapp/**' + pull_request: + # The branches below must be a subset of the branches above + branches: + - future3/develop + - future3/main + paths: + - '.github/workflows/test_build_webapp_v3.yml' + - '.github/actions/build-webapp/**' + - 'src/webapp/**' + +jobs: + + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build Web App + uses: ./.github/actions/build-webapp \ No newline at end of file diff --git a/.github/workflows/test_docker_debian_codename_sub_v3.yml b/.github/workflows/test_docker_debian_codename_sub_v3.yml index dde60cb2a..a9ec217dc 100644 --- a/.github/workflows/test_docker_debian_codename_sub_v3.yml +++ b/.github/workflows/test_docker_debian_codename_sub_v3.yml @@ -1,4 +1,4 @@ -name: Subworkflow Test Install Scripts Debian V3 +name: Subworkflow Test Install Scripts Debian v3 on: workflow_call: @@ -46,6 +46,7 @@ jobs: cache_key: ${{ steps.vars.outputs.cache_key }} image_file_name: ${{ steps.vars.outputs.image_file_name }} image_tag_name: ${{ steps.vars.outputs.image_tag_name }} + docker_run_options: ${{ steps.vars.outputs.docker_run_options }} # create local docker registry to use locally build images services: @@ -83,6 +84,7 @@ jobs: id: vars env: LOCAL_REGISTRY_PORT: ${{ inputs.local_registry_port }} + PLATFORM: ${{ inputs.platform }} run: | echo "image_tag_name=${{ steps.pre-vars.outputs.image_tag_name }}" >> $GITHUB_OUTPUT echo "image_tag_name_local_base=localhost:${{ env.LOCAL_REGISTRY_PORT }}/${{ steps.pre-vars.outputs.image_tag_name }}-base" >> $GITHUB_OUTPUT @@ -90,6 +92,9 @@ jobs: echo "image_file_path=./${{ steps.pre-vars.outputs.image_file_name }}" >> $GITHUB_OUTPUT echo "cache_scope=${{ steps.pre-vars.outputs.cache_scope }}" >> $GITHUB_OUTPUT echo "cache_key=${{ steps.pre-vars.outputs.cache_scope }}-${{ github.sha }}#${{ github.run_attempt }}" >> $GITHUB_OUTPUT + if [ "${{ env.PLATFORM }}" == "linux/arm/v6" ] ; then + echo "docker_run_options=-e QEMU_CPU=arm1176" >> $GITHUB_OUTPUT + fi # Build base image for debian version name. Layers will be cached and image pushes to local registry - name: Build Image - Base @@ -167,7 +172,7 @@ jobs: uses: tj-actions/docker-run@v2 with: image: ${{ needs.build.outputs.image_tag_name }} - options: --platform ${{ inputs.platform }} --user ${{ env.TEST_USER_NAME }} --init + options: ${{ needs.build.outputs.docker_run_options }} --platform ${{ inputs.platform }} --user ${{ env.TEST_USER_NAME }} --init name: ${{ matrix.test_script }} args: | ./${{ matrix.test_script }} diff --git a/.github/workflows/test_docker_debian_v3.yml b/.github/workflows/test_docker_debian_v3.yml index 06718afba..673745db9 100644 --- a/.github/workflows/test_docker_debian_v3.yml +++ b/.github/workflows/test_docker_debian_v3.yml @@ -45,9 +45,25 @@ jobs: debian_codename: 'bookworm' platform: linux/arm/v7 + # # can be activate on test branches, currently failing + # run_bookworm_armv6: + # name: 'bookworm armv6' + # uses: ./.github/workflows/test_docker_debian_codename_sub_v3.yml + # with: + # debian_codename: 'bookworm' + # platform: linux/arm/v6 + run_bullseye_armv7: name: 'bullseye armv7' uses: ./.github/workflows/test_docker_debian_codename_sub_v3.yml with: debian_codename: 'bullseye' platform: linux/arm/v7 + + # # can be activate on test branches, currently failing + # run_bullseye_armv6: + # name: 'bullseye armv6' + # uses: ./.github/workflows/test_docker_debian_codename_sub_v3.yml + # with: + # debian_codename: 'bullseye' + # platform: linux/arm/v6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbf12f84d..f4ac9cd16 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,12 +45,17 @@ as local, temporary scratch areas. Contributors have played a bigger role over time to keep Phoniebox on the edge of innovation :) -We want to keep it as easy as possible to contribute changes that get things working in your environment. -There are a few guidelines that we need contributors to follow so that we can have a chance of keeping on top of things. +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. -Development for Version 3 is done on the git branch `future3/develop`. How to move to that branch, see below. +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. -For bug fixes and improvements just open an issue or PR as described below. If you plan to port a feature from Version 2.X or implement a new feature, it is advisable to contact us first. In this case, also open an issue describing what you are planning to do. We will just check that nobody else is already on the subject. We are looking forward to your work. Check the current [feature list](https://rpi-jukebox-rfid.readthedocs.io/en/latest/featurelist.html) for available features and work in progress. +We eagerly await your contributions! You can review the current [feature list](documentation/developers/status.md) to check for available features and ongoing work. ## Getting Started @@ -60,31 +65,21 @@ For bug fixes and improvements just open an issue or PR as described below. If y Version 2 will continue to live for quite a while. * Clearly describe the issue including steps to reproduce when it is a bug * Make sure you fill in the earliest version that you know has the issue -* By default this will get you to the `future3/main` branch. You will move to the `future3/develop` branch, do this: - -~~~bash -cd ~/RPi-Jukebox-RFID -git checkout future3/develop -git fetch origin -git reset --hard origin/future3/develop -git pull -~~~ The preferred way of code contributions are [pull requests (follow this link for a small howto)](https://www.digitalocean.com/community/tutorials/how-to-create-a-pull-request-on-github). -And, ideally pull requests use the "running code" on the `future3/develop` branch of your Phoniebox. +And ideally pull requests use the "running code" of your Phoniebox. Alternatively, feel free to post tweaks, suggestions and snippets in the ["issues" section](https://github.com/MiczFlor/RPi-Jukebox-RFID/issues). ## Making Changes +* Create a fork of this repository * Create a topic branch from where you want to base your work. - * This is usually the master branch or the develop branch. - * Only target release branches if you are certain your fix must be on that + * This is usually the `future3/develop` branch. + * Only target the `future3/main` branch if you are certain your fix must be on that branch. - * To quickly create a topic branch based on master, run `git checkout -b - fix/master/my_contribution master`. Please avoid working directly on the - `master` branch. * Make commits of logical and atomic units. * Check for unnecessary whitespace with `git diff --check` before committing. +* See also the [documentation for developers](documentation/developers/README.md) ## Making Trivial Changes @@ -168,8 +163,8 @@ The original contributor will be notified of the revert. ## Guidelines -* Phoniebox runs on Raspian **Buster**. Therefore, all Python code should work at least with **Python 3.7**. -* For GPIO all code should work with **RPi.GPIO**. gpiozero is currently not intended to use. +* Phoniebox runs on Raspberry Pi OS. +* Minimum python version is currently **Python 3.9**. ## Additional Resources diff --git a/ci/installation/run_install_common.sh b/ci/installation/run_install_common.sh index 3d4c59778..b8c641580 100644 --- a/ci/installation/run_install_common.sh +++ b/ci/installation/run_install_common.sh @@ -24,7 +24,7 @@ export ENABLE_WEBAPP_PROD_DOWNLOAD=true # n - setup rfid reader # y - setup samba # y - setup webapp -# - - install node (forced WebApp Download) +# - - build webapp (skipped due to forced webapp Download) # n - setup kiosk mode # n - reboot diff --git a/ci/installation/run_install_faststartup.sh b/ci/installation/run_install_faststartup.sh index 249d78ffc..134aeca71 100644 --- a/ci/installation/run_install_faststartup.sh +++ b/ci/installation/run_install_faststartup.sh @@ -22,7 +22,7 @@ LOCAL_INSTALL_SCRIPT_PATH="${LOCAL_INSTALL_SCRIPT_PATH%/}" # n - setup rfid reader # n - setup samba # n - setup webapp -# - - install node (only with webapp = y) +# - - build webapp (only with webapp = y) # - - setup kiosk mode (only with webapp = y) # n - reboot diff --git a/ci/installation/run_install_libzmq_local.sh b/ci/installation/run_install_libzmq_local.sh index aa3726b2f..335cb24a1 100644 --- a/ci/installation/run_install_libzmq_local.sh +++ b/ci/installation/run_install_libzmq_local.sh @@ -23,7 +23,7 @@ export BUILD_LIBZMQ_WITH_DRAFTS_ON_DEVICE=true # n - setup rfid reader # n - setup samba # n - setup webapp -# - - install node (only with webapp = y) +# - - build webapp (only with webapp = y) # - - setup kiosk mode (only with webapp = y) # n - reboot diff --git a/ci/installation/run_install_webapp_download.sh b/ci/installation/run_install_webapp_download.sh index 698e057b9..ded27ec54 100644 --- a/ci/installation/run_install_webapp_download.sh +++ b/ci/installation/run_install_webapp_download.sh @@ -4,7 +4,7 @@ # Used e.g. for tests on Docker # Objective: -# Test for the WebApp (download) and dependent features path. +# Test for the Web App (download) and dependent features path. SOURCE="${BASH_SOURCE[0]}" SCRIPT_DIR="$(dirname "$SOURCE")" @@ -22,7 +22,7 @@ LOCAL_INSTALL_SCRIPT_PATH="${LOCAL_INSTALL_SCRIPT_PATH%/}" # n - setup rfid reader # n - setup samba # y - setup webapp -# n - install node +# n - build webapp # y - setup kiosk mode # n - reboot diff --git a/ci/installation/run_install_webapp_local.sh b/ci/installation/run_install_webapp_local.sh index 917f985af..7af16df3a 100644 --- a/ci/installation/run_install_webapp_local.sh +++ b/ci/installation/run_install_webapp_local.sh @@ -4,7 +4,7 @@ # Used e.g. for tests on Docker # Objective: -# Test for the WebApp (build locally) and dependent features path. +# Test for the Web App (build locally) and dependent features path. SOURCE="${BASH_SOURCE[0]}" SCRIPT_DIR="$(dirname "$SOURCE")" @@ -23,7 +23,7 @@ export ENABLE_WEBAPP_PROD_DOWNLOAD=false # n - setup rfid reader # n - setup samba # y - setup webapp -# y - install node +# y - build webapp # y - setup kiosk mode # n - reboot diff --git a/documentation/builders/README.md b/documentation/builders/README.md index f9fef397e..8ea5e0648 100644 --- a/documentation/builders/README.md +++ b/documentation/builders/README.md @@ -14,6 +14,12 @@ * [Card Database](./card-database.md) * [Troubleshooting](./troubleshooting.md) +## Components +* [Power](./components/power/) + * [OnOff SHIM for safe power on/off](./components/power/onoff-shim.md) +* [Soundcards](./components/soundcards/) + * [HiFiBerry Boards](./components/soundcards/hifiberry.md) + ## Advanced * [Bluetooth (and audio buttons)](./bluetooth-audio-buttons.md) diff --git a/documentation/builders/autohotspot.md b/documentation/builders/autohotspot.md index 69e6f4d6a..ecf996f81 100644 --- a/documentation/builders/autohotspot.md +++ b/documentation/builders/autohotspot.md @@ -2,7 +2,7 @@ The Auto-Hotspot function allows the Jukebox to switch between its connection between a known WiFi and an automatically generated hotspot -so that you can still access via SSH or Webapp. +so that you can still access via SSH or Web App. > [!IMPORTANT] > Please configure the WiFi connection to your home access point before enabling these feature! @@ -17,10 +17,10 @@ hotspot named `Phoniebox_Hotspot`. You will be able to connect to this hotspot using the given password in the installation or the default password: `PlayItLoud!` -### Webapp +### Web App After connecting to the `Phoniebox_Hotspot` you are able to connect to -the webapp accessing the website [10.0.0.5](http://10.0.0.5/). +the Web App accessing the website [10.0.0.5](http://10.0.0.5/). ### ssh @@ -69,7 +69,7 @@ ieee80211d=1 ## Disabling automatism -Auto-Hotspot can be enabled or disabled using the Webapp. +Auto-Hotspot can be enabled or disabled using the Web App. > [!IMPORTANT] > Disabling or enabling will keep the last state. diff --git a/documentation/builders/components/power/onoff-shim.md b/documentation/builders/components/power/onoff-shim.md new file mode 100644 index 000000000..b83ea6140 --- /dev/null +++ b/documentation/builders/components/power/onoff-shim.md @@ -0,0 +1,36 @@ +# OnOff SHIM by Pimorino + +The OnOff SHIM from Pimorino allows you to savely start and shutdown your Raspberry Pi through a button. While you can switch of your Phoniebox via an RFID Card (through an RPC command), it is difficult to switch it on again without cutting the physical power supply. + +## Installation + +To install the software, open a terminal and type the following command to run the one-line-installer. A reboot will be required once the installation is finished. + +> [!NOTE] +> The installation will ask you a few questions. You can safely answer with the default response. + +``` +curl https://get.pimoroni.com/onoffshim | bash +``` + +* [Source](https://shop.pimoroni.com/products/onoff-shim?variant=41102600138) + +## How to manually wire OnOff SHIM + +The OnOff SHIM comes with a 12-PIN header which needs soldering. If you want to spare some GPIO pins for other purposes, you can individually wire the OnOff SHIM with the Raspberry Pi. Below you can find a table of Pins to be connected. + +| Board pin name | Board pin | Physical RPi pin | RPi pin name | +|----------------|-----------|------------------|--------------| +| 3.3V | 1 | 1, 17 | 3V3 power | +| 5V | 2 | 2 | 5V power | +| 5V | 4 | 4 | 5V power | +| GND | 6 | 6, 9, 20, 25 | Ground | +| GPLCLK0 | 7 | 7 | GPIO4 | +| GPIO17 | 11 | 11 | GPIO17 | + +* More information can be found here: https://pinout.xyz/pinout/onoff_shim + +## Assembly options + +![](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 new file mode 100644 index 000000000..1f19fa96d --- /dev/null +++ b/documentation/builders/components/soundcards/hifiberry.md @@ -0,0 +1,49 @@ +# HiFiBerry + +The installation script works for the most common set of HiFiBerry boards but also other "DAC" related sound cards like `I2S PCM5102A DAC`. + +## Automatic setup + +Run the following command to install any HiFiBerry board. Make sure you reboot your device afterwards. + +```bash +cd ~/RPi-Jukebox-RFID/installation/components +./setup_hifiberry.sh +``` + +If you know you HifiBerry Board identifier, you can run the script as a 1-liner as well + +```bash +./setup_hifiberry.sh enable hifiberry-dac +``` + +If you like to disable your HiFiberry Sound card and enable onboard sound, run the following command + + +```bash +./setup_hifiberry.sh disable +``` + +## Additional information + +If you like to understand what's happening under the hood, feel free to check the [install script](../../../../installation/components/setup_hifiberry.sh). + +The setup is based on [HiFiBerry's instructions](https://www.hifiberry.com/docs/software/configuring-linux-3-18-x/). + +## How to manually wire your HiFiBerry board + +Most HiFiBerry boards come with 40-pin header that you can directly attach to your Pi. This idles many GPIO pins that may be required for other inputs to be attached (like GPIO buttons or RFID). You can also connect your HiFiBerry board separately. The following table show cases the pins required. + +* [Raspberry Pi Pinout](https://github.com/raspberrypi/documentation/blob/develop/documentation/asciidoc/computers/os/using-gpio.adoc) + +| Board pin name | Board pin | Physical RPi pin | RPi pin name | +|----------------|-----------|------------------|--------------| +| 3.3V | 1 | 1, 17 | 3V3 power | +| 5V | 2 | 2, 4 | 5V power | +| GND | 6 | 6, 9, 20, 25 | Ground | +| PCM_CLK | 12 | 12 | GPIO18 | +| PCM_FS | 36 | 36 | GPIO19 | +| PCM_DIN | 38 | 38 | GPIO20 | +| PCM_DOUT | 40 | 40 | GPIO21 | + +You can find more information about manually wiring [here](https://forum-raspberrypi.de/forum/thread/44967-kein-ton-ueber-hifiberry-miniamp-am-rpi-4/?postID=401305#post401305). diff --git a/documentation/builders/concepts.md b/documentation/builders/concepts.md index 37c13db89..0a4305cfe 100644 --- a/documentation/builders/concepts.md +++ b/documentation/builders/concepts.md @@ -12,7 +12,7 @@ The core app is centered around a plugin concept. This serves three purposes: ## Remote Procedure Call Server (RPC) -The Remote Procedure Call (RPC) server allows remotely triggering actions (e.g., from the Webapp) within the Jukebox core application. Only Python functions registered by the plugin interface can be called. This simplifies external APIs and lets us focus on the relevant user functions. +The Remote Procedure Call (RPC) server allows remotely triggering actions (e.g., from the Web App) within the Jukebox core application. Only Python functions registered by the plugin interface can be called. This simplifies external APIs and lets us focus on the relevant user functions. Why should you care? Because we use the same protocol when triggering actions from other inputs like a card swipe, a GPIO button press, etc. How that works is described in [RPC Commands](rpc-commands.md). diff --git a/documentation/builders/installation.md b/documentation/builders/installation.md index 945368480..7ce0e59c0 100644 --- a/documentation/builders/installation.md +++ b/documentation/builders/installation.md @@ -3,7 +3,7 @@ ## Install Raspberry Pi OS Lite > [!IMPORTANT] -> Currently, the installation does only work on Raspberry Pi's with ARMv7 and ARMv8 architecture, so 2, 3 and 4! Pi 1 and Zero's are currently unstable and will 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. @@ -79,30 +79,48 @@ You will need a terminal, like PuTTY for Windows or the Terminal app for Mac to ## Install Phoniebox software -Run the following command in your SSH terminal and follow the instructions +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) + +After a successful installation, [configure your Phoniebox](configuration.md). + +> [!TIP] +> 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 cd; bash <(wget -qO- https://raw.githubusercontent.com/MiczFlor/RPi-Jukebox-RFID/future3/main/installation/install-jukebox.sh) ``` -This will get the latest **stable release** from the branch *future3/main*. +### Pre-Release +This will install the latest **pre-release** from the *future3/develop* branch. -To install directly from a specific branch and/or a different repository -specify the variables like this: +```bash +cd; GIT_BRANCH='future3/develop' bash <(wget -qO- https://raw.githubusercontent.com/MiczFlor/RPi-Jukebox-RFID/future3/develop/installation/install-jukebox.sh) +``` + +### 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] +> A fork repository must be named '*RPi-Jukebox-RFID*' like the official repository ```bash cd; GIT_USER='MiczFlor' GIT_BRANCH='future3/develop' bash <(wget -qO- https://raw.githubusercontent.com/MiczFlor/RPi-Jukebox-RFID/future3/develop/installation/install-jukebox.sh) ``` -This will switch directly to the specified feature branch during installation. - > [!NOTE] -> For all branches *except* the current Release future3/main, you will need to build the Web App locally on the Pi. This is not part of the installation process due to memory limitation issues. See [Developer steps to install](../developers/development-environment.md#steps-to-install) +> The Installation of the official repository's release branches ([Stable Release](#stable-release) and [Pre-Release](#pre-release)) will deploy a pre-build bundle of the Web App. +> 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. -If you suspect an error you can monitor the installation-process with +### Logs +To follow the installation closely, use this command in another terminal. ```bash cd; tail -f INSTALL-.log ``` -After successful installation, continue with [configuring your Phoniebox](configuration.md). diff --git a/documentation/builders/system.md b/documentation/builders/system.md index 2f7df8888..f6eeb7ba1 100644 --- a/documentation/builders/system.md +++ b/documentation/builders/system.md @@ -7,7 +7,7 @@ The system consists of 1. [Music Player Daemon (MPD)](system.md#music-player-daemon-mpd) which we use for all music playback (local, stream, podcast, ...) 2. [PulseAudio](system.md#pulseaudio) for flexible audio output support 3. [Jukebox Core Service](system.md#jukebox-core-service) for controlling MPD and PulseAudio and providing all the features -4. [Web UI](system.md#web-ui) which is served through an Nginx web server +4. [Web App](system.md#web-app-ui) as User Interface (UI) for a web browser 5. A set of [Configuration Tools](../developers/coreapps.md#configuration-tools) and a set of [Developer Tools](../developers/coreapps.md#developer-tools) > [!NOTE] The default install puts everything into the users home folder `~/RPi-Jukebox-RFID`. @@ -96,9 +96,9 @@ The `systemd` service file is located at the default location for user services: Starting and stopping the service can be useful for debugging or configuration checks. -## Web UI +## Web App (UI) -The Web UI is served using nginx. Nginx runs as a system service. The home directory is localed at +The [Web App](../developers/webapp.md) is served using nginx. Nginx runs as a system service. The home directory is located at ```text ./src/webapp/build diff --git a/documentation/builders/update.md b/documentation/builders/update.md index f6dab2c91..94baf0a93 100644 --- a/documentation/builders/update.md +++ b/documentation/builders/update.md @@ -29,7 +29,7 @@ $ git pull $ diff shared/settings/jukebox.yaml resources/default-settings/jukebox.default.yaml $ cd src/webapp -$ ./run_rebuild.sh +$ ./run_rebuild.sh -u ``` ## Migration Path from Version 2 diff --git a/documentation/developers/README.md b/documentation/developers/README.md index 64fee44c1..f6ec65dc4 100644 --- a/documentation/developers/README.md +++ b/documentation/developers/README.md @@ -8,7 +8,10 @@ ## Reference * [Jukebox Apps](./coreapps.md) +* [Web App](./webapp.md) * [RFID Readers](./rfid) +* [Docstring API Docs (from py files)](./docstring/README.md) +* [Plugin Reference](./docstring/README.md#jukeboxplugs) * [Feature Status](./status.md) * [Known Issues](./known-issues.md) diff --git a/documentation/developers/developer-issues.md b/documentation/developers/developer-issues.md deleted file mode 100644 index bcb4d491a..000000000 --- a/documentation/developers/developer-issues.md +++ /dev/null @@ -1,86 +0,0 @@ -# Developer Issues - -## Building the Webapp on the PI - -### JavaScript heap out of memory - -While (re-) building the Web App, you get the following output: - -``` {.bash emphasize-lines="12"} -pi@MusicPi:~/RPi-Jukebox-RFID/src/webapp $ npm run build - -> webapp@0.1.0 build -> react-scripts build - -Creating an optimized production build... - -[...] - -<--- JS stacktrace ---> - -FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory -``` - -#### Reason - -Not enough memory for Node - -#### Solution - -Prior to building set the node memory environment variable. - -1. Make sure the value is less than the total available space on the - system, or you may run into the next issue. (Not always though!) - Check memory availability with `free -mt`. -2. We also experience trouble, when the space is set too small a - value. 512 always works, 256 sometimes does, sometimes does not. - If your free memory is small, consider increasing the swap size of - your system! - -``` bash -export NODE_OPTIONS=--max-old-space-size=512 -npm run build -``` - -Alternatively, use the provided script, which sets the variable for you -(provided your swap size is large enough): - -``` bash -$ cd src/webapp -$ ./run_rebuild.sh -``` - -#### Changing Swap Size - -This will set the swapsize to 1024 MB (and will deactivate swapfactor). Change accordingly if you have a SD Card with small capacity. - -```bash -sudo dphys-swapfile swapoff -sudo sed -i "s|.*CONF_SWAPSIZE=.*|CONF_SWAPSIZE=1024|g" /etc/dphys-swapfile -sudo sed -i "s|^\s*CONF_SWAPFACTOR=|#CONF_SWAPFACTOR=|g" /etc/dphys-swapfile -sudo dphys-swapfile setup -sudo dphys-swapfile swapon -``` - -### Process exited too early // kill -9 - -``` {.bash emphasize-lines="8,9"} -pi@MusicPi:~/RPi-Jukebox-RFID/src/webapp $ npm run build - -> webapp@0.1.0 build -> react-scripts build - -... - -The build failed because the process exited too early. -This probably means the system ran out of memory or someone called 'kill -9' on the process. -``` - -#### Reason - -Node tried to allocate more memory than available on the system. - -#### Solution - -Adjust the node memory variable as described in [JavaScript heap out of memory](#javascript-heap-out-of-memory). But make sure to allocate less memory than the available memory. If that is not sufficient, increase the swap file size of your -system and try again. diff --git a/documentation/developers/development-environment.md b/documentation/developers/development-environment.md index 249f6263c..d2d995919 100644 --- a/documentation/developers/development-environment.md +++ b/documentation/developers/development-environment.md @@ -1,6 +1,6 @@ # Development Environment -You have 3 development options. Each option has its pros and cons. To interact with GPIO or other hardware, it's required to develop directly on a Raspberry Pi. For general development of Python code (Jukebox) or JavaScript (Webapp), we recommend Docker. Developing on your local machine (Linux, Mac, Windows) works as well and requires all dependencies to be installed locally. +You have 3 development options. Each option has its pros and cons. To interact with GPIO or other hardware, it's required to develop directly on a Raspberry Pi. For general development of Python code (Jukebox) or JavaScript (Web App), we recommend Docker. Developing on your local machine (Linux, Mac, Windows) works as well and requires all dependencies to be installed locally. - [Development Environment](#development-environment) - [Develop in Docker](#develop-in-docker) @@ -15,35 +15,15 @@ There is a complete [Docker setup](./docker.md). ## Develop on Raspberry Pi -The full setup is running on the RPi and you access files via SSH. Pretty easy to set up as you simply do a normal install and switch to the `future3/develop` branch. +The full setup is running on the RPi and you access files via SSH. ### Steps to install -We recommend to use at least a Pi 3 or Pi Zero 2 for development. This hardware won\'t be needed in production, but it can be slow while developing. +We recommend to use at least a Pi 3 or Pi Zero 2 for development. While this hardware won\'t be needed in production, it comes in helpful while developing. -1. Install the latest Pi OS on a SD card. -2. Boot up your Raspberry Pi. -3. [Install](../builders/installation.md) the Jukebox software as if you were building a Phoniebox. You can install from your own fork and feature branch you wish which can be changed later as well. The original repository will be set as `upstream`. -4. Once the installation has successfully ran, reboot your Pi. -5. Due to some resource constraints, the Webapp does not build the latest changes and instead consumes the latest official release. To change that, you need to install NodeJS and build the Webapp locally. -6. Install NodeJS using the existing installer - - ``` bash - cd ~/RPi-Jukebox-RFID/installation/routines; \ - source setup_jukebox_webapp.sh; \ - _jukebox_webapp_install_node - ``` - -7. To free up RAM, reboot your Pi. -8. Build the Webapp using the existing build command. If the build fails, you might have forgotten to reboot. - - ``` bash - cd ~/RPi-Jukebox-RFID/src/webapp; \ - ./run_rebuild.sh -u - ``` - -9. The Webapp should now be updated. -10. To continuously update Webapp, pull the latest changes from your repository and rerun the command above. +1. Follow the [installation preperation](../builders/installation.md#install-raspberry-pi-os-lite) steps +1. [Install](../builders/installation.md#development) your feature/fork branch of the Jukebox software. The official repository will be set as `upstream`. +1. If neccessary [build the Web App](./webapp.md) locally ## Develop on local machine diff --git a/documentation/developers/docker.md b/documentation/developers/docker.md index 80651ce84..827178aca 100644 --- a/documentation/developers/docker.md +++ b/documentation/developers/docker.md @@ -15,7 +15,7 @@ need to adapt some of those commands to your needs. ## Prerequisites 1. Install required software: Docker, Compose and pulseaudio - * Check installations guide for [Mac](#mac), [Windows](#windows) or [Linux](#linux) + * Check installation guide for [Mac](#mac), [Windows](#windows) or [Linux](#linux) 2. Pull the Jukebox repository: @@ -30,7 +30,7 @@ need to adapt some of those commands to your needs. $ cp ./resources/default-settings/jukebox.default.yaml ./shared/settings/jukebox.yaml ``` - * Override/Merge the values from the following [Override file](https://github.com/MiczFlor/RPi-Jukebox-RFID/blob/future3/develop/docker/config/jukebox.overrides.yaml) in your `jukebox.yaml`. + * Override/Merge the values from the following [Override file](../../docker/config/jukebox.overrides.yaml) in your `jukebox.yaml`. * **\[Currently required\]** Update all relative paths (`../..`) in to `/home/pi/RPi-Jukebox-RFID`. 4. Change directory into the `./shared/audiofolders` @@ -159,7 +159,7 @@ Read these threads for details: [thread 1](https://unix.stackexchange.com/questi The Dockerfile is defined to start all Phoniebox related services. -Open in your browser to see the web application. +Open in your browser to see the Web App. While the `webapp` container does not require a reload while working on it (hot-reload is enabled), you will have to restart your `jukebox` diff --git a/documentation/developers/docstring/README.md b/documentation/developers/docstring/README.md new file mode 100644 index 000000000..dde85b224 --- /dev/null +++ b/documentation/developers/docstring/README.md @@ -0,0 +1,5914 @@ +# None + +## Table of Contents + +* [run\_jukebox](#run_jukebox) +* [\_\_init\_\_](#__init__) +* [run\_register\_rfid\_reader](#run_register_rfid_reader) +* [run\_rpc\_tool](#run_rpc_tool) + * [get\_common\_beginning](#run_rpc_tool.get_common_beginning) + * [runcmd](#run_rpc_tool.runcmd) +* [run\_configure\_audio](#run_configure_audio) +* [run\_publicity\_sniffer](#run_publicity_sniffer) +* [misc](#misc) + * [recursive\_chmod](#misc.recursive_chmod) + * [flatten](#misc.flatten) + * [getattr\_hierarchical](#misc.getattr_hierarchical) +* [misc.inputminus](#misc.inputminus) + * [input\_int](#misc.inputminus.input_int) + * [input\_yesno](#misc.inputminus.input_yesno) +* [misc.loggingext](#misc.loggingext) + * [ColorFilter](#misc.loggingext.ColorFilter) + * [\_\_init\_\_](#misc.loggingext.ColorFilter.__init__) + * [PubStream](#misc.loggingext.PubStream) + * [PubStreamHandler](#misc.loggingext.PubStreamHandler) +* [misc.simplecolors](#misc.simplecolors) + * [Colors](#misc.simplecolors.Colors) + * [resolve](#misc.simplecolors.resolve) + * [print](#misc.simplecolors.print) +* [components](#components) +* [components.playermpd.playcontentcallback](#components.playermpd.playcontentcallback) + * [PlayContentCallbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks) + * [register](#components.playermpd.playcontentcallback.PlayContentCallbacks.register) + * [run\_callbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks.run_callbacks) +* [components.playermpd](#components.playermpd) + * [PlayerMPD](#components.playermpd.PlayerMPD) + * [mpd\_retry\_with\_mutex](#components.playermpd.PlayerMPD.mpd_retry_with_mutex) + * [pause](#components.playermpd.PlayerMPD.pause) + * [next](#components.playermpd.PlayerMPD.next) + * [rewind](#components.playermpd.PlayerMPD.rewind) + * [replay](#components.playermpd.PlayerMPD.replay) + * [toggle](#components.playermpd.PlayerMPD.toggle) + * [replay\_if\_stopped](#components.playermpd.PlayerMPD.replay_if_stopped) + * [play\_card](#components.playermpd.PlayerMPD.play_card) + * [get\_single\_coverart](#components.playermpd.PlayerMPD.get_single_coverart) + * [get\_folder\_content](#components.playermpd.PlayerMPD.get_folder_content) + * [play\_folder](#components.playermpd.PlayerMPD.play_folder) + * [play\_album](#components.playermpd.PlayerMPD.play_album) + * [get\_volume](#components.playermpd.PlayerMPD.get_volume) + * [set\_volume](#components.playermpd.PlayerMPD.set_volume) + * [play\_card\_callbacks](#components.playermpd.play_card_callbacks) +* [components.playermpd.coverart\_cache\_manager](#components.playermpd.coverart_cache_manager) +* [components.rpc\_command\_alias](#components.rpc_command_alias) +* [components.synchronisation.rfidcards](#components.synchronisation.rfidcards) + * [SyncRfidcards](#components.synchronisation.rfidcards.SyncRfidcards) + * [sync\_change\_on\_rfid\_scan](#components.synchronisation.rfidcards.SyncRfidcards.sync_change_on_rfid_scan) + * [sync\_all](#components.synchronisation.rfidcards.SyncRfidcards.sync_all) + * [sync\_card\_database](#components.synchronisation.rfidcards.SyncRfidcards.sync_card_database) + * [sync\_folder](#components.synchronisation.rfidcards.SyncRfidcards.sync_folder) +* [components.synchronisation](#components.synchronisation) +* [components.synchronisation.syncutils](#components.synchronisation.syncutils) +* [components.volume](#components.volume) + * [PulseMonitor](#components.volume.PulseMonitor) + * [SoundCardConnectCallbacks](#components.volume.PulseMonitor.SoundCardConnectCallbacks) + * [toggle\_on\_connect](#components.volume.PulseMonitor.toggle_on_connect) + * [toggle\_on\_connect](#components.volume.PulseMonitor.toggle_on_connect) + * [stop](#components.volume.PulseMonitor.stop) + * [run](#components.volume.PulseMonitor.run) + * [PulseVolumeControl](#components.volume.PulseVolumeControl) + * [OutputChangeCallbackHandler](#components.volume.PulseVolumeControl.OutputChangeCallbackHandler) + * [OutputVolumeCallbackHandler](#components.volume.PulseVolumeControl.OutputVolumeCallbackHandler) + * [toggle\_output](#components.volume.PulseVolumeControl.toggle_output) + * [get\_outputs](#components.volume.PulseVolumeControl.get_outputs) + * [publish\_volume](#components.volume.PulseVolumeControl.publish_volume) + * [publish\_outputs](#components.volume.PulseVolumeControl.publish_outputs) + * [set\_volume](#components.volume.PulseVolumeControl.set_volume) + * [get\_volume](#components.volume.PulseVolumeControl.get_volume) + * [change\_volume](#components.volume.PulseVolumeControl.change_volume) + * [get\_mute](#components.volume.PulseVolumeControl.get_mute) + * [mute](#components.volume.PulseVolumeControl.mute) + * [set\_output](#components.volume.PulseVolumeControl.set_output) + * [set\_soft\_max\_volume](#components.volume.PulseVolumeControl.set_soft_max_volume) + * [get\_soft\_max\_volume](#components.volume.PulseVolumeControl.get_soft_max_volume) + * [card\_list](#components.volume.PulseVolumeControl.card_list) +* [components.rfid](#components.rfid) +* [components.rfid.reader](#components.rfid.reader) + * [RfidCardDetectCallbacks](#components.rfid.reader.RfidCardDetectCallbacks) + * [register](#components.rfid.reader.RfidCardDetectCallbacks.register) + * [run\_callbacks](#components.rfid.reader.RfidCardDetectCallbacks.run_callbacks) + * [rfid\_card\_detect\_callbacks](#components.rfid.reader.rfid_card_detect_callbacks) + * [CardRemovalTimerClass](#components.rfid.reader.CardRemovalTimerClass) + * [\_\_init\_\_](#components.rfid.reader.CardRemovalTimerClass.__init__) +* [components.rfid.configure](#components.rfid.configure) + * [reader\_install\_dependencies](#components.rfid.configure.reader_install_dependencies) + * [reader\_load\_module](#components.rfid.configure.reader_load_module) + * [query\_user\_for\_reader](#components.rfid.configure.query_user_for_reader) + * [write\_config](#components.rfid.configure.write_config) +* [components.rfid.hardware.fake\_reader\_gui.fake\_reader\_gui](#components.rfid.hardware.fake_reader_gui.fake_reader_gui) +* [components.rfid.hardware.fake\_reader\_gui.description](#components.rfid.hardware.fake_reader_gui.description) +* [components.rfid.hardware.fake\_reader\_gui.gpioz\_gui\_addon](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon) + * [create\_inputs](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.create_inputs) + * [set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.set_state) + * [que\_set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.que_set_state) + * [fix\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.fix_state) + * [pbox\_set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.pbox_set_state) + * [que\_set\_pbox](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.que_set_pbox) + * [create\_outputs](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.create_outputs) +* [components.rfid.hardware.generic\_usb.description](#components.rfid.hardware.generic_usb.description) +* [components.rfid.hardware.generic\_usb.generic\_usb](#components.rfid.hardware.generic_usb.generic_usb) +* [components.rfid.hardware.rc522\_spi.description](#components.rfid.hardware.rc522_spi.description) +* [components.rfid.hardware.rc522\_spi.rc522\_spi](#components.rfid.hardware.rc522_spi.rc522_spi) +* [components.rfid.hardware.pn532\_i2c\_py532.description](#components.rfid.hardware.pn532_i2c_py532.description) +* [components.rfid.hardware.pn532\_i2c\_py532.pn532\_i2c\_py532](#components.rfid.hardware.pn532_i2c_py532.pn532_i2c_py532) +* [components.rfid.hardware.rdm6300\_serial.rdm6300\_serial](#components.rfid.hardware.rdm6300_serial.rdm6300_serial) + * [decode](#components.rfid.hardware.rdm6300_serial.rdm6300_serial.decode) +* [components.rfid.hardware.rdm6300\_serial.description](#components.rfid.hardware.rdm6300_serial.description) +* [components.rfid.hardware.template\_new\_reader.description](#components.rfid.hardware.template_new_reader.description) +* [components.rfid.hardware.template\_new\_reader.template\_new\_reader](#components.rfid.hardware.template_new_reader.template_new_reader) + * [query\_customization](#components.rfid.hardware.template_new_reader.template_new_reader.query_customization) + * [ReaderClass](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass) + * [\_\_init\_\_](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.__init__) + * [cleanup](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.cleanup) + * [stop](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.stop) + * [read\_card](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.read_card) +* [components.rfid.readerbase](#components.rfid.readerbase) + * [ReaderBaseClass](#components.rfid.readerbase.ReaderBaseClass) +* [components.rfid.cards](#components.rfid.cards) + * [list\_cards](#components.rfid.cards.list_cards) + * [delete\_card](#components.rfid.cards.delete_card) + * [register\_card](#components.rfid.cards.register_card) + * [register\_card\_custom](#components.rfid.cards.register_card_custom) + * [save\_card\_database](#components.rfid.cards.save_card_database) +* [components.rfid.cardutils](#components.rfid.cardutils) + * [decode\_card\_command](#components.rfid.cardutils.decode_card_command) + * [card\_command\_to\_str](#components.rfid.cardutils.card_command_to_str) + * [card\_to\_str](#components.rfid.cardutils.card_to_str) +* [components.publishing](#components.publishing) + * [republish](#components.publishing.republish) +* [components.player](#components.player) + * [MusicLibPath](#components.player.MusicLibPath) + * [get\_music\_library\_path](#components.player.get_music_library_path) +* [components.jingle](#components.jingle) + * [JingleFactory](#components.jingle.JingleFactory) + * [list](#components.jingle.JingleFactory.list) + * [play](#components.jingle.play) + * [play\_startup](#components.jingle.play_startup) + * [play\_shutdown](#components.jingle.play_shutdown) +* [components.jingle.alsawave](#components.jingle.alsawave) + * [AlsaWave](#components.jingle.alsawave.AlsaWave) + * [play](#components.jingle.alsawave.AlsaWave.play) + * [AlsaWaveBuilder](#components.jingle.alsawave.AlsaWaveBuilder) + * [\_\_init\_\_](#components.jingle.alsawave.AlsaWaveBuilder.__init__) +* [components.jingle.jinglemp3](#components.jingle.jinglemp3) + * [JingleMp3Play](#components.jingle.jinglemp3.JingleMp3Play) + * [play](#components.jingle.jinglemp3.JingleMp3Play.play) + * [JingleMp3PlayBuilder](#components.jingle.jinglemp3.JingleMp3PlayBuilder) + * [\_\_init\_\_](#components.jingle.jinglemp3.JingleMp3PlayBuilder.__init__) +* [components.hostif.linux](#components.hostif.linux) + * [shutdown](#components.hostif.linux.shutdown) + * [reboot](#components.hostif.linux.reboot) + * [jukebox\_is\_service](#components.hostif.linux.jukebox_is_service) + * [is\_any\_jukebox\_service\_active](#components.hostif.linux.is_any_jukebox_service_active) + * [restart\_service](#components.hostif.linux.restart_service) + * [get\_disk\_usage](#components.hostif.linux.get_disk_usage) + * [get\_cpu\_temperature](#components.hostif.linux.get_cpu_temperature) + * [get\_ip\_address](#components.hostif.linux.get_ip_address) + * [wlan\_disable\_power\_down](#components.hostif.linux.wlan_disable_power_down) + * [get\_autohotspot\_status](#components.hostif.linux.get_autohotspot_status) + * [stop\_autohotspot](#components.hostif.linux.stop_autohotspot) + * [start\_autohotspot](#components.hostif.linux.start_autohotspot) +* [components.misc](#components.misc) + * [rpc\_cmd\_help](#components.misc.rpc_cmd_help) + * [get\_all\_loaded\_packages](#components.misc.get_all_loaded_packages) + * [get\_all\_failed\_packages](#components.misc.get_all_failed_packages) + * [get\_start\_time](#components.misc.get_start_time) + * [get\_log](#components.misc.get_log) + * [get\_log\_debug](#components.misc.get_log_debug) + * [get\_log\_error](#components.misc.get_log_error) + * [get\_git\_state](#components.misc.get_git_state) + * [empty\_rpc\_call](#components.misc.empty_rpc_call) +* [components.controls](#components.controls) +* [components.controls.bluetooth\_audio\_buttons](#components.controls.bluetooth_audio_buttons) +* [components.controls.common.evdev\_listener](#components.controls.common.evdev_listener) + * [find\_device](#components.controls.common.evdev_listener.find_device) + * [EvDevKeyListener](#components.controls.common.evdev_listener.EvDevKeyListener) + * [\_\_init\_\_](#components.controls.common.evdev_listener.EvDevKeyListener.__init__) + * [run](#components.controls.common.evdev_listener.EvDevKeyListener.run) + * [start](#components.controls.common.evdev_listener.EvDevKeyListener.start) +* [components.battery\_monitor](#components.battery_monitor) +* [components.battery\_monitor.BatteryMonitorBase](#components.battery_monitor.BatteryMonitorBase) + * [pt1\_frac](#components.battery_monitor.BatteryMonitorBase.pt1_frac) + * [BattmonBase](#components.battery_monitor.BatteryMonitorBase.BattmonBase) +* [components.battery\_monitor.batt\_mon\_simulator](#components.battery_monitor.batt_mon_simulator) + * [battmon\_simulator](#components.battery_monitor.batt_mon_simulator.battmon_simulator) +* [components.battery\_monitor.batt\_mon\_i2c\_ads1015](#components.battery_monitor.batt_mon_i2c_ads1015) + * [battmon\_ads1015](#components.battery_monitor.batt_mon_i2c_ads1015.battmon_ads1015) +* [components.gpio.gpioz.plugin](#components.gpio.gpioz.plugin) + * [output\_devices](#components.gpio.gpioz.plugin.output_devices) + * [input\_devices](#components.gpio.gpioz.plugin.input_devices) + * [factory](#components.gpio.gpioz.plugin.factory) + * [IS\_ENABLED](#components.gpio.gpioz.plugin.IS_ENABLED) + * [IS\_MOCKED](#components.gpio.gpioz.plugin.IS_MOCKED) + * [CONFIG\_FILE](#components.gpio.gpioz.plugin.CONFIG_FILE) + * [ServiceIsRunningCallbacks](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks) + * [register](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks.register) + * [run\_callbacks](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks.run_callbacks) + * [service\_is\_running\_callbacks](#components.gpio.gpioz.plugin.service_is_running_callbacks) + * [build\_output\_device](#components.gpio.gpioz.plugin.build_output_device) + * [build\_input\_device](#components.gpio.gpioz.plugin.build_input_device) + * [get\_output](#components.gpio.gpioz.plugin.get_output) + * [on](#components.gpio.gpioz.plugin.on) + * [off](#components.gpio.gpioz.plugin.off) + * [set\_value](#components.gpio.gpioz.plugin.set_value) + * [flash](#components.gpio.gpioz.plugin.flash) +* [components.gpio.gpioz.plugin.connectivity](#components.gpio.gpioz.plugin.connectivity) + * [BUZZ\_TONE](#components.gpio.gpioz.plugin.connectivity.BUZZ_TONE) + * [register\_rfid\_callback](#components.gpio.gpioz.plugin.connectivity.register_rfid_callback) + * [register\_status\_led\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_led_callback) + * [register\_status\_buzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_buzzer_callback) + * [register\_status\_tonalbuzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_tonalbuzzer_callback) + * [register\_audio\_sink\_change\_callback](#components.gpio.gpioz.plugin.connectivity.register_audio_sink_change_callback) + * [register\_volume\_led\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_led_callback) + * [register\_volume\_buzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_buzzer_callback) + * [register\_volume\_rgbled\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_rgbled_callback) +* [components.gpio.gpioz.core.converter](#components.gpio.gpioz.core.converter) + * [ColorProperty](#components.gpio.gpioz.core.converter.ColorProperty) + * [VolumeToRGB](#components.gpio.gpioz.core.converter.VolumeToRGB) + * [\_\_call\_\_](#components.gpio.gpioz.core.converter.VolumeToRGB.__call__) + * [luminize](#components.gpio.gpioz.core.converter.VolumeToRGB.luminize) +* [components.gpio.gpioz.core.mock](#components.gpio.gpioz.core.mock) + * [patch\_mock\_outputs\_with\_callback](#components.gpio.gpioz.core.mock.patch_mock_outputs_with_callback) +* [components.gpio.gpioz.core.input\_devices](#components.gpio.gpioz.core.input_devices) + * [NameMixin](#components.gpio.gpioz.core.input_devices.NameMixin) + * [set\_rpc\_actions](#components.gpio.gpioz.core.input_devices.NameMixin.set_rpc_actions) + * [EventProperty](#components.gpio.gpioz.core.input_devices.EventProperty) + * [ButtonBase](#components.gpio.gpioz.core.input_devices.ButtonBase) + * [value](#components.gpio.gpioz.core.input_devices.ButtonBase.value) + * [pin](#components.gpio.gpioz.core.input_devices.ButtonBase.pin) + * [pull\_up](#components.gpio.gpioz.core.input_devices.ButtonBase.pull_up) + * [close](#components.gpio.gpioz.core.input_devices.ButtonBase.close) + * [Button](#components.gpio.gpioz.core.input_devices.Button) + * [on\_press](#components.gpio.gpioz.core.input_devices.Button.on_press) + * [LongPressButton](#components.gpio.gpioz.core.input_devices.LongPressButton) + * [on\_press](#components.gpio.gpioz.core.input_devices.LongPressButton.on_press) + * [ShortLongPressButton](#components.gpio.gpioz.core.input_devices.ShortLongPressButton) + * [RotaryEncoder](#components.gpio.gpioz.core.input_devices.RotaryEncoder) + * [pin\_a](#components.gpio.gpioz.core.input_devices.RotaryEncoder.pin_a) + * [pin\_b](#components.gpio.gpioz.core.input_devices.RotaryEncoder.pin_b) + * [on\_rotate\_clockwise](#components.gpio.gpioz.core.input_devices.RotaryEncoder.on_rotate_clockwise) + * [on\_rotate\_counter\_clockwise](#components.gpio.gpioz.core.input_devices.RotaryEncoder.on_rotate_counter_clockwise) + * [close](#components.gpio.gpioz.core.input_devices.RotaryEncoder.close) + * [TwinButton](#components.gpio.gpioz.core.input_devices.TwinButton) + * [StateVar](#components.gpio.gpioz.core.input_devices.TwinButton.StateVar) + * [close](#components.gpio.gpioz.core.input_devices.TwinButton.close) + * [value](#components.gpio.gpioz.core.input_devices.TwinButton.value) + * [is\_active](#components.gpio.gpioz.core.input_devices.TwinButton.is_active) +* [components.gpio.gpioz.core.output\_devices](#components.gpio.gpioz.core.output_devices) + * [LED](#components.gpio.gpioz.core.output_devices.LED) + * [flash](#components.gpio.gpioz.core.output_devices.LED.flash) + * [Buzzer](#components.gpio.gpioz.core.output_devices.Buzzer) + * [flash](#components.gpio.gpioz.core.output_devices.Buzzer.flash) + * [PWMLED](#components.gpio.gpioz.core.output_devices.PWMLED) + * [flash](#components.gpio.gpioz.core.output_devices.PWMLED.flash) + * [RGBLED](#components.gpio.gpioz.core.output_devices.RGBLED) + * [flash](#components.gpio.gpioz.core.output_devices.RGBLED.flash) + * [TonalBuzzer](#components.gpio.gpioz.core.output_devices.TonalBuzzer) + * [flash](#components.gpio.gpioz.core.output_devices.TonalBuzzer.flash) + * [melody](#components.gpio.gpioz.core.output_devices.TonalBuzzer.melody) +* [components.timers](#components.timers) +* [jukebox](#jukebox) +* [jukebox.callingback](#jukebox.callingback) + * [CallbackHandler](#jukebox.callingback.CallbackHandler) + * [register](#jukebox.callingback.CallbackHandler.register) + * [run\_callbacks](#jukebox.callingback.CallbackHandler.run_callbacks) + * [has\_callbacks](#jukebox.callingback.CallbackHandler.has_callbacks) +* [jukebox.version](#jukebox.version) + * [version](#jukebox.version.version) + * [version\_info](#jukebox.version.version_info) +* [jukebox.cfghandler](#jukebox.cfghandler) + * [ConfigHandler](#jukebox.cfghandler.ConfigHandler) + * [loaded\_from](#jukebox.cfghandler.ConfigHandler.loaded_from) + * [get](#jukebox.cfghandler.ConfigHandler.get) + * [setdefault](#jukebox.cfghandler.ConfigHandler.setdefault) + * [getn](#jukebox.cfghandler.ConfigHandler.getn) + * [setn](#jukebox.cfghandler.ConfigHandler.setn) + * [setndefault](#jukebox.cfghandler.ConfigHandler.setndefault) + * [config\_dict](#jukebox.cfghandler.ConfigHandler.config_dict) + * [is\_modified](#jukebox.cfghandler.ConfigHandler.is_modified) + * [clear\_modified](#jukebox.cfghandler.ConfigHandler.clear_modified) + * [save](#jukebox.cfghandler.ConfigHandler.save) + * [load](#jukebox.cfghandler.ConfigHandler.load) + * [get\_handler](#jukebox.cfghandler.get_handler) + * [load\_yaml](#jukebox.cfghandler.load_yaml) + * [write\_yaml](#jukebox.cfghandler.write_yaml) +* [jukebox.playlistgenerator](#jukebox.playlistgenerator) + * [TYPE\_DECODE](#jukebox.playlistgenerator.TYPE_DECODE) + * [PlaylistCollector](#jukebox.playlistgenerator.PlaylistCollector) + * [\_\_init\_\_](#jukebox.playlistgenerator.PlaylistCollector.__init__) + * [set\_exclusion\_endings](#jukebox.playlistgenerator.PlaylistCollector.set_exclusion_endings) + * [get\_directory\_content](#jukebox.playlistgenerator.PlaylistCollector.get_directory_content) + * [parse](#jukebox.playlistgenerator.PlaylistCollector.parse) +* [jukebox.NvManager](#jukebox.NvManager) +* [jukebox.publishing](#jukebox.publishing) + * [get\_publisher](#jukebox.publishing.get_publisher) +* [jukebox.publishing.subscriber](#jukebox.publishing.subscriber) +* [jukebox.publishing.server](#jukebox.publishing.server) + * [PublishServer](#jukebox.publishing.server.PublishServer) + * [run](#jukebox.publishing.server.PublishServer.run) + * [handle\_message](#jukebox.publishing.server.PublishServer.handle_message) + * [handle\_subscription](#jukebox.publishing.server.PublishServer.handle_subscription) + * [Publisher](#jukebox.publishing.server.Publisher) + * [\_\_init\_\_](#jukebox.publishing.server.Publisher.__init__) + * [send](#jukebox.publishing.server.Publisher.send) + * [revoke](#jukebox.publishing.server.Publisher.revoke) + * [resend](#jukebox.publishing.server.Publisher.resend) + * [close\_server](#jukebox.publishing.server.Publisher.close_server) +* [jukebox.daemon](#jukebox.daemon) + * [log\_active\_threads](#jukebox.daemon.log_active_threads) + * [JukeBox](#jukebox.daemon.JukeBox) + * [signal\_handler](#jukebox.daemon.JukeBox.signal_handler) +* [jukebox.plugs](#jukebox.plugs) + * [PluginPackageClass](#jukebox.plugs.PluginPackageClass) + * [register](#jukebox.plugs.register) + * [register](#jukebox.plugs.register) + * [register](#jukebox.plugs.register) + * [register](#jukebox.plugs.register) + * [register](#jukebox.plugs.register) + * [register](#jukebox.plugs.register) + * [tag](#jukebox.plugs.tag) + * [initialize](#jukebox.plugs.initialize) + * [finalize](#jukebox.plugs.finalize) + * [atexit](#jukebox.plugs.atexit) + * [load](#jukebox.plugs.load) + * [load\_all\_named](#jukebox.plugs.load_all_named) + * [load\_all\_unnamed](#jukebox.plugs.load_all_unnamed) + * [load\_all\_finalize](#jukebox.plugs.load_all_finalize) + * [close\_down](#jukebox.plugs.close_down) + * [call](#jukebox.plugs.call) + * [call\_ignore\_errors](#jukebox.plugs.call_ignore_errors) + * [exists](#jukebox.plugs.exists) + * [get](#jukebox.plugs.get) + * [loaded\_as](#jukebox.plugs.loaded_as) + * [delete](#jukebox.plugs.delete) + * [dump\_plugins](#jukebox.plugs.dump_plugins) + * [summarize](#jukebox.plugs.summarize) + * [generate\_help\_rst](#jukebox.plugs.generate_help_rst) + * [get\_all\_loaded\_packages](#jukebox.plugs.get_all_loaded_packages) + * [get\_all\_failed\_packages](#jukebox.plugs.get_all_failed_packages) +* [jukebox.speaking\_text](#jukebox.speaking_text) +* [jukebox.multitimer](#jukebox.multitimer) + * [MultiTimer](#jukebox.multitimer.MultiTimer) + * [cancel](#jukebox.multitimer.MultiTimer.cancel) + * [GenericTimerClass](#jukebox.multitimer.GenericTimerClass) + * [\_\_init\_\_](#jukebox.multitimer.GenericTimerClass.__init__) + * [start](#jukebox.multitimer.GenericTimerClass.start) + * [cancel](#jukebox.multitimer.GenericTimerClass.cancel) + * [toggle](#jukebox.multitimer.GenericTimerClass.toggle) + * [trigger](#jukebox.multitimer.GenericTimerClass.trigger) + * [is\_alive](#jukebox.multitimer.GenericTimerClass.is_alive) + * [get\_timeout](#jukebox.multitimer.GenericTimerClass.get_timeout) + * [set\_timeout](#jukebox.multitimer.GenericTimerClass.set_timeout) + * [publish](#jukebox.multitimer.GenericTimerClass.publish) + * [get\_state](#jukebox.multitimer.GenericTimerClass.get_state) + * [GenericEndlessTimerClass](#jukebox.multitimer.GenericEndlessTimerClass) + * [GenericMultiTimerClass](#jukebox.multitimer.GenericMultiTimerClass) + * [\_\_init\_\_](#jukebox.multitimer.GenericMultiTimerClass.__init__) + * [start](#jukebox.multitimer.GenericMultiTimerClass.start) +* [jukebox.utils](#jukebox.utils) + * [decode\_rpc\_call](#jukebox.utils.decode_rpc_call) + * [decode\_rpc\_command](#jukebox.utils.decode_rpc_command) + * [decode\_and\_call\_rpc\_command](#jukebox.utils.decode_and_call_rpc_command) + * [bind\_rpc\_command](#jukebox.utils.bind_rpc_command) + * [rpc\_call\_to\_str](#jukebox.utils.rpc_call_to_str) + * [generate\_cmd\_alias\_rst](#jukebox.utils.generate_cmd_alias_rst) + * [generate\_cmd\_alias\_reference](#jukebox.utils.generate_cmd_alias_reference) + * [get\_git\_state](#jukebox.utils.get_git_state) +* [jukebox.rpc](#jukebox.rpc) +* [jukebox.rpc.client](#jukebox.rpc.client) +* [jukebox.rpc.server](#jukebox.rpc.server) + * [RpcServer](#jukebox.rpc.server.RpcServer) + * [\_\_init\_\_](#jukebox.rpc.server.RpcServer.__init__) + * [run](#jukebox.rpc.server.RpcServer.run) + + + +# run\_jukebox + +This is the main app and starts the Jukebox Core. + +Usually this runs as a service, which is started automatically after boot-up. At times, it may be necessary to restart +the service. +For example after a configuration change. Not all configuration changes can be applied on-the-fly. +See [Jukebox Configuration](../../builders/configuration.md#jukebox-configuration). + +For debugging, it is usually desirable to run the Jukebox directly from the console rather than +as service. This gives direct logging info in the console and allows changing command line parameters. +See [Troubleshooting](../../builders/troubleshooting.md). + + + + +# \_\_init\_\_ + + + +# run\_register\_rfid\_reader + +Setup tool to configure the RFID Readers. + +Run this once to register and configure the RFID readers with the Jukebox. Can be re-run at any time to change +the settings. For more information see [RFID Readers](../rfid/README.md). + +> [!NOTE] +> This tool will always write a new configurations file. Thus, overwrite the old one (after checking with the user). +> Any manual modifications to the settings will have to be re-applied + + + + +# run\_rpc\_tool + +Command Line Interface to the Jukebox RPC Server + +A command line tool for sending RPC commands to the running jukebox app. +This uses the same interface as the WebUI. Can be used for additional control +or for debugging. + +The tool features auto-completion and command history. + +The list of available commands is fetched from the running Jukebox service. + +.. todo: + - kwargs support + + + + +#### get\_common\_beginning + +```python +def get_common_beginning(strings) +``` + +Return the strings that are common to the beginning of each string in the strings list. + + + + +#### runcmd + +```python +def runcmd(cmd) +``` + +Just run a command. + +Right now duplicates more or less main() +:todo remove duplication of code + + + + +# run\_configure\_audio + +Setup tool to register the PulseAudio sinks as primary and secondary audio outputs. + +Will also setup equalizer and mono down mixer in the pulseaudio config file. + +Run this once after installation. Can be re-run at any time to change the settings. +For more information see [Audio Configuration](../../builders/audio.md#audio-configuration). + + + + +# run\_publicity\_sniffer + +A command line tool that monitors all messages being sent out from the + +Jukebox via the publishing interface. Received messages are printed in the console. +Mainly used for debugging. + + + + +# misc + + + +#### recursive\_chmod + +```python +def recursive_chmod(path, mode_files, mode_dirs) +``` + +Recursively change folder and file permissions + +mode_files/mode dirs can be given in octal notation e.g. 0o777 +flags from the stats module. + +Reference: https://docs.python.org/3/library/os.html#os.chmod + + + + +#### flatten + +```python +def flatten(iterable) +``` + +Flatten all levels of hierarchy in nested iterables + + + + +#### getattr\_hierarchical + +```python +def getattr_hierarchical(obj: Any, name: str) -> Any +``` + +Like the builtin getattr, but descends though the hierarchy levels + + + + +# misc.inputminus + +Zero 3rd-party dependency module for user prompting + +Yes, there are modules out there to do the same and they have more features. +However, this is low-complexity and has zero dependencies + + + + +#### input\_int + +```python +def input_int(prompt, + blank=None, + min=None, + max=None, + prompt_color=None, + prompt_hint=False) -> int +``` + +Request an integer input from user + +**Arguments**: + +- `prompt`: The prompt to display +- `blank`: Value to return when user just hits enter. Leave at None, if blank is invalid +- `min`: Minimum valid integer value (None disables this check) +- `max`: Maximum valid integer value (None disables this check) +- `prompt_color`: Color of the prompt. Color will be reset at end of prompt +- `prompt_hint`: Append a 'hint' with [min...max, default=xx] to end of prompt + +**Returns**: + +integer value read from user input + + + +#### input\_yesno + +```python +def input_yesno(prompt, + blank=None, + prompt_color=None, + prompt_hint=False) -> bool +``` + +Request a yes / no choice from user + +Accepts multiple input for true/false and is case insensitive + +**Arguments**: + +- `prompt`: The prompt to display +- `blank`: Value to return when user just hits enter. Leave at None, if blank is invalid +- `prompt_color`: Color of the prompt. Color will be reset at end of prompt +- `prompt_hint`: Append a 'hint' with [y/n] to end of prompt. Default choice will be capitalized + +**Returns**: + +boolean value read from user input + + + +# misc.loggingext + +## Logger + +We use a hierarchical Logger structure based on pythons logging module. It can be finely configured with a yaml file. + +The top-level logger is called 'jb' (to make it short). In any module you may simple create a child-logger at any hierarchy +level below 'jb'. It will inherit settings from it's parent logger unless otherwise configured in the yaml file. +Hierarchy separator is the '.'. If the logger already exits, getLogger will return a reference to the same, else it will be +created on the spot. + +Example: How to get logger and log away at your heart's content: + + >>> import logging + >>> logger = logging.getLogger('jb.awesome_module') + >>> logger.info('Started general awesomeness aura') + +Example: YAML snippet, setting WARNING as default level everywhere and DEBUG for jb.awesome_module: + + loggers: + jb: + level: WARNING + handlers: [console, debug_file_handler, error_file_handler] + propagate: no + jb.awesome_module: + level: DEBUG + + +> [!NOTE] +> The name (and hierarchy path) of the logger can be arbitrary and must not necessarily match the module name (still makes +> sense). +> There can be multiple loggers per module, e.g. for special classes, to further control the amount of log output + + + + +## ColorFilter Objects + +```python +class ColorFilter(logging.Filter) +``` + +This filter adds colors to the logger + +It adds all colors from simplecolors by using the color name as new keyword, +i.e. use %(colorname)c or {colorname} in the formatter string + +It also adds the keyword {levelnameColored} which is an auto-colored drop-in replacement +for the levelname depending on severity. + +Don't forget to {reset} the color settings at the end of the string. + + + + +#### \_\_init\_\_ + +```python +def __init__(enable=True, color_levelname=True) +``` + +**Arguments**: + +- `enable`: Enable the coloring +- `color_levelname`: Enable auto-coloring when using the levelname keyword + + + +## PubStream Objects + +```python +class PubStream() +``` + +Stream handler wrapper around the publisher for logging.StreamHandler + +Allows logging to send all log information (based on logging configuration) +to the Publisher. + +> [!CAUTION] +> This can lead to recursions! +> Recursions come up when +> * Publish.send / PublishServer.send also emits logs, which cause a another send, which emits a log, +> which causes a send, ..... +> * Publisher initialization emits logs, which need a Publisher instance to send logs + +> [!IMPORTANT] +> To avoid endless recursions: The creation of a Publisher MUST NOT generate any log messages! Nor any of the +> functions in the send-function stack! + + + + +## PubStreamHandler Objects + +```python +class PubStreamHandler(logging.StreamHandler) +``` + +Wrapper for logging.StreamHandler with stream = PubStream + +This serves one purpose: In logger.yaml custom handlers +can be configured (which are automatically instantiated). +Using this Handler, we can output to PubStream whithout +support code to instantiate PubStream keeping this file generic + + + + +# misc.simplecolors + +Zero 3rd-party dependency module to add colors to unix terminal output + +Yes, there are modules out there to do the same and they have more features. +However, this is low-complexity and has zero dependencies + + + + +## Colors Objects + +```python +class Colors() +``` + +Container class for all the colors as constants + + + + +#### resolve + +```python +def resolve(color_name: str) +``` + +Resolve a color name into the respective color constant + +**Arguments**: + +- `color_name`: Name of the color + +**Returns**: + +color constant + + + +#### print + +```python +def print(color: Colors, + *values, + sep=' ', + end='\n', + file=sys.stdout, + flush=False) +``` + +Drop-in replacement for print with color choice and auto color reset for convenience + +Use just as a regular print function, but with first parameter as color + + + + +# components + + + +# components.playermpd.playcontentcallback + + + +## PlayContentCallbacks Objects + +```python +class PlayContentCallbacks(Generic[STATE], CallbackHandler) +``` + +Callbacks are executed in various play functions + + + + +#### register + +```python +def register(func: Callable[[str, STATE], None]) +``` + +Add a new callback function :attr:`func`. + +Callback signature is + +.. py:function:: func(folder: str, state: STATE) + :noindex: + +**Arguments**: + +- `folder`: relativ path to folder to play +- `state`: indicator of the state inside the calling + + + +#### run\_callbacks + +```python +def run_callbacks(folder: str, state: STATE) +``` + + + + + +# components.playermpd + +Package for interfacing with the MPD Music Player Daemon + +Status information in three topics +1) Player Status: published only on change + This is a subset of the MPD status (and not the full MPD status) ?? + - folder + - song + - volume (volume is published only via player status, and not separatly to avoid too many Threads) + - ... +2) Elapsed time: published every 250 ms, unless constant + - elapsed +3) Folder Config: published only on change + This belongs to the folder being played + Publish: + - random, resume, single, loop + On save store this information: + Contains the information for resume functionality of each folder + - random, resume, single, loop + - if resume: + - current song, elapsed + - what is PLAYSTATUS for? + When to save + - on stop + Angstsave: + - on pause (only if box get turned off without proper shutdown - else stop gets implicitly called) + - on status change of random, resume, single, loop (for resume omit current status if currently playing- this has now meaning) + Load checks: + - if resume, but no song, elapsed -> log error and start from the beginning + +Status storing: + - Folder config for each folder (see above) + - Information to restart last folder playback, which is: + - last_folder -> folder_on_close + - song, elapsed + - random, resume, single, loop + - if resume is enabled, after start we need to set last_played_folder, such that card swipe is detected as second swipe?! + on the other hand: if resume is enabled, this is also saved to folder.config -> and that is checked by play card + +Internal status + - last played folder: Needed to detect second swipe + + +Saving {'player_status': {'last_played_folder': 'TraumfaengerStarkeLieder', 'CURRENTSONGPOS': '0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3'}, +'audio_folder_status': +{'TraumfaengerStarkeLieder': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'stop', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}, +'Giraffenaffen': {'ELAPSED': '1.0', 'CURRENTFILENAME': 'TraumfaengerStarkeLieder/01.mp3', 'CURRENTSONGPOS': '0', 'PLAYSTATUS': 'play', 'RESUME': 'OFF', 'SHUFFLE': 'OFF', 'LOOP': 'OFF', 'SINGLE': 'OFF'}}} + +References: +https://github.com/Mic92/python-mpd2 +https://python-mpd2.readthedocs.io/en/latest/topics/commands.html +https://mpd.readthedocs.io/en/latest/protocol.html + +sudo -u mpd speaker-test -t wav -c 2 + + + + +## PlayerMPD Objects + +```python +class PlayerMPD() +``` + +Interface to MPD Music Player Daemon + + + + +#### mpd\_retry\_with\_mutex + +```python +def mpd_retry_with_mutex(mpd_cmd, *args) +``` + +This method adds thread saftey for acceses to mpd via a mutex lock, + +it shall be used for each access to mpd to ensure thread safety +In case of a communication error the connection will be reestablished and the pending command will be repeated 2 times + +I think this should be refactored to a decorator + + + + +#### pause + +```python +@plugs.tag +def pause(state: int = 1) +``` + +Enforce pause to state (1: pause, 0: resume) + +This is what you want as card removal action: pause the playback, so it can be resumed when card is placed +on the reader again. What happens on re-placement depends on configured second swipe option + + + + +#### next + +```python +@plugs.tag +def next() +``` + +Play next track in current playlist + + + + +#### rewind + +```python +@plugs.tag +def rewind() +``` + +Re-start current playlist from first track + +Note: Will not re-read folder config, but leave settings untouched + + + + +#### replay + +```python +@plugs.tag +def replay() +``` + +Re-start playing the last-played folder + +Will reset settings to folder config + + + + +#### toggle + +```python +@plugs.tag +def toggle() +``` + +Toggle pause state, i.e. do a pause / resume depending on current state + + + + +#### replay\_if\_stopped + +```python +@plugs.tag +def replay_if_stopped() +``` + +Re-start playing the last-played folder unless playlist is still playing + +> [!NOTE] +> To me this seems much like the behaviour of play, +> but we keep it as it is specifically implemented in box 2.X + + + + +#### play\_card + +```python +@plugs.tag +def play_card(folder: str, recursive: bool = False) +``` + +Main entry point for trigger music playing from RFID reader. Decodes second swipe options before playing folder content + +Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action +accordingly. + +**Arguments**: + +- `folder`: Folder path relative to music library path +- `recursive`: Add folder recursively + + + +#### get\_single\_coverart + +```python +@plugs.tag +def get_single_coverart(song_url) +``` + +Saves the album art image to a cache and returns the filename. + + + + +#### get\_folder\_content + +```python +@plugs.tag +def get_folder_content(folder: str) +``` + +Get the folder content as content list with meta-information. Depth is always 1. + +Call repeatedly to descend in hierarchy + +**Arguments**: + +- `folder`: Folder path relative to music library path + + + +#### play\_folder + +```python +@plugs.tag +def play_folder(folder: str, recursive: bool = False) -> None +``` + +Playback a music folder. + +Folder content is added to the playlist as described by :mod:`jukebox.playlistgenerator`. +The playlist is cleared first. + +**Arguments**: + +- `folder`: Folder path relative to music library path +- `recursive`: Add folder recursively + + + +#### play\_album + +```python +@plugs.tag +def play_album(albumartist: str, album: str) +``` + +Playback a album found in MPD database. + +All album songs are added to the playlist +The playlist is cleared first. + +**Arguments**: + +- `albumartist`: Artist of the Album provided by MPD database +- `album`: Album name provided by MPD database + + + +#### get\_volume + +```python +def get_volume() +``` + +Get the current volume + +For volume control do not use directly, but use through the plugin 'volume', +as the user may have configured a volume control manager other than MPD + + + + +#### set\_volume + +```python +def set_volume(volume) +``` + +Set the volume + +For volume control do not use directly, but use through the plugin 'volume', +as the user may have configured a volume control manager other than MPD + + + + +#### play\_card\_callbacks + +Callback handler instance for play_card events. + +- is executed when play_card function is called +States: +- See :class:`PlayCardState` +See :class:`PlayContentCallbacks` + + + + +# components.playermpd.coverart\_cache\_manager + + + +# components.rpc\_command\_alias + +This file provides definitions for RPC command aliases + +See [RPC Commands](../../builders/rpc-commands.md) + + + + +# components.synchronisation.rfidcards + +Handles the synchronisation of RFID cards (audiofolder and card database entries). + +sync-all -> all card entries and audiofolders are synced from remote including deletions +sync-on-scan -> only the entry and audiofolder for the cardId will be synced from remote. + Deletions are only performed on files and subfolder inside the audiofolder. + A deletion of the audiofolder itself on remote side will not be propagated. + +card database: +On synchronisation the remote file will not be synced with the original cards database, but rather a local copy. +If a full sync is performed, the state is written back to the original file. +If a single card sync is performed, only the state of the specific cardId is updated in the original file. +This is done to allow to play audio offline. +Otherwise we would also update other cardIds where the audiofolders have not been synced yet. +The local copy is kept to reduce unnecessary syncing. + + + + +## SyncRfidcards Objects + +```python +class SyncRfidcards() +``` + +Control class for sync RFID cards functionality + + + + +#### sync\_change\_on\_rfid\_scan + +```python +@plugs.tag +def sync_change_on_rfid_scan(option: str = 'toggle') -> None +``` + +Change activation of 'on_rfid_scan_enabled' + +**Arguments**: + +- `option`: Must be one of 'enable', 'disable', 'toggle' + + + +#### sync\_all + +```python +@plugs.tag +def sync_all() -> bool +``` + +Sync all audiofolder and cardids from the remote server. + +Removes local entries not existing at the remote server. + + + + +#### sync\_card\_database + +```python +@plugs.tag +def sync_card_database(card_id: str) -> bool +``` + +Sync the card database from the remote server, if existing. + +If card_id is provided only this entry is updated. + +**Arguments**: + +- `card_id`: The cardid to update + + + +#### sync\_folder + +```python +@plugs.tag +def sync_folder(folder: str) -> bool +``` + +Sync the folder from the remote server, if existing + +**Arguments**: + +- `folder`: Folder path relative to music library path + + + +# components.synchronisation + + + +# components.synchronisation.syncutils + + + +# components.volume + +PulseAudio Volume Control Plugin Package + +## Features + +* Volume Control +* Two outputs +* Watcher thread on volume / output change + +## Publishes + +* volume.level +* volume.sink + +## PulseAudio References + + + +Check fallback device (on device de-connect): + + $ pacmd list-sinks | grep -e 'name:' -e 'index' + + +## Integration + +Pulse Audio runs as a user process. Processes who want to communicate / stream to it +must also run as a user process. + +This means must also run as user process, as described in +[Music Player Daemon](../../builders/system.md#music-player-daemon-mpd). + +## Misc + +PulseAudio may switch the sink automatically to a connecting bluetooth device depending on the loaded module +with name module-switch-on-connect. On RaspianOS Bullseye, this module is not part of the default configuration +in ``/usr/pulse/default.pa``. So, we don't need to worry about it. +If the module gets loaded it conflicts with the toggle on connect and the selected primary / secondary outputs +from the Jukebox. Remove it from the configuration! + + ### Use hot-plugged devices like Bluetooth or USB automatically (LP: `1702794`) + ### not available on PI? + .ifexists module-switch-on-connect.so + load-module module-switch-on-connect + .endif + +## Why PulseAudio? + +The audio configuration of the system is one of those topics, +which has a myriad of options and possibilities. Every system is different and PulseAudio unifies these and +makes our life easier. Besides, it is only option to support Bluetooth at the moment. + +## Callbacks: + +The following callbacks are provided. Register callbacks with these adder functions (see their documentation for details): + +1. :func:`add_on_connect_callback` +2. :func:`add_on_output_change_callbacks` +3. :func:`add_on_volume_change_callback` + + + + +## PulseMonitor Objects + +```python +class PulseMonitor(threading.Thread) +``` + +A thread for monitoring and interacting with the Pulse Lib via pulsectrl + +Whenever we want to access pulsectl, we need to exit the event listen loop. +This is handled by the context manager. It stops the event loop and returns +the pulsectl instance to be used (it does no return the monitor thread itself!) + +The context manager also locks the module to ensure proper thread sequencing, +as only a single thread may work with pulsectl at any time. Currently, an RLock is +used, even if it may not be necessary + + + + +## SoundCardConnectCallbacks Objects + +```python +class SoundCardConnectCallbacks(CallbackHandler) +``` + +Callbacks are executed when + +* new sound card gets connected + + + + +#### register + +```python +def register(func: Callable[[str, str], None]) +``` + +Add a new callback function :attr:`func`. + +Callback signature is + +.. py:function:: func(card_driver: str, device_name: str) + :noindex: + +**Arguments**: + +- `card_driver`: The PulseAudio card driver module, +e.g. :data:`module-bluez5-device.c` or :data:`module-alsa-card.c` +- `device_name`: The sound card device name as reported +in device properties + + + +#### run\_callbacks + +```python +def run_callbacks(sink_name, alias, sink_index, error_state) +``` + + + + + +#### toggle\_on\_connect + +```python +@property +def toggle_on_connect() +``` + +Returns :data:`True` if the sound card shall be changed when a new card connects/disconnects. Setting this + +property changes the behavior. + +> [!NOTE] +> A new card is always assumed to be the secondary device from the audio configuration. +> At the moment there is no check it actually is the configured device. This means any new +> device connection will initiate the toggle. This, however, is no real issue as the RPi's audio +> system will be relatively stable once setup + + + + +#### toggle\_on\_connect + +```python +@toggle_on_connect.setter +def toggle_on_connect(state=True) +``` + +Toggle Doc 2 + + + + +#### stop + +```python +def stop() +``` + +Stop the pulse monitor thread + + + + +#### run + +```python +def run() -> None +``` + +Starts the pulse monitor thread + + + + +## PulseVolumeControl Objects + +```python +class PulseVolumeControl() +``` + +Volume control manager for PulseAudio + +When accessing the pulse library, it needs to be put into a special +state. Which is ensured by the context manager + + with pulse_monitor as pulse ... + + +All private functions starting with `_function_name` assume that this is ensured by +the calling function. All user functions acquire proper context! + + + + +## OutputChangeCallbackHandler Objects + +```python +class OutputChangeCallbackHandler(CallbackHandler) +``` + +Callbacks are executed when + +* audio sink is changed + + + + +#### register + +```python +def register(func: Callable[[str, str, int, int], None]) +``` + +Add a new callback function :attr:`func`. + +Parameters always give the valid audio sink. That means, if an error +occurred, all parameters are valid. + +Callback signature is + +.. py:function:: func(sink_name: str, alias: str, sink_index: int, error_state: int) + :noindex: + +**Arguments**: + +- `sink_name`: PulseAudio's sink name +- `alias`: The alias for :attr:`sink_name` +- `sink_index`: The index of the sink in the configuration list +- `error_state`: 1 if there was an attempt to change the output +but an error occurred. Above parameters always give the now valid sink! +If a sink change is successful, it is 0. + + + +#### run\_callbacks + +```python +def run_callbacks(sink_name, alias, sink_index, error_state) +``` + + + + + +## OutputVolumeCallbackHandler Objects + +```python +class OutputVolumeCallbackHandler(CallbackHandler) +``` + +Callbacks are executed when + +* audio volume level is changed + + + + +#### register + +```python +def register(func: Callable[[int, bool, bool], None]) +``` + +Add a new callback function :attr:`func`. + +Callback signature is + +.. py:function:: func(volume: int, is_min: bool, is_max: bool) + :noindex: + +**Arguments**: + +- `volume`: Volume level +- `is_min`: 1, if volume level is minimum, else 0 +- `is_max`: 1, if volume level is maximum, else 0 + + + +#### run\_callbacks + +```python +def run_callbacks(sink_name, alias, sink_index, error_state) +``` + + + + + +#### toggle\_output + +```python +@plugin.tag +def toggle_output() +``` + +Toggle the audio output sink + + + + +#### get\_outputs + +```python +@plugin.tag +def get_outputs() +``` + +Get current output and list of outputs + + + + +#### publish\_volume + +```python +@plugin.tag +def publish_volume() +``` + +Publish (volume, mute) + + + + +#### publish\_outputs + +```python +@plugin.tag +def publish_outputs() +``` + +Publish current output and list of outputs + + + + +#### set\_volume + +```python +@plugin.tag +def set_volume(volume: int) +``` + +Set the volume (0-100) for the currently active output + + + + +#### get\_volume + +```python +@plugin.tag +def get_volume() +``` + +Get the volume + + + + +#### change\_volume + +```python +@plugin.tag +def change_volume(step: int) +``` + +Increase/decrease the volume by step for the currently active output + + + + +#### get\_mute + +```python +@plugin.tag +def get_mute() +``` + +Return mute status for the currently active output + + + + +#### mute + +```python +@plugin.tag +def mute(mute=True) +``` + +Set mute status for the currently active output + + + + +#### set\_output + +```python +@plugin.tag +def set_output(sink_index: int) +``` + +Set the active output (sink_index = 0: primary, 1: secondary) + + + + +#### set\_soft\_max\_volume + +```python +@plugin.tag +def set_soft_max_volume(max_volume: int) +``` + +Limit the maximum volume to max_volume for the currently active output + + + + +#### get\_soft\_max\_volume + +```python +@plugin.tag +def get_soft_max_volume() +``` + +Return the maximum volume limit for the currently active output + + + + +#### card\_list + +```python +def card_list() -> List[pulsectl.PulseCardInfo] +``` + +Return the list of present sound card + + + + +# components.rfid + + + +# components.rfid.reader + + + +## RfidCardDetectCallbacks Objects + +```python +class RfidCardDetectCallbacks(CallbackHandler) +``` + +Callbacks are executed if rfid card is detected + + + + +#### register + +```python +def register(func: Callable[[str, RfidCardDetectState], None]) +``` + +Add a new callback function :attr:`func`. + +Callback signature is + +.. py:function:: func(card_id: str, state: int) + :noindex: + +**Arguments**: + +- `card_id`: Card ID +- `state`: See `RfidCardDetectState` + + + +#### run\_callbacks + +```python +def run_callbacks(card_id: str, state: RfidCardDetectState) +``` + + + + + +#### rfid\_card\_detect\_callbacks + +Callback handler instance for rfid_card_detect_callbacks events. + +See [`RfidCardDetectCallbacks`](#components.rfid.reader.RfidCardDetectCallbacks) + + + + +## CardRemovalTimerClass Objects + +```python +class CardRemovalTimerClass(threading.Thread) +``` + +A timer watchdog thread that calls timeout_action on time-out + + + + +#### \_\_init\_\_ + +```python +def __init__(on_timeout_callback, logger: logging.Logger = None) +``` + +**Arguments**: + +- `on_timeout_callback`: The function to execute on time-out + + + +# components.rfid.configure + + + +#### reader\_install\_dependencies + +```python +def reader_install_dependencies(reader_path: str, + dependency_install: str) -> None +``` + +Install dependencies for the selected reader module + +**Arguments**: + +- `reader_path`: Path to the reader module +- `dependency_install`: how to handle installing of dependencies +'query': query user (default) +'auto': automatically +'no': don't install dependencies + + + +#### reader\_load\_module + +```python +def reader_load_module(reader_name) +``` + +Load the module for the reader_name + +A ModuleNotFoundError is unrecoverable, but we at least want to give some hint how to resolve that to the user +All other errors will NOT be handled. Modules that do not load due to compile errors have other problems + +**Arguments**: + +- `reader_name`: Name of the reader to load the module for + +**Returns**: + +module + + + +#### query\_user\_for\_reader + +```python +def query_user_for_reader(dependency_install='query') -> dict +``` + +Ask the user to select a RFID reader and prompt for the reader's configuration + +This function performs the following steps, to find and present all available readers to the user + +- search for available reader subpackages +- dynamically load the description module for each reader subpackage +- queries user for selection +- if no_dep_install=False, install dependencies as given by requirements.txt and execute setup.inc.sh of subpackage +- dynamically load the actual reader module from the reader subpackage +- if selected reader has customization options query user for that now +- return configuration + +There are checks to make sure we have the right reader modules and they are what we expect. +The are as few requirements towards the reader module as possible and everything else is optional +(see reader_template for these requirements) +However, there is no error handling w.r.t to user input and reader's query_config. Firstly, in this script +we cannot gracefully handle an exception that occurs on reader level, and secondly the exception will simply +exit the script w/o writing the config to file. No harm done. + +This script expects to reside in the directory with all the reader subpackages, i.e it is part of the rfid-reader package. +Otherwise you'll need to adjust sys.path + +**Arguments**: + +- `dependency_install`: how to handle installing of dependencies +'query': query user (default) +'auto': automatically +'no': don't install dependencies + +**Returns**: + +`dict as {section: {parameter: value}}`: nested dict with entire configuration that can be read into ConfigParser + + + +#### write\_config + +```python +def write_config(config_file: str, + config_dict: dict, + force_overwrite=False) -> None +``` + +Write configuration to config_file + +**Arguments**: + +- `config_file`: relative or absolute path to config file +- `config_dict`: nested dict with configuration parameters for ConfigParser consumption +- `force_overwrite`: overwrite existing configuration file without asking + + + +# components.rfid.hardware.fake\_reader\_gui.fake\_reader\_gui + + + +# components.rfid.hardware.fake\_reader\_gui.description + + + +# components.rfid.hardware.fake\_reader\_gui.gpioz\_gui\_addon + +Add GPIO input devices and output devices to the RFID Mock Reader GUI + + + + +#### create\_inputs + +```python +def create_inputs(frame, default_btn_width, default_padx, default_pady) +``` + +Add all input devies to the GUI + +**Arguments**: + +- `frame`: The TK frame (e.g. LabelFrame) in the main GUI to add the buttons to + +**Returns**: + +List of all added GUI buttons + + + +#### set\_state + +```python +def set_state(value, box_state_var) +``` + +Change the value of a checkbox state variable + + + + +#### que\_set\_state + +```python +def que_set_state(value, box_state_var) +``` + +Queue the action to change a checkbox state variable to the TK GUI main thread + + + + +#### fix\_state + +```python +def fix_state(box_state_var) +``` + +Prevent a checkbox state variable to change on checkbox mouse press + + + + +#### pbox\_set\_state + +```python +def pbox_set_state(value, pbox_state_var, label_var) +``` + +Update progress bar state and related state label + + + + +#### que\_set\_pbox + +```python +def que_set_pbox(value, pbox_state_var, label_var) +``` + +Queue the action to change the progress bar state to the TK GUI main thread + + + + +#### create\_outputs + +```python +def create_outputs(frame, default_btn_width, default_padx, default_pady) +``` + +Add all output devices to the GUI + +**Arguments**: + +- `frame`: The TK frame (e.g. LabelFrame) in the main GUI to add the representations to + +**Returns**: + +List of all added GUI objects + + + +# components.rfid.hardware.generic\_usb.description + + + +# components.rfid.hardware.generic\_usb.generic\_usb + + + +# components.rfid.hardware.rc522\_spi.description + + + +# components.rfid.hardware.rc522\_spi.rc522\_spi + + + +# components.rfid.hardware.pn532\_i2c\_py532.description + + + +# components.rfid.hardware.pn532\_i2c\_py532.pn532\_i2c\_py532 + + + +# components.rfid.hardware.rdm6300\_serial.rdm6300\_serial + + + +#### decode + +```python +def decode(raw_card_id: bytearray, number_format: int) -> str +``` + +Decode the RDM6300 data format into actual card ID + + + + +# components.rfid.hardware.rdm6300\_serial.description + + + +# components.rfid.hardware.template\_new\_reader.description + +Provide a short title for this reader. + +This is what that user will see when asked for selecting his RFID reader +So, be precise but readable. Precise means 40 characters or less + + + + +# components.rfid.hardware.template\_new\_reader.template\_new\_reader + + + +#### query\_customization + +```python +def query_customization() -> dict +``` + +Query the user for reader parameter customization + +This function will be called during the configuration/setup phase when the user selects this reader module. +It must return all configuration parameters that are necessary to later use the Reader class. +You can ask the user for selections and choices. And/or provide default values. +If your reader requires absolutely no configuration return {} + + + + +## ReaderClass Objects + +```python +class ReaderClass(ReaderBaseClass) +``` + +The actual reader class that is used to read RFID cards. + +It will be instantiated once and then read_card() is called in an endless loop. + +It will be used in a manner + with Reader(reader_cfg_key) as reader: + for card_id in reader: + ... +which ensures proper resource de-allocation. For this to work derive this class from ReaderBaseClass. +All the required interfaces are implemented there. + +Put your code into these functions (see below for more information) + - `__init__` + - read_card + - cleanup + - stop + + + + +#### \_\_init\_\_ + +```python +def __init__(reader_cfg_key) +``` + +In the constructor, you will get the `reader_cfg_key` with which you can access the configuration data + +As you are dealing directly with potentially user-manipulated config information, it is +advisable to do some sanity checks and give useful error messages. Even if you cannot recover gracefully, +a good error message helps :-) + + + + +#### cleanup + +```python +def cleanup() +``` + +The cleanup function: free and release all resources used by this card reader (if any). + +Put all your cleanup code here, e.g. if you are using the serial bus or GPIO pins. +Will be called implicitly via the __exit__ function +This function must exist! If there is nothing to do, just leave the pass statement in place below + + + + +#### stop + +```python +def stop() +``` + +This function is called to tell the reader to exist it's reading function. + +This function is called before cleanup is called. + +> [!NOTE] +> This is usually called from a different thread than the reader's thread! And this is the reason for the +> two-step exit strategy. This function works across threads to indicate to the reader that is should stop attempt +> to read a card. Once called, the function read_card will not be called again. When the reader thread exits +> cleanup is called from the reader thread itself. + + + + +#### 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 + +This is were your main code goes :-) +This function must return a string with the card id +In case of error, it may return None or an empty string + +The function should break and return with an empty string, once stop() is called + + + + +# components.rfid.readerbase + + + +## ReaderBaseClass Objects + +```python +class ReaderBaseClass(ABC) +``` + +Abstract Base Class for all Reader Classes to ensure common API + +Look at template_new_reader.py for documentation how to integrate a new RFID reader + + + + +# components.rfid.cards + +Handling the RFID card database + +A few considerations: +- Changing the Card DB influences to current state + - rfid.reader: Does not care, as it always freshly looks into the DB when a new card is triggered + - fake_reader_gui: Initializes the Drop-down menu once on start --> Will get out of date! + +Do we need a notifier? Or a callback for modules to get notified? +Do we want to publish the information about a card DB update? +TODO: Add callback for on_database_change + +TODO: check card id type (if int, convert to str) +TODO: check if args is really a list (convert if not?) + + + + +#### list\_cards + +```python +@plugs.register +def list_cards() +``` + +Provide a summarized, decoded list of all card actions + +This is intended as basis for a formatter function + +Format: 'id': {decoded_function_call, ignore_same_id_delay, ignore_card_removal_action, description, from_alias} + + + + +#### delete\_card + +```python +@plugs.register +def delete_card(card_id: str, auto_save: bool = True) +``` + +**Arguments**: + +- `auto_save`: +- `card_id`: + + + +#### register\_card + +```python +@plugs.register +def register_card(card_id: str, + cmd_alias: str, + args: Optional[List] = None, + kwargs: Optional[Dict] = None, + ignore_card_removal_action: Optional[bool] = None, + ignore_same_id_delay: Optional[bool] = None, + overwrite: bool = False, + auto_save: bool = True) +``` + +Register a new card based on quick-selection + +If you are going to call this through the RPC it will get a little verbose + +**Example:** Registering a new card with ID *0009* for increment volume with a custom argument to inc_volume +(*here: 15*) and custom *ignore_same_id_delay value*:: + + plugin.call_ignore_errors('cards', 'register_card', + args=['0009', 'inc_volume'], + kwargs={'args': [15], 'ignore_same_id_delay': True, 'overwrite': True}) + + + + +#### register\_card\_custom + +```python +@plugs.register +def register_card_custom() +``` + +Register a new card with full RPC call specification (Not implemented yet) + + + + +#### save\_card\_database + +```python +@plugs.register +def save_card_database(filename=None, *, only_if_changed=True) +``` + +Store the current card database. If filename is None, it is saved back to the file it was loaded from + + + + +# components.rfid.cardutils + +Common card decoding functions + +TODO: Thread safety when accessing the card DB! + + + + +#### decode\_card\_command + +```python +def decode_card_command(cfg_rpc_cmd: Mapping, logger: logging.Logger = log) +``` + +Extension of utils.decode_action with card-specific parameters + + + + +#### card\_command\_to\_str + +```python +def card_command_to_str(cfg_rpc_cmd: Mapping, long=False) -> List[str] +``` + +Returns a list of strings with [card_action, ignore_same_id_delay, ignore_card_removal_action] + +The last two parameters are only present, if *long* is True and if they are present in the cfg_rpc_cmd + + + + +#### card\_to\_str + +```python +def card_to_str(card_id: str, long=False) -> List[str] +``` + +Returns a list of strings from card entry command in the format of :func:`card_command_to_str` + + + + +# components.publishing + +Plugin interface for Jukebox Publisher + +Thin wrapper around jukebox.publishing to benefit from the plugin loading / exit handling / function handling + +This is the first package to be loaded and the last to be closed: put Hello and Goodbye publish messages here. + + + + +#### republish + +```python +@plugin.register +def republish(topic=None) +``` + +Re-publish the topic tree 'topic' to all subscribers + +**Arguments**: + +- `topic`: Topic tree to republish. None = resend all + + + +# components.player + + + +## MusicLibPath Objects + +```python +class MusicLibPath() +``` + +Extract the music directory from the mpd.conf file + + + + +#### get\_music\_library\_path + +```python +def get_music_library_path() +``` + +Get the music library path + + + + +# components.jingle + +Jingle Playback Factory for extensible run-time support of various file types + + + + +## JingleFactory Objects + +```python +class JingleFactory() +``` + +Jingle Factory + + + + +#### list + +```python +def list() +``` + +List the available volume services + + + + +#### play + +```python +@plugin.register +def play(filename) +``` + +Play the jingle using the configured jingle service + +> [!NOTE] +> This runs in a separate thread. And this may cause troubles +> when changing the volume level before +> and after the sound playback: There is nothing to prevent another +> thread from changing the volume and sink while playback happens +> and afterwards we change the volume back to where it was before! + +There is no way around this dilemma except for not running the jingle as a +separate thread. Currently (as thread) even the RPC is started before the sound +is finished and the volume is reset to normal... + +However: Volume plugin is loaded before jingle and sets the default +volume. No interference here. It can now only happen +if (a) through the RPC or (b) some other plugin the volume is changed. Okay, now +(a) let's hope that there is enough delay in the user requesting a volume change +(b) let's hope no other plugin wants to do that +(c) no bluetooth device connects during this time (and pulseaudio control is set to toggle_on_connect) +and take our changes with the threaded approach. + + + + +#### play\_startup + +```python +@plugin.register +def play_startup() +``` + +Play the startup sound (using jingle.play) + + + + +#### play\_shutdown + +```python +@plugin.register +def play_shutdown() +``` + +Play the shutdown sound (using jingle.play) + + + + +# components.jingle.alsawave + +ALSA wave jingle Service for jingle.JingleFactory + + + + +## AlsaWave Objects + +```python +@plugin.register +class AlsaWave() +``` + +Jingle Service for playing wave files directly from Python through ALSA + + + + +#### play + +```python +@plugin.tag +def play(filename) +``` + +Play the wave file + + + + +## AlsaWaveBuilder Objects + +```python +class AlsaWaveBuilder() +``` + + + +#### \_\_init\_\_ + +```python +def __init__() +``` + +Builder instantiates AlsaWave during init and not during first call because + +we want AlsaWave registers as plugin function in any case if this plugin is loaded +(and not only on first use!) + + + + +# components.jingle.jinglemp3 + +Generic MP3 jingle Service for jingle.JingleFactory + + + + +## JingleMp3Play Objects + +```python +@plugin.register(auto_tag=True) +class JingleMp3Play() +``` + +Jingle Service for playing MP3 files + + + + +#### play + +```python +def play(filename) +``` + +Play the MP3 file + + + + +## JingleMp3PlayBuilder Objects + +```python +class JingleMp3PlayBuilder() +``` + + + +#### \_\_init\_\_ + +```python +def __init__() +``` + +Builder instantiates JingleMp3Play during init and not during first call because + +we want JingleMp3Play registers as plugin function in any case if this plugin is loaded +(and not only on first use!) + + + + +# components.hostif.linux + + + +#### shutdown + +```python +@plugin.register +def shutdown() +``` + +Shutdown the host machine + + + + +#### reboot + +```python +@plugin.register +def reboot() +``` + +Reboot the host machine + + + + +#### jukebox\_is\_service + +```python +@plugin.register +def jukebox_is_service() +``` + +Check if current Jukebox process is running as a service + + + + +#### is\_any\_jukebox\_service\_active + +```python +@plugin.register +def is_any_jukebox_service_active() +``` + +Check if a Jukebox service is running + +> [!NOTE] +> Does not have the be the current app, that is running as a service! + + + + +#### restart\_service + +```python +@plugin.register +def restart_service() +``` + +Restart Jukebox App if running as a service + + + + +#### get\_disk\_usage + +```python +@plugin.register() +def get_disk_usage(path='/') +``` + +Return the disk usage in Megabytes as dictionary for RPC export + + + + +#### get\_cpu\_temperature + +```python +@plugin.register +def get_cpu_temperature() +``` + +Get the CPU temperature with single decimal point + +No error handling: this is expected to take place up-level! + + + + +#### get\_ip\_address + +```python +@plugin.register +def get_ip_address() +``` + +Get the IP address + + + + +#### wlan\_disable\_power\_down + +```python +@plugin.register() +def wlan_disable_power_down(card=None) +``` + +Turn off power management of wlan. Keep RPi reachable via WLAN + +This must be done after every reboot +card=None takes card from configuration file + + + + +#### get\_autohotspot\_status + +```python +@plugin.register +def get_autohotspot_status() +``` + +Get the status of the auto hotspot feature + + + + +#### stop\_autohotspot + +```python +@plugin.register() +def stop_autohotspot() +``` + +Stop auto hotspot functionality + +Basically disabling the cronjob and running the script one last time manually + + + + +#### start\_autohotspot + +```python +@plugin.register() +def start_autohotspot() +``` + +start auto hotspot functionality + +Basically enabling the cronjob and running the script one time manually + + + + +# components.misc + +Miscellaneous function package + + + + +#### rpc\_cmd\_help + +```python +@plugin.register +def rpc_cmd_help() +``` + +Return all commands for RPC + + + + +#### get\_all\_loaded\_packages + +```python +@plugin.register +def get_all_loaded_packages() +``` + +Get all successfully loaded plugins + + + + +#### get\_all\_failed\_packages + +```python +@plugin.register +def get_all_failed_packages() +``` + +Get all plugins with error during load or initialization + + + + +#### get\_start\_time + +```python +@plugin.register +def get_start_time() +``` + +Time when JukeBox has been started + + + + +#### get\_log + +```python +def get_log(handler_name: str) +``` + +Get the log file from the loggers (debug_file_handler, error_file_handler) + + + + +#### get\_log\_debug + +```python +@plugin.register +def get_log_debug() +``` + +Get the log file (from the debug_file_handler) + + + + +#### get\_log\_error + +```python +@plugin.register +def get_log_error() +``` + +Get the log file (from the error_file_handler) + + + + +#### get\_git\_state + +```python +@plugin.register +def get_git_state() +``` + +Return git state information for the current branch + + + + +#### empty\_rpc\_call + +```python +@plugin.register +def empty_rpc_call(msg: str = '') +``` + +This function does nothing. + +The RPC command alias 'none' is mapped to this function. + +This is also used when configuration errors lead to non existing RPC command alias definitions. +When the alias definition is void, we still want to return a valid function to simplify error handling +up the module call stack. + +**Arguments**: + +- `msg`: If present, this message is send to the logger with severity warning + + + +# components.controls + + + +# components.controls.bluetooth\_audio\_buttons + +Plugin to attempt to automatically listen to it's buttons (play, next, ...) + +when a bluetooth sound device (headphone, speakers) connects + +This effectively does: + +* register a callback with components.volume to get notified when a new sound card connects +* if that is a bluetooth device, try opening an input device with similar name using +* button listeners are run each in its own thread + + + + +# components.controls.common.evdev\_listener + +Generalized listener for ``dev/input`` devices + + + + +#### find\_device + +```python +def find_device(device_name: str, + exact_name: bool = True, + mandatory_keys: Optional[Set[int]] = None) -> str +``` + +Find an input device with device_name and mandatory keys. + +**Arguments**: + +- `device_name`: See :func:`_filter_by_device_name` +- `exact_name`: See :func:`_filter_by_device_name` +- `mandatory_keys`: See :func:`_filter_by_mandatory_keys` + +**Raises**: + +- `FileNotFoundError`: if no device is found. +- `AttributeError`: if device does not have the mandatory key +If multiple devices match, the first match is returned + +**Returns**: + +The path to the device + + + +## EvDevKeyListener Objects + +```python +class EvDevKeyListener(threading.Thread) +``` + +Opens and event input device from ``/dev/inputs``, and runs callbacks upon the button presses. + +Input devices could be .e.g. Keyboard, Bluetooth audio buttons, USB buttons + +Runs as a separate thread. When device disconnects or disappears, thread exists. A new thread must be started +when device re-connects. + +Assign callbacks to :attr:`EvDevKeyListener.button_callbacks` + + + + +#### \_\_init\_\_ + +```python +def __init__(device_name_request: str, exact_name: bool, thread_name: str) +``` + +**Arguments**: + +- `device_name_request`: The device name to look for +- `exact_name`: If true, device_name must mach exactly, else a match is returned if device_name is a substring of +the reported device name +- `thread_name`: Name of the listener thread + + + +#### run + +```python +def run() +``` + + + + + +#### start + +```python +def start() -> None +``` + +Start the tread and start listening + + + + +# components.battery\_monitor + + + +# components.battery\_monitor.BatteryMonitorBase + + + +## pt1\_frac Objects + +```python +class pt1_frac() +``` + +fixed point first order filter, fractional format: 2^16,2^16 + + + + +## BattmonBase Objects + +```python +class BattmonBase() +``` + +Battery Monitor base class + + + + +# components.battery\_monitor.batt\_mon\_simulator + + + +## battmon\_simulator Objects + +```python +class battmon_simulator(BatteryMonitorBase.BattmonBase) +``` + +Battery Monitor Simulator + + + + +# components.battery\_monitor.batt\_mon\_i2c\_ads1015 + + + +## battmon\_ads1015 Objects + +```python +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! + + + + +# components.gpio.gpioz.plugin + +The GPIOZ plugin interface build all input and output devices from the configuration file and connects + +the actions and callbacks. It also provides a very restricted, but common API for the output devices to the RPC. +That API is mainly used for testing. All the relevant output state changes are usually made through callbacks directly +using the output device's API. + + + + +#### output\_devices + +List of all created output devices + + + + +#### input\_devices + +List of all created input devices + + + + +#### factory + +The global pin factory used in this module + +Using different pin factories for different devices is not supported + + + + +#### IS\_ENABLED + +Indicates that the GPIOZ module is enabled and loaded w/o errors + + + + +#### IS\_MOCKED + +Indicates that the pin factory is a mock factory + + + + +#### CONFIG\_FILE + +The path of the config file the GPIOZ configuration was loaded from + + + + +## ServiceIsRunningCallbacks Objects + +```python +class ServiceIsRunningCallbacks(CallbackHandler) +``` + +Callbacks are executed when + +* Jukebox app started +* Jukebox shuts down + +This is intended to e.g. signal an LED to change state. +This is integrated into this module because: + +* we need the GPIO to control a LED (it must be available when the status callback comes) +* the plugin callback functions provide all the functionality to control the status of the LED +* which means no need to adapt other modules + + + + +#### register + +```python +def register(func: Callable[[int], None]) +``` + +Add a new callback function :attr:`func`. + +Callback signature is + +.. py:function:: func(status: int) + :noindex: + +**Arguments**: + +- `status`: 1 if app started, 0 if app shuts down + + + +#### run\_callbacks + +```python +def run_callbacks(status: int) +``` + + + + + +#### service\_is\_running\_callbacks + +Callback handler instance for service_is_running_callbacks events. + +See :class:`ServiceIsRunningCallbacks` + + + + +#### build\_output\_device + +```python +def build_output_device(name: str, config: Dict) +``` + +Construct and register a new output device + +In principal all supported GPIOZero output devices can be used. +For all devices a custom functions need to be written to control the state of the outputs + + + + +#### build\_input\_device + +```python +def build_input_device(name: str, config) +``` + +Construct and connect a new input device + +Supported input devices are those from gpio.gpioz.core.input_devices + + + + +#### get\_output + +```python +def get_output(name: str) +``` + +Get the output device instance based on the configured name + +**Arguments**: + +- `name`: The alias name output device instance + + + +#### on + +```python +@plugin.register +def on(name: str) +``` + +Turn an output device on + +**Arguments**: + +- `name`: The alias name output device instance + + + +#### off + +```python +@plugin.register +def off(name: str) +``` + +Turn an output device off + +**Arguments**: + +- `name`: The alias name output device instance + + + +#### set\_value + +```python +@plugin.register +def set_value(name: str, value: Any) +``` + +Set the output device to :attr:`value` + +**Arguments**: + +- `name`: The alias name output device instance +- `value`: Value to set the device to + + + +#### flash + +```python +@plugin.register +def flash(name, + on_time=1, + off_time=1, + n=1, + *, + fade_in_time=0, + fade_out_time=0, + tone=None, + color=(1, 1, 1)) +``` + +Flash (blink or beep) an output device + +This is a generic function for all types of output devices. Parameters not applicable to an +specific output device are silently ignored + +**Arguments**: + +- `name`: The alias name output device instance +- `on_time`: Time in seconds in state ``ON`` +- `off_time`: Time in seconds in state ``OFF`` +- `n`: Number of flash cycles +- `tone`: The tone in to play, e.g. 'A4'. *Only for TonalBuzzer*. +- `color`: The RGB color *only for PWMLED*. +- `fade_in_time`: Time in seconds for transitioning to on. *Only for PWMLED and RGBLED* +- `fade_out_time`: Time in seconds for transitioning to off. *Only for PWMLED and RGBLED* + + + +# components.gpio.gpioz.plugin.connectivity + +Provide connector functions to hook up to some kind of Jukebox functionality and change the output device's state + +accordingly. + +Connector functions can often be used for various output devices. Some connector functions are specific to +an output device type. + + + + +#### BUZZ\_TONE + +The tone to be used as buzz tone when the buzzer is an active buzzer + + + + +#### register\_rfid\_callback + +```python +def register_rfid_callback(device) +``` + +Flash the output device once on successful RFID card detection and thrice if card ID is unknown + +Compatible devices: + +* :class:`components.gpio.gpioz.core.output_devices.LED` +* :class:`components.gpio.gpioz.core.output_devices.PWMLED` +* :class:`components.gpio.gpioz.core.output_devices.RGBLED` +* :class:`components.gpio.gpioz.core.output_devices.Buzzer` +* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` + + + + +#### register\_status\_led\_callback + +```python +def register_status_led_callback(device) +``` + +Turn LED on when Jukebox App has started + +Compatible devices: + +* :class:`components.gpio.gpioz.core.output_devices.LED` +* :class:`components.gpio.gpioz.core.output_devices.PWMLED` +* :class:`components.gpio.gpioz.core.output_devices.RGBLED` + + + + +#### register\_status\_buzzer\_callback + +```python +def register_status_buzzer_callback(device) +``` + +Buzz once when Jukebox App has started, twice when closing down + +Compatible devices: + +* :class:`components.gpio.gpioz.core.output_devices.Buzzer` +* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` + + + + +#### register\_status\_tonalbuzzer\_callback + +```python +def register_status_tonalbuzzer_callback(device) +``` + +Buzz a multi-note melody when Jukebox App has started and when closing down + +Compatible devices: + +* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` + + + + +#### register\_audio\_sink\_change\_callback + +```python +def register_audio_sink_change_callback(device) +``` + +Turn LED on if secondary audio output is selected. If audio output change + +fails, blink thrice + +Compatible devices: + +* :class:`components.gpio.gpioz.core.output_devices.LED` +* :class:`components.gpio.gpioz.core.output_devices.PWMLED` +* :class:`components.gpio.gpioz.core.output_devices.RGBLED` + + + + +#### register\_volume\_led\_callback + +```python +def register_volume_led_callback(device) +``` + +Have a PWMLED change it's brightness according to current volume. LED flashes when minimum or maximum volume + +is reached. Minimum value is still a very dimly turned on LED (i.e. LED is never off). + +Compatible devices: + +* :class:`components.gpio.gpioz.core.output_devices.PWMLED` + + + + +#### register\_volume\_buzzer\_callback + +```python +def register_volume_buzzer_callback(device) +``` + +Sound a buzzer once when minimum or maximum value is reached + +Compatible devices: + +* :class:`components.gpio.gpioz.core.output_devices.Buzzer` +* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` + + + + +#### register\_volume\_rgbled\_callback + +```python +def register_volume_rgbled_callback(device) +``` + +Have a :class:`RGBLED` change it's color according to current volume. LED flashes when minimum or maximum volume + +is reached. + +Compatible devices: + +* :class:`components.gpio.gpioz.core.output_devices.RGBLED` + + + + +# components.gpio.gpioz.core.converter + +Provides converter functions/classes for various Jukebox parameters to + +values that can be assigned to GPIO output devices + + + + +## ColorProperty Objects + +```python +class ColorProperty() +``` + +Color descriptor ensuring valid weight ranges + + + + +## VolumeToRGB Objects + +```python +class VolumeToRGB() +``` + +Converts linear volume level to an RGB color value running through the color spectrum + +**Arguments**: + +- `max_input`: Maximum input value of linear input data +- `offset`: Offset in degrees in the color circle. Color circle +traverses blue (0), cyan(60), green (120), yellow(180), red (240), magenta (340) +- `section`: The section of the full color circle to use in degrees +Map input :data:`0...100` to color range :data:`green...magenta` and get the color for level 50 + + conv = VolumeToRGB(100, offset=120, section=180) + (r, g, b) = conv(50) + +The three components of an RGB LEDs do not have the same luminosity. +Weight factors are used to get a balanced color output + + + +#### \_\_call\_\_ + +```python +def __call__(volume) -> Tuple[float, float, float] +``` + +Perform conversion for single volume level + +**Returns**: + +Tuple(red, green, blue) + + + +#### luminize + +```python +def luminize(r, g, b) +``` + +Apply the color weight factors to the input color values + + + + +# components.gpio.gpioz.core.mock + +Changes to the GPIOZero devices for using with the Mock RFID Reader + + + + +#### patch\_mock\_outputs\_with\_callback + +```python +def patch_mock_outputs_with_callback() +``` + +Monkey Patch LED + Buzzer to get a callback when state changes + +This targets to represent the state in the TK GUI. +Other output devices cannot be represented in the GUI and are silently ignored. + +> [!NOTE] +> Only for developing purposes! + + + + +# components.gpio.gpioz.core.input\_devices + +Provides all supported input devices for the GPIOZ plugin. + +Input devices are based on GPIOZero devices. So for certain configuration parameters, you should +their documentation. + +All callback handlers are replaced by GPIOZ callback handlers. These are usually configured +by using the :func:`set_rpc_actions` each input device exhibits. + +For examples how to use the devices from the configuration files, see +[GPIO: Input Devices](../../builders/gpio.md#input-devices). + + + + +## NameMixin Objects + +```python +class NameMixin(ABC) +``` + +Provides name property and RPC decode function + + + + +#### set\_rpc\_actions + +```python +@abstractmethod +def set_rpc_actions(action_config) -> None +``` + +Set all input device callbacks from :attr:`action_config` + +**Arguments**: + +- `action_config`: Dictionary with one +[RPC Commands](../../builders/rpc-commands.md) definition entry for every device callback + + + +## EventProperty Objects + +```python +class EventProperty() +``` + +Event callback property + + + + +## ButtonBase Objects + +```python +class ButtonBase(ABC) +``` + +Common stuff for single button devices + + + + +#### value + +```python +@property +def value() +``` + +Returns 1 if the button is currently pressed, and 0 if it is not. + + + + +#### pin + +```python +@property +def pin() +``` + +Returns the underlying pin class from GPIOZero. + + + + +#### pull\_up + +```python +@property +def pull_up() +``` + +If :data:`True`, the device uses an internal pull-up resistor to set the GPIO pin “high” by default. + + + + +#### close + +```python +def close() +``` + +Close the device and release the pin + + + + +## Button Objects + +```python +class Button(NameMixin, ButtonBase) +``` + +A basic Button that runs a single actions on button press + +**Arguments**: + +- `pull_up` (`bool`): If :data:`True`, the device uses an internal pull-up resistor to set the GPIO pin “high” by default. +If :data:`False` the internal pull-down resistor is used. If :data:`None`, the pin will be floating and an external +resistor must be used and the :attr:`active_state` must be set. +- `active_state` (`bool or None`): If :data:`True`, when the hardware pin state is ``HIGH``, the software +pin is ``HIGH``. If :data:`False`, the input polarity is reversed: when +the hardware pin state is ``HIGH``, the software pin state is ``LOW``. +Use this parameter to set the active state of the underlying pin when +configuring it as not pulled (when *pull_up* is :data:`None`). When +*pull_up* is :data:`True` or :data:`False`, the active state is +automatically set to the proper value. +- `bounce_time` (`float or None`): Specifies the length of time (in seconds) that the component will +ignore changes in state after an initial change. This defaults to +:data:`None` which indicates that no bounce compensation will be +performed. +- `hold_repeat` (`bool`): If :data:`True` repeat the :attr:`on_press` every :attr:`hold_time` seconds. Else action +is run only once independent of the length of time the button is pressed for. +- `hold_time` (`float`): Time in seconds to wait between invocations of :attr:`on_press`. +- `pin_factory`: The GPIOZero pin factory. This parameter cannot be set through the configuration file +- `name` (`str`): The name of the button for use in error messages. This parameter cannot be set explicitly +through the configuration file + +.. copied from GPIOZero's documentation: active_state, bounce_time +.. Copyright Ben Nuttall / SPDX-License-Identifier: BSD-3-Clause + + + +#### on\_press + +```python +@property +def on_press() +``` + +The function to run when the device has been pressed + + + + +## LongPressButton Objects + +```python +class LongPressButton(NameMixin, ButtonBase) +``` + +A Button that runs a single actions only when the button is pressed long enough + +**Arguments**: + +- `pull_up`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) +- `active_state`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) +- `bounce_time`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) +- `hold_repeat`: If :data:`True` repeat the :attr:`on_press` every :attr:`hold_time` seconds. Else only action +is run only once independent of the length of time the button is pressed for. +- `hold_time`: The minimum time, the button must be pressed be running :attr:`on_press` for the first time. +Also the time in seconds to wait between invocations of :attr:`on_press`. + + + +#### on\_press + +```python +@on_press.setter +def on_press(func) +``` + +The function to run when the device has been pressed for longer than :attr:`hold_time` + + + + +## ShortLongPressButton Objects + +```python +class ShortLongPressButton(NameMixin, ButtonBase) +``` + +A single button that runs two different actions depending if the button is pressed for a short or long time. + +The shortest possible time is used to ensure a unique identification to an action can be made. For example a short press +can only be identified, when a button is released before :attr:`hold_time`, i.e. not directly on button press. +But a long press can be identified as soon as :attr:`hold_time` is reached and there is no need to wait for the release +event. Furthermore, if there is a long hold, only the long hold action is executed - the short press action is not run +in this case! + +**Arguments**: + +- `pull_up`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) +- `active_state`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) +- `bounce_time`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) +- `hold_time`: The time in seconds to differentiate if it is a short or long press. If the button is released before +this time, it is a short press. As soon as the button is held for :attr:`hold_time` it is a long press and the +short press action is ignored +- `hold_repeat`: If :data:`True` repeat the long press action every :attr:`hold_time` seconds after first long press +action +- `pin_factory`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) +- `name`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) + + + +## RotaryEncoder Objects + +```python +class RotaryEncoder(NameMixin) +``` + +A rotary encoder to run one of two actions depending on the rotation direction. + +**Arguments**: + +- `bounce_time`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) +- `pin_factory`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) +- `name`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) + + + +#### pin\_a + +```python +@property +def pin_a() +``` + +Returns the underlying pin A + + + + +#### pin\_b + +```python +@property +def pin_b() +``` + +Returns the underlying pin B + + + + +#### on\_rotate\_clockwise + +```python +@property +def on_rotate_clockwise() +``` + +The function to run when the encoder is rotated clockwise + + + + +#### on\_rotate\_counter\_clockwise + +```python +@property +def on_rotate_counter_clockwise() +``` + +The function to run when the encoder is rotated counter clockwise + + + + +#### close + +```python +def close() +``` + +Close the device and release the pin + + + + +## TwinButton Objects + +```python +class TwinButton(NameMixin) +``` + +A two-button device which can run up to six different actions, a.k.a the six function beast. + +Per user press "input" of the TwinButton, only a single callback is executed (but this callback +may be executed several times). +The shortest possible time is used to ensure a unique identification to an action can be made. For example a short press +can only be identified, when a button is released before :attr:`hold_time`, i.e. not directly on button press. +But a long press can be identified as soon as :attr:`hold_time` is reached and there is no need to wait for the release +event. Furthermore, if there is a long hold, only the long hold action is executed - the short press action is not run +in this case! + +It is not necessary to configure all actions. + +**Arguments**: + +- `pull_up`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) +- `active_state`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) +- `bounce_time`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) +- `hold_time`: The time in seconds to differentiate if it is a short or long press. If the button is released before +this time, it is a short press. As soon as the button is held for :attr:`hold_time` it is a long press and the +short press action is ignored. +- `hold_repeat`: If :data:`True` repeat the long press action every :attr:`hold_time` seconds after first long press +action. A long dual press is never repeated independent of this setting +- `pin_factory`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) +- `name`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) + + + +## StateVar Objects + +```python +class StateVar(Enum) +``` + +State encoding of the Mealy FSM + + + + +#### close + +```python +def close() +``` + +Close the device and release the pins + + + + +#### value + +```python +@property +def value() +``` + +2 bit integer indicating if and which button is currently pressed. Button A is the LSB. + + + + +#### is\_active + +```python +@property +def is_active() +``` + + + + + +# components.gpio.gpioz.core.output\_devices + +Provides all supported output devices for the GPIOZ plugin. + +For each device all constructor parameters can be set via the configuration file. Only exceptions +are the :attr:`name` and :attr:`pin_factory` which are set by internal mechanisms. + +The devices a are a relatively thin wrapper around the GPIOZero devices with the same name. +We add a name property to be used for error log message and similar and a :func:`flash` function +to all devices. This function provides a unified API to all devices. This means it can be called for every device +with parameters for this device and optional parameters from another device. Unused/unsupported parameters +are silently ignored. This is done to reduce the amount of coding required for connectivity functions. + +For examples how to use the devices from the configuration files, see +[GPIO: Output Devices](../../builders/gpio.md#output-devices). + + + + +## LED Objects + +```python +class LED(NameMixin, gpiozero.LED) +``` + +A binary LED + +**Arguments**: + +- `pin`: The GPIO pin which the LED is connected +- `active_high`: If :data:`true` the output pin will have a high logic level when the device is turned on. +- `pin_factory`: The GPIOZero pin factory. This parameter cannot be set through the configuration file +- `name` (`str`): The name of the button for use in error messages. This parameter cannot be set explicitly +through the configuration file + + + +#### flash + +```python +def flash(on_time=1, off_time=1, n=1, *, background=True, **ignored_kwargs) +``` + +Exactly like :func:`blink` but restores the original state after flashing the device + +**Arguments**: + +- `on_time` (`float`): Number of seconds on. Defaults to 1 second. +- `off_time` (`float`): Number of seconds off. Defaults to 1 second. +- `n`: Number of times to blink; :data:`None` means forever. +- `background` (`bool`): If :data:`True` (the default), start a background thread to +continue blinking and return immediately. If :data:`False`, only +return when the blink is finished +- `ignored_kwargs`: Ignore all other keywords so this function can be called with identical +parameters also for all other output devices + + + +## Buzzer Objects + +```python +class Buzzer(NameMixin, gpiozero.Buzzer) +``` + + + +#### flash + +```python +def flash(on_time=1, off_time=1, n=1, *, background=True, **ignored_kwargs) +``` + +Flash the device and restore the previous value afterwards + + + + +## PWMLED Objects + +```python +class PWMLED(NameMixin, gpiozero.PWMLED) +``` + + + +#### flash + +```python +def flash(on_time=1, + off_time=1, + n=1, + *, + fade_in_time=0, + fade_out_time=0, + background=True, + **ignored_kwargs) +``` + +Flash the LED and restore the previous value afterwards + + + + +## RGBLED Objects + +```python +class RGBLED(NameMixin, gpiozero.RGBLED) +``` + + + +#### flash + +```python +def flash(on_time=1, + off_time=1, + *, + fade_in_time=0, + fade_out_time=0, + on_color=(1, 1, 1), + off_color=(0, 0, 0), + n=None, + background=True, + **igorned_kwargs) +``` + +Flash the LED with :attr:`on_color` and restore the previous value afterwards + + + + +## TonalBuzzer Objects + +```python +class TonalBuzzer(NameMixin, gpiozero.TonalBuzzer) +``` + + + +#### flash + +```python +def flash(on_time=1, + off_time=1, + n=1, + *, + tone=None, + background=True, + **ignored_kwargs) +``` + +Play the tone :data:`tone` for :attr:`n` times + + + + +#### melody + +```python +def melody(on_time=0.2, + off_time=0.05, + *, + tone: Optional[List[Tone]] = None, + background=True) +``` + +Play a melody from the list of tones in :attr:`tone` + + + + +# components.timers + + + +# jukebox + + + +# jukebox.callingback + +Provides a generic callback handler + + + + +## CallbackHandler Objects + +```python +class CallbackHandler() +``` + +Generic Callback Handler to collect callbacks functions through :func:`register` and execute them + +with :func:`run_callbacks` + +A lock is used to sequence registering of new functions and running callbacks. + +**Arguments**: + +- `name`: A name of this handler for usage in log messages +- `logger`: The logger instance to use for logging +- `context`: A custom context handler to use as lock. If none, a local :class:`threading.Lock()` will be created + + + +#### register + +```python +def register(func: Optional[Callable[..., None]]) +``` + +Register a new function to be executed when the callback event happens + +**Arguments**: + +- `func`: The function to register. If set to :data:`None`, this register request is silently ignored. + + + +#### run\_callbacks + +```python +def run_callbacks(*args, **kwargs) +``` + +Run all registered callbacks. + +*ALL* exceptions from callback functions will be caught and logged only. +Exceptions are not raised upwards! + + + + +#### has\_callbacks + +```python +@property +def has_callbacks() +``` + + + + + +# jukebox.version + + + +#### version + +```python +def version() +``` + +Return the Jukebox version as a string + + + + +#### version\_info + +```python +def version_info() +``` + +Return the Jukebox version as a tuple of three numbers + +If this is a development version, an identifier string will be appended after the third integer. + + + + +# jukebox.cfghandler + +This module handles global and local configuration data + +The concept is that config handler is created and initialized once in the main thread:: + + cfg = get_handler('global') + load_yaml(cfg, 'filename.yaml') + +In all other modules (in potentially different threads) the same handler is obtained and used by:: + + cfg = get_handler('global') + +This eliminates the need to pass an effectively global configuration handler by parameters across the entire design. +Handlers are identified by their name (in the above example *global*) + +The function :func:`get_handler` is the main entry point to obtain a new or existing handler. + + + + +## ConfigHandler Objects + +```python +class ConfigHandler() +``` + +The configuration handler class + +Don't instantiate directly. Always use :func:`get_handler`! + +**Threads:** + +All threads can read and write to the configuration data. +**Proper thread-safeness must be ensured** by the the thread modifying the data by acquiring the lock +Easiest and best way is to use the context handler:: + + with cfg: + cfg['key'] = 66 + cfg.setndefault('hello', value='world') + +For a single function call, this is done implicitly. In this case, there is no need +to explicitly acquire the lock. + +Alternatively, you can lock and release manually by using :func:`acquire` and :func:`release` +But be very sure to release the lock even in cases of errors an exceptions! +Else we have a deadlock. + +Reading may be done without acquiring a lock. But be aware that when reading multiple values without locking, another +thread may intervene and modify some values in between! So, locking is still recommended. + + + + +#### loaded\_from + +```python +@property +def loaded_from() -> Optional[str] +``` + +Property to store filename from which the config was loaded + + + + +#### get + +```python +def get(key, *, default=None) +``` + +Enforce keyword on default to avoid accidental misuse when actually getn is wanted + + + + +#### setdefault + +```python +def setdefault(key, *, value) +``` + +Enforce keyword on default to avoid accidental misuse when actually setndefault is wanted + + + + +#### getn + +```python +def getn(*keys, default=None) +``` + +Get the value at arbitrary hierarchy depth. Return ``default`` if key not present + +The *default* value is returned no matter at which hierarchy level the path aborts. +A hierarchy is considered as any type with a :func:`get` method. + + + + +#### setn + +```python +def setn(*keys, value, hierarchy_type=None) -> None +``` + +Set the ``key: value`` pair at arbitrary hierarchy depth + +All non-existing hierarchy levels are created. + +**Arguments**: + +- `keys`: Key hierarchy path through the nested levels +- `value`: The value to set +- `hierarchy_type`: The type for new hierarchy levels. If *None*, the top-level type +is used + + + +#### setndefault + +```python +def setndefault(*keys, value, hierarchy_type=None) +``` + +Set the ``key: value`` pair at arbitrary hierarchy depth unless the key already exists + +All non-existing hierarchy levels are created. + +**Arguments**: + +- `keys`: Key hierarchy path through the nested levels +- `value`: The default value to set +- `hierarchy_type`: The type for new hierarchy levels. If *None*, the top-level type +is used + +**Returns**: + +The actual value or or the default value if key does not exit + + + +#### config\_dict + +```python +def config_dict(data) +``` + +Initialize configuration data from dict-like data structure + +**Arguments**: + +- `data`: configuration data + + + +#### is\_modified + +```python +def is_modified() -> bool +``` + +Check if the data has changed since the last load/store + +> [!NOTE] +> This relies on the *__str__* representation of the underlying data structure +> In case of ruamel, this ignores comments and only looks at the data + + + + +#### clear\_modified + +```python +def clear_modified() -> None +``` + +Sets the current state as new baseline, clearing the is_modified state + + + + +#### save + +```python +def save(only_if_changed: bool = False) -> None +``` + +Save config back to the file it was loaded from + +If you want to save to a different file, use :func:`write_yaml`. + + + + +#### load + +```python +def load(filename: str) -> None +``` + +Load YAML config file into memory + + + + +#### get\_handler + +```python +def get_handler(name: str) -> ConfigHandler +``` + +Get a configuration data handler with the specified name, creating it + +if it doesn't yet exit. If created, it is always created empty. + +This is the main entry point for obtaining an configuration handler + +**Arguments**: + +- `name`: Name of the config handler + +**Returns**: + +`ConfigHandler`: The configuration data handler for *name* + + + +#### load\_yaml + +```python +def load_yaml(cfg: ConfigHandler, filename: str) -> None +``` + +Load a yaml file into a ConfigHandler + +**Arguments**: + +- `cfg`: ConfigHandler instance +- `filename`: filename to yaml file + +**Returns**: + +None + + + +#### write\_yaml + +```python +def write_yaml(cfg: ConfigHandler, + filename: str, + only_if_changed: bool = False, + *args, + **kwargs) -> None +``` + +Writes ConfigHandler data to yaml file / sys.stdout + +**Arguments**: + +- `cfg`: ConfigHandler instance +- `filename`: filename to output file. If *sys.stdout*, output is written to console +- `only_if_changed`: Write file only, if ConfigHandler.is_modified() +- `args`: passed on to yaml.dump(...) +- `kwargs`: passed on to yaml.dump(...) + +**Returns**: + +None + + + +# jukebox.playlistgenerator + +Playlists are build from directory content in the following way: + +a directory is parsed and files are added to the playlist in the following way + +1. files are added in alphabetic order +2. files ending with ``*livestream.txt`` are unpacked and the containing URL(s) are added verbatim to the playlist +3. files ending with ``*podcast.txt`` are unpacked and the containing Podcast URL(s) are expanded and added to the playlist +4. files ending with ``*.m3u`` are treated as folder playlist. Regular folder processing is suspended and the playlist + is build solely from the ``*.m3u`` content. Only the alphabetically first ``*.m3u`` is processed. URLs are added verbatim + to the playlist except for ``*.xml`` and ``*.podcast`` URLS, which are expanded first + +An directory may contain a mixed set of files and multiple ``*.txt`` files, e.g. + + 01-livestream.txt + 02-livestream.txt + music.mp3 + podcast.txt + +All files are treated as music files and are added to the playlist, except those: + + * starting with ``.``, + * not having a file ending, i.e. do not contain a ``.``, + * ending with ``.txt``, + * ending with ``.m3u``, + * ending with one of the excluded file endings in :attr:`PlaylistCollector._exclude_endings` + +In recursive mode, the playlist is generated by concatenating all sub-folder playlists. Sub-folders are parsed +in alphabetic order. Symbolic links are being followed. The above rules are enforced on a per-folder bases. +This means, one ``*.m3u`` file per sub-folder is processed (if present). + +In ``*.txt`` and ``*.m3u`` files, all lines starting with ``#`` are ignored. + + + + +#### TYPE\_DECODE + +Types if file entires in parsed directory + + + + +## PlaylistCollector Objects + +```python +class PlaylistCollector() +``` + +Build a playlist from directory(s) + +This class is intended to be used with an absolute path to the music library:: + + plc = PlaylistCollector('/home/chris/music') + plc.parse('Traumfaenger') + print(f"res = {plc}") + +But it can also be used with relative paths from current working directory:: + + plc = PlaylistCollector('.') + plc.parse('../../../../music/Traumfaenger') + print(f"res = {plc}") + +The file ending exclusion list :attr:`PlaylistCollector._exclude_endings` is a class variable for performance reasons. +If changed it will affect all instances. For modifications always call :func:`set_exclusion_endings`. + + + + +#### \_\_init\_\_ + +```python +def __init__(music_library_base_path='/') +``` + +Initialize the playlist generator with music_library_base_path + +**Arguments**: + +- `music_library_base_path`: Base path the the music library. This is used to locate the file in the disk +but is omitted when generating the playlist entries. I.e. all files in the playlist are relative to this base dir + + + +#### set\_exclusion\_endings + +```python +@classmethod +def set_exclusion_endings(cls, endings: List[str]) +``` + +Set the class-wide file ending exclusion list + +See :attr:`PlaylistCollector._exclude_endings` + + + + +#### get\_directory\_content + +```python +def get_directory_content(path='.') +``` + +Parse the folder ``path`` and create a content list. Depth is always the current level + +**Arguments**: + +- `path`: Path to folder **relative** to ``music_library_base_path`` + +**Returns**: + +[ { type: 'directory', name: 'Simone', path: '/some/path/to/Simone' }, {...} ] +where type is one of :attr:`TYPE_DECODE` + + + +#### parse + +```python +def parse(path='.', recursive=False) +``` + +Parse the folder ``path`` and create a playlist from it's content + +**Arguments**: + +- `path`: Path to folder **relative** to ``music_library_base_path`` +- `recursive`: Parse folder recursivley, or stay in top-level folder + + + +# jukebox.NvManager + + + +# jukebox.publishing + + + +#### get\_publisher + +```python +def get_publisher() +``` + +Return the publisher instance for this thread + +Per thread, only one publisher instance is required to connect to the inproc socket. +A new instance is created if it does not already exist. + +If there is a remote-chance that your function publishing something may be called form +different threads, always make a fresh call to ``get_publisher()`` to get the correct instance for the current thread. + +Example:: + + import jukebox.publishing as publishing + + class MyClass: + def __init__(self): + pass + + def say_hello(name): + publishing.get_publisher().send('hello', f'Hi {name}, howya?') + +To stress what **NOT** to do: don't get a publisher instance in the constructor and save it to ``self._pub``. +If you do and ``say_hello`` gets called from different threads, the publisher of the thread which instantiated the class +will be used. + +If you need your very own private Publisher Instance, you'll need to instantiate it yourself. +But: the use cases are very rare for that. I cannot think of one at the moment. + +**Remember**: Don’t share ZeroMQ sockets between threads. + + + + +# jukebox.publishing.subscriber + + + +# jukebox.publishing.server + +## Publishing Server + +The common publishing server for the entire Jukebox using ZeroMQ + +### Structure + + +-----------------------+ + | functional interface | Publisher + | | - functional interface for single Thread + | PUB | - sends data to publisher (and thus across threads) + +-----------------------+ + | (1) + v + +-----------------------+ + | SUB (bind) | PublishServer + | | - Last Value (LV) Cache + | XPUB (bind) | - Subscriber notification and LV resend + +-----------------------+ - independent thread + | (2) + v + +#### Connection (1): Internal connection + +Internal connection only - do not use (no, not even inside this App for you own plugins - always bind to the PublishServer) + + Protocol: Multi-part message + + Part 1: Topic (in topic tree format) + E.g. player.status.elapsed + + Part 2: Payload or Message in json serialization + If empty (i.e. ``b''``), it means delete the topic sub-tree from cache. And instruct subscribers to do the same + + Part 3: Command + Usually empty, i.e. ``b''``. If not empty the message is treated as command for the PublishServer + and the message is not forwarded to the outside. This third part of the message is never forwarded + +#### Connection (2): External connection + +Upon connection of a new subscriber, the entire current state is resend from cache to ALL subscribers! +Subscribers must subscribe to topics. Topics are treated as topic trees! Subscribing to a root tree will +also get you all the branch topics. To get everything, subscribe to ``b''`` + + Protocol: Multi-part message + + Part 1: Topic (in topic tree format) + E.g. player.status.elapsed + + Part 2: Payload or Message in json serialization + If empty (i.e. b''), it means the subscriber must delete this key locally (not valid anymore) + +### Why? Why? + +Check out the [ZeroMQ Documentation](https://zguide.zeromq.org/docs/chapter5) +for why you need a proxy in a good design. + +For use case, we made a few simplifications + +### Design Rationales + +* "If you need [millions of messages per second](https://zguide.zeromq.org/docs/chapter5/`Pros`-and-Cons-of-Pub-Sub) + sent to thousands of points, + you'll appreciate pub-sub a lot more than if you need a few messages a second sent to a handful of recipients." +* "lower-volume network with a few dozen subscribers and a limited number of topics, we can use TCP and then + the [XSUB and XPUB](https://zguide.zeromq.org/docs/chapter5/`Last`-Value-Caching)" +* "Let's imagine [our feed has an average of 100,000 100-byte messages a + second](https://zguide.zeromq.org/docs/chapter5/`High`-Speed-Subscribers-Black-Box-Pattern) [...]. + While 100K messages a second is easy for a ZeroMQ application, ..." + +**But we have:** + +* few dozen subscribers --> Check! +* limited number of topics --> Check! +* max ~10 messages per second --> Check! +* small common state information --> Check! +* only the server updates the state --> Check! + +This means, we can use less complex patters than used for these high-speed, high code count, high data rate networks :-) + +* XPUB / XSUB to detect new subscriber +* Cache the entire state in the publisher +* Re-send the entire state on-demand (and then even to every subscriber) +* Using the same channel: sends state to every subscriber + +**Reliability considerations** + +* Late joining client (or drop-off and re-join): get full state update +* Server crash etc: No special handling necessary, we are simple + and don't need recovery in this case. Server will publish initial state + after re-start +* Subscriber too slow: Subscribers problem (TODO: Do we need to do anything about it?) + +**Start-up sequence:** + +* Publisher plugin is first plugin to be loaded +* Due to Publisher - PublisherServer structure no further sequencing required + +### Plugin interactions and usage + +RPC can trigger through function call in components/publishing plugin that + +* entire state is re-published (from the cache) +* a specific topic tree is re-published (from the cache) + +Plugins publishing state information should publish initial state at @plugin.finalize + +> [!IMPORTANT] +> Do not direclty instantiate the Publisher in your plugin module. Only one Publisher is +> required per thread. But the publisher instance **must** be thread-local! +> Always go through :func:`publishing.get_publisher()`. + +**Sockets** + +Three sockets are opened: + +1. TCP (on a configurable port) +2. Websocket (on a configurable port) +3. Inproc: On ``inproc://PublisherToProxy`` all topics are published app-internally. This can be used for plugin modules + that want to know about the current state on event based updates. + +**Further ZeroMQ References:** + +* [Working with Messages](https://zguide.zeromq.org/docs/chapter2/`Working`-with-Messages) +* [Multiple Threads](https://zguide.zeromq.org/docs/chapter2/`Multithreading`-with-ZeroMQ) + + + + +## PublishServer Objects + +```python +class PublishServer(threading.Thread) +``` + +The publish proxy server that collects and caches messages from all internal publishers and + +forwards them to the outside world + +Handles new subscriptions by sending out the entire cached state to **all** subscribers + +The code is structures using a [Reactor Pattern](https://zguide.zeromq.org/docs/chapter5/`Using`-a-Reactor) + + + + +#### run + +```python +def run() +``` + +Thread's activity + + + + +#### handle\_message + +```python +def handle_message(msg) +``` + +Handle incoming messages + + + + +#### handle\_subscription + +```python +def handle_subscription(msg) +``` + +Handle new subscribers + + + + +## Publisher Objects + +```python +class Publisher() +``` + +The publisher that provides the functional interface to the application + +> [!NOTE] +> * An instance must not be shared across threads! +> * One instance per thread is enough + + + + +#### \_\_init\_\_ + +```python +def __init__(check_thread_owner=True) +``` + +**Arguments**: + +- `check_thread_owner`: Check if send() is always called from the correct thread. This is debug feature +and is intended to expose the situation before it leads to real trouble. Leave it on! + + + +#### send + +```python +def send(topic: str, payload) +``` + +Send out a message for topic + + + + +#### revoke + +```python +def revoke(topic: str) +``` + +Revoke a single topic element (not a topic tree!) + + + + +#### resend + +```python +def resend(topic: Optional[str] = None) +``` + +Instructs the PublishServer to resend current status to all subscribers + +Not necessary to call after incremental updates or new subscriptions - that will happen automatically! + + + + +#### close\_server + +```python +def close_server() +``` + +Instructs the PublishServer to close itself down + + + + +# jukebox.daemon + + + +#### log\_active\_threads + +```python +@atexit.register +def log_active_threads() +``` + +This functions is registered with atexit very early, meaning it will be run very late. It is the best guess to + +evaluate which Threads are still running (and probably shouldn't be) + +This function is registered before all the plugins and their dependencies are loaded + + + + +## JukeBox Objects + +```python +class JukeBox() +``` + + + +#### signal\_handler + +```python +def signal_handler(esignal, frame) +``` + +Signal handler for orderly shutdown + +On first Ctrl-C (or SIGTERM) orderly shutdown procedure is embarked upon. It gets allocated a time-out! +On third Ctrl-C (or SIGTERM), this is interrupted and there will be a hard exit! + + + + +# jukebox.plugs + +A plugin package with some special functionality + +Plugins packages are python packages that are dynamically loaded. From these packages only a subset of objects is exposed +through the plugs.call interface. The python packages can use decorators or dynamic function call to register (callable) +objects. + +The python package name may be different from the name the package is registered under in plugs. This allows to load different +python packages for a specific feature based on a configuration file. Note: Python package are still loaded as regular +python packages and can be accessed by normal means + +If you want to provide additional functionality to the same feature (probably even for run-time switching) +you can implement a Factory Pattern using this package. Take a look at volume.py as an example. + +**Example:** Decorate a function for auto-registering under it's own name: + + import jukebox.plugs as plugs + @plugs.register + def func1(param): + pass + +**Example:** Decorate a function for auto-registering under a new name: + + @plugs.register(name='better_name') + def func2(param): + pass + +**Example:** Register a function during run-time under it's own name: + + def func3(param): + pass + plugs.register(func3) + +**Example:** Register a function during run-time under a new name: + + def func4(param): + pass + plugs.register(func4, name='other_name', package='other_package') + +**Example:** Decorate a class for auto registering during initialization, +including all methods (see _register_class for more info): + + @plugs.register(auto_tag=True) + class MyClass1: + pass + +**Example:** Register a class instance, from which only report is a callable method through the plugs interface: + + class MyClass2: + @plugs.tag + def report(self): + pass + myinst2 = MyClass2() + plugin.register(myinst2, name='myinst2') + +Naming convention: + +* package + * Either a python package + * or a plugin package (which is the python package but probably loaded under a different name inside plugs) +* plugin + * An object from the package that can be accessed through the plugs call function (i.e. a function or a class instance) + * The string name to above object +* name + * The string name of the plugin object for registration +* method + * In case the object is a class instance a bound method to call from the class instance + * The string name to above object + + + + +## PluginPackageClass Objects + +```python +class PluginPackageClass() +``` + +A local data class for holding all information about a loaded plugin package + + + + +#### register + +```python +@overload +def register(plugin: Callable) -> Callable +``` + +1-level decorator around a function + + + + +#### register + +```python +@overload +def register(plugin: Type) -> Any +``` + +Signature: 1-level decorator around a class + + + + +#### register + +```python +@overload +def register(*, name: str, package: Optional[str] = None) -> Callable +``` + +Signature: 2-level decorator around a function + + + + +#### register + +```python +@overload +def register(*, auto_tag: bool = False, package: Optional[str] = None) -> Type +``` + +Signature: 2-level decorator around a class + + + + +#### register + +```python +@overload +def register(plugin: Callable[..., Any] = None, + *, + name: Optional[str] = None, + package: Optional[str] = None, + replace: bool = False) -> Callable +``` + +Signature: Run-time registration of function / class instance / bound method + + + + +#### register + +```python +def register(plugin: Optional[Callable] = None, + *, + name: Optional[str] = None, + package: Optional[str] = None, + replace: bool = False, + auto_tag: bool = False) -> Callable +``` + +A generic decorator / run-time function to register plugin module callables + +The functions comes in five distinct signatures for 5 use cases: + +1. ``@plugs.register``: decorator for a class w/o any arguments +2. ``@plugs.register``: decorator for a function w/o any arguments +3. ``@plugs.register(auto_tag=bool)``: decorator for a class with 1 arguments +4. ``@plugs.register(name=name, package=package)``: decorator for a function with 1 or 2 arguments +5. ``plugs.register(plugin, name=name, package=package)``: run-time registration of + * function + * bound method + * class instance + +For more documentation see the functions +* :func:`_register_obj` +* :func:`_register_class` + +See the examples in Module :mod:`plugs` how to use this decorator / function + +**Arguments**: + +- `plugin`: +- `name`: +- `package`: +- `replace`: +- `auto_tag`: + + + +#### tag + +```python +def tag(func: Callable) -> Callable +``` + +Method decorator for tagging a method as callable through the plugs interface + +Note that the instantiated class must still be registered as plugin object +(either with the class decorator or dynamically) + +**Arguments**: + +- `func`: function to decorate + +**Returns**: + +the function + + + +#### initialize + +```python +def initialize(func: Callable) -> Callable +``` + +Decorator for functions that shall be called by the plugs package directly after the module is loaded + +**Arguments**: + +- `func`: Function to decorate + +**Returns**: + +The function itself + + + +#### finalize + +```python +def finalize(func: Callable) -> Callable +``` + +Decorator for functions that shall be called by the plugs package directly after ALL modules are loaded + +**Arguments**: + +- `func`: Function to decorate + +**Returns**: + +The function itself + + + +#### atexit + +```python +def atexit(func: Callable[[int], Any]) -> Callable[[int], Any] +``` + +Decorator for functions that shall be called by the plugs package directly after at exit of program. + +> [!IMPORTANT] +> There is no automatism as in atexit.atexit. The function plugs.shutdown() must be explicitly called +> during the shutdown procedure of your program. This is by design, so you can choose the exact situation in your +> shutdown handler. + +The atexit-functions are called with a single integer argument, which is passed down from plugin.exit(int) +It is intended for passing down the signal number that initiated the program termination + +**Arguments**: + +- `func`: Function to decorate + +**Returns**: + +The function itself + + + +#### load + +```python +def load(package: str, + load_as: Optional[str] = None, + prefix: Optional[str] = None) +``` + +Loads a python package as plugin package + +Executes a regular python package load. That means a potentially existing `__init__.py` is executed. +Decorator `@register` can by used to register functions / classes / class istances as plugin callable +Decorator `@initializer` can be used to tag functions that shall be called after package loading +Decorator `@finalizer` can be used to tag functions that shall be called after ALL plugin packges have been loaded +Instead of using `@initializer`, you may of course use `__init__.py` + +Python packages may be loaded under a different plugs package name. Python packages must be unique and the name under +which they are loaded as plugin package also. + +**Arguments**: + +- `package`: Python package to load as plugin package +- `load_as`: Plugin package registration name. If None the name is the python's package simple name +- `prefix`: Prefix to python package to create fully qualified name. This is used only to locate the python package +and ignored otherwise. Useful if all the plugin module are in a dedicated folder + + + +#### load\_all\_named + +```python +def load_all_named(packages_named: Mapping[str, str], + prefix: Optional[str] = None, + ignore_errors=False) +``` + +Load all packages in packages_named with mapped names + +**Arguments**: + +- `packages_named`: Dict[load_as, package] + + + +#### load\_all\_unnamed + +```python +def load_all_unnamed(packages_unnamed: Iterable[str], + prefix: Optional[str] = None, + ignore_errors=False) +``` + +Load all packages in packages_unnamed with default names + + + + +#### load\_all\_finalize + +```python +def load_all_finalize(ignore_errors=False) +``` + +Calls all functions registered with @finalize from all loaded modules in the order they were loaded + +This must be executed after the last plugin package is loaded + + + + +#### close\_down + +```python +def close_down(**kwargs) -> Any +``` + +Calls all functions registered with @atexit from all loaded modules in reverse order of module load order + +Modules are processed in reverse order. Several at-exit tagged functions of a single module are processed +in the order of registration. + +Errors raised in functions are suppressed to ensure all plugins are processed + + + + +#### call + +```python +def call(package: str, + plugin: str, + method: Optional[str] = None, + *, + args=(), + kwargs=None, + as_thread: bool = False, + thread_name: Optional[str] = None) -> Any +``` + +Call a function/method from the loaded plugins + +If a plugin is a function or a callable instance of a class, this is equivalent to + +``package.plugin(*args, **kwargs)`` + +If plugin is a class instance from which a method is called, this is equivalent to the followig. +Also remember, that method must have the attribute ``plugin_callable = True`` + +``package.plugin.method(*args, **kwargs)`` + +Calls are serialized by a thread lock. The thread lock is shared with call_ignore_errors. + +> [!NOTE] +> There is no logger in this function as they all belong up-level where the exceptions are handled. +> If you want logger messages instead of exceptions, use :func:`call_ignore_errors` + +**Arguments**: + +- `package`: Name of the plugin package in which to look for function/class instance +- `plugin`: Function name or instance name of a class +- `method`: Method name when accessing a class instance' method. Leave at *None* if unneeded. +- `as_thread`: Run the callable in separate daemon thread. +There is no return value from the callable in this case! The return value is the thread object. +Also note that Exceptions in the Thread must be handled in the Thread and are not propagated to the main Thread. +All threads are started as daemon threads with terminate upon main program termination. +There is not stop-thread mechanism. This is intended for short lived threads. +- `thread_name`: Name of the thread +- `args`: Arguments passed to callable +- `kwargs`: Keyword arguments passed to callable + +**Returns**: + +The return value from the called function, or, if started as thread the thread object + + + +#### call\_ignore\_errors + +```python +def call_ignore_errors(package: str, + plugin: str, + method: Optional[str] = None, + *, + args=(), + kwargs=None, + as_thread: bool = False, + thread_name: Optional[str] = None) -> Any +``` + +Call a function/method from the loaded plugins ignoring all raised Exceptions. + +Errors get logged. + +See :func:`call` for parameter documentation. + + + + +#### exists + +```python +def exists(package: str, + plugin: Optional[str] = None, + method: Optional[str] = None) -> bool +``` + +Check if an object is registered within the plugs package + + + + +#### get + +```python +def get(package: str, + plugin: Optional[str] = None, + method: Optional[str] = None) -> Any +``` + +Get a plugs-package registered object + +The return object depends on the number of parameters + +* 1 argument: Get the python module reference for the plugs *package* +* 2 arguments: Get the plugin reference for the plugs *package.plugin* +* 3 arguments: Get the plugin reference for the plugs *package.plugin.method* + + + + +#### loaded\_as + +```python +def loaded_as(module_name: str) -> str +``` + +Return the plugin name a python module is loaded as + + + + +#### delete + +```python +def delete(package: str, plugin: Optional[str] = None, ignore_errors=False) +``` + +Delete a plugin object from the registered plugs callables + +> [!NOTE] +> This does not 'unload' the python module. It merely makes it un-callable via plugs! + + + + +#### dump\_plugins + +```python +def dump_plugins(stream) +``` + +Write a human readable summary of all plugin callables to stream + + + + +#### summarize + +```python +def summarize() +``` + +Create a reference summary of all plugin callables in dictionary format + + + + +#### generate\_help\_rst + +```python +def generate_help_rst(stream) +``` + +Write a reference of all plugin callables in Restructured Text format + + + + +#### get\_all\_loaded\_packages + +```python +def get_all_loaded_packages() -> Dict[str, str] +``` + +Report a short summary of all loaded packages + +**Returns**: + +Dictionary of the form `{loaded_as: loaded_from, ...}` + + + +#### get\_all\_failed\_packages + +```python +def get_all_failed_packages() -> Dict[str, str] +``` + +Report those packages that did not load error free + +> [!NOTE] +> Package could fail to load +> * altogether: these package are not registered +> * partially: during initializer, finalizer functions: The package is loaded, +> but the function did not execute error-free +> +> Partially loaded packages are listed in both _PLUGINS and _PLUGINS_FAILED + +**Returns**: + +Dictionary of the form `{loaded_as: loaded_from, ...}` + + + +# jukebox.speaking\_text + +Text to Speech. Plugin to speak any given text via speaker + + + + +# jukebox.multitimer + +Multitimer Module + + + + +## MultiTimer Objects + +```python +class MultiTimer(threading.Thread) +``` + +Call a function after a specified number of seconds, repeat that iteration times + +May be cancelled during any of the wait times. +Function is called with keyword parameter 'iteration' (which decreases down to 0 for the last iteration) + +If iterations is negative, an endlessly repeating timer is created (which needs to be cancelled with cancel()) + +Initiates start and publishing by calling self.publish_callback + +Note: Inspired by threading.Timer and generally using the same API + + + + +#### cancel + +```python +def cancel() +``` + +Stop the timer if it hasn't finished all iterations yet. + + + + +## GenericTimerClass Objects + +```python +class GenericTimerClass() +``` + +Interface for plugin / RPC accessibility for a single event timer + + + + +#### \_\_init\_\_ + +```python +def __init__(name, wait_seconds: float, function, args=None, kwargs=None) +``` + +**Arguments**: + +- `wait_seconds`: The time in seconds to wait before calling function +- `function`: The function to call with args and kwargs. +- `args`: Parameters for function call +- `kwargs`: Parameters for function call + + + +#### start + +```python +@plugin.tag +def start(wait_seconds=None) +``` + +Start the timer (with default or new parameters) + + + + +#### cancel + +```python +@plugin.tag +def cancel() +``` + +Cancel the timer + + + + +#### toggle + +```python +@plugin.tag +def toggle() +``` + +Toggle the activation of the timer + + + + +#### trigger + +```python +@plugin.tag +def trigger() +``` + +Trigger the next target execution before the time is up + + + + +#### is\_alive + +```python +@plugin.tag +def is_alive() +``` + +Check if timer is active + + + + +#### get\_timeout + +```python +@plugin.tag +def get_timeout() +``` + +Get the configured time-out + +**Returns**: + +The total wait time. (Not the remaining wait time!) + + + +#### set\_timeout + +```python +@plugin.tag +def set_timeout(wait_seconds: float) +``` + +Set a new time-out in seconds. Re-starts the timer if already running! + + + + +#### publish + +```python +@plugin.tag +def publish() +``` + +Publish the current state and config + + + + +#### get\_state + +```python +@plugin.tag +def get_state() +``` + +Get the current state and config as dictionary + + + + +## GenericEndlessTimerClass Objects + +```python +class GenericEndlessTimerClass(GenericTimerClass) +``` + +Interface for plugin / RPC accessibility for an event timer call function endlessly every m seconds + + + + +## GenericMultiTimerClass Objects + +```python +class GenericMultiTimerClass(GenericTimerClass) +``` + +Interface for plugin / RPC accessibility for an event timer that performs an action n times every m seconds + + + + +#### \_\_init\_\_ + +```python +def __init__(name, + iterations: int, + wait_seconds_per_iteration: float, + callee, + args=None, + kwargs=None) +``` + +**Arguments**: + +- `iterations`: Number of times callee is called +- `wait_seconds_per_iteration`: Wait in seconds before each iteration +- `callee`: A builder class that gets instantiated once as callee(*args, iterations=iterations, **kwargs). +Then with every time out iteration __call__(*args, iteration=iteration, **kwargs) is called. +'iteration' is the current iteration count in decreasing order! +- `args`: +- `kwargs`: + + + +#### start + +```python +@plugin.tag +def start(iterations=None, wait_seconds_per_iteration=None) +``` + +Start the timer (with default or new parameters) + + + + +# jukebox.utils + +Common utility functions + + + + +#### decode\_rpc\_call + +```python +def decode_rpc_call(cfg_rpc_call: Dict) -> Optional[Dict] +``` + +Makes sure that the core rpc call parameters have valid default values in cfg_rpc_call. + +> [!IMPORTANT] +> Leaves all other parameters in cfg_action untouched or later downstream processing! + +**Arguments**: + +- `cfg_rpc_call`: RPC command as configuration entry + +**Returns**: + +A fully populated deep copy of cfg_rpc_call + + + +#### decode\_rpc\_command + +```python +def decode_rpc_command(cfg_rpc_cmd: Dict, + logger: logging.Logger = log) -> Optional[Dict] +``` + +Decode an RPC Command from a config entry. + +This means + +* Decode RPC command alias (if present) +* Ensure all RPC call parameters have valid default values + +If the command alias cannot be decoded correctly, the command is mapped to misc.empty_rpc_call +which emits a misuse warning when called +If an explicitly specified this is not done. However, it is ensured that the returned +dictionary contains all mandatory parameters for an RPC call. RPC call functions have error handling +for non-existing RPC commands and we get a clearer error message. + +**Arguments**: + +- `cfg_rpc_cmd`: RPC command as configuration entry +- `logger`: The logger to use + +**Returns**: + +A decoded, fully populated deep copy of cfg_rpc_cmd + + + +#### decode\_and\_call\_rpc\_command + +```python +def decode_and_call_rpc_command(rpc_cmd: Dict, logger: logging.Logger = log) +``` + +Convenience function combining decode_rpc_command and plugs.call_ignore_errors + + + + +#### bind\_rpc\_command + +```python +def bind_rpc_command(cfg_rpc_cmd: Dict, + dereference=False, + logger: logging.Logger = log) +``` + +Decode an RPC command configuration entry and bind it to a function + +**Arguments**: + +- `dereference`: Dereference even the call to plugs.call(...) + ``. If false, the returned function is ``plugs.call(package, plugin, method, *args, **kwargs)`` with + all checks applied at bind time + ``. If true, the returned function is ``package.plugin.method(*args, **kwargs)`` with + all checks applied at bind time. + +Setting deference to True, circumvents the dynamic nature of the plugins: the function to call + must exist at bind time and cannot change. If False, the function to call must only exist at call time. + This can be important during the initialization where package ordering and initialization means that not all + classes have been instantiated yet. With dereference=True also the plugs thread lock for serialization of calls + is circumvented. Use with care! + +**Returns**: + +Callable function w/o parameters which directly runs the RPC command +using plugs.call_ignore_errors + + + +#### rpc\_call\_to\_str + +```python +def rpc_call_to_str(cfg_rpc_call: Dict, with_args=True) -> str +``` + +Return a readable string of an RPC call config + +**Arguments**: + +- `cfg_rpc_call`: RPC call configuration entry +- `with_args`: Return string shall include the arguments of the function + + + +#### generate\_cmd\_alias\_rst + +```python +def generate_cmd_alias_rst(stream) +``` + +Write a reference of all rpc command aliases in Restructured Text format + + + + +#### generate\_cmd\_alias\_reference + +```python +def generate_cmd_alias_reference(stream) +``` + +Write a reference of all rpc command aliases in text format + + + + +#### get\_git\_state + +```python +def get_git_state() +``` + +Return git state information for the current branch + + + + +# jukebox.rpc + + + +# jukebox.rpc.client + + + +# jukebox.rpc.server + +## Remote Procedure Call Server (RPC) + +Bind to tcp and/or websocket port and translates incoming requests to procedure calls. +Avaiable procedures to call are all functions registered with the plugin package. + +The protocol is loosely based on [jsonrpc](https://www.jsonrpc.org/specification) + +But with different elements directly relating to the plugin concept and Python function argument options + + { + 'package' : str # The plugin package loaded from python module + 'plugin' : str # The plugin object to be accessed from the package + # (i.e. function or class instance) + 'method' : str # (optional) The method of the class instance + 'args' : [ ] # (optional) Positional arguments as list + 'kwargs' : { } # (optional) Keyword arguments as dictionary + 'as_thread': bool # (optional) start call in separate thread + 'id' : Any # (optional) Round-trip id for response (may not be None) + 'tsp' : Any # (optional) measure and return total processing time for + # the call request (may not be None) + } + +**Response** + +A response will ALWAYS be send, independent of presence of 'id'. This is in difference to the +jsonrpc specification. But this is a ZeroMQB REQ/REP pattern requirement! + +If 'id' is omitted, the response will be 'None'! Unless an error occurred, then the error is returned. +The absence of 'id' indicates that the requester is not interested in the response. +If present, 'id' and 'tsp' may not be None. If they are None, there are treated as if non-existing. + +**Sockets** + +Three sockets are opened + +1. TCP (on a configurable port) +2. Websocket (on a configurable port) +3. Inproc: On ``inproc://JukeBoxRpcServer`` connection from the internal app are accepted. This is indented be + call arbitrary RPC functions from plugins that provide an interface to the outside world (e.g. GPIO). By also going though + the RPC instead of calling function directly we increase thread-safety and provide easy configurability (e.g. which + button triggers what action) + + + + +## RpcServer Objects + +```python +class RpcServer() +``` + +The RPC Server Class + + + + +#### \_\_init\_\_ + +```python +def __init__(context=None) +``` + +Initialize the connections and bind to the ports + + + + +#### run + +```python +def run() +``` + +The main endless loop waiting for requests and forwarding the + +call request to the plugin module + + diff --git a/documentation/developers/known-issues.md b/documentation/developers/known-issues.md index 817298c60..db3429bcc 100644 --- a/documentation/developers/known-issues.md +++ b/documentation/developers/known-issues.md @@ -16,6 +16,8 @@ RUN cd ${HOME} && mkdir ${ZMQ_TMP_DIR} && cd ${ZMQ_TMP_DIR}; \ make && make install ``` +[libzmq details](./libzmq.md) + ## Configuration In `jukebox.yaml` (and all other config files): diff --git a/documentation/developers/status.md b/documentation/developers/status.md index 48c2b6c3b..0a40f8125 100644 --- a/documentation/developers/status.md +++ b/documentation/developers/status.md @@ -6,7 +6,7 @@ There are a few things that are specifically not integrated yet: playing streams In the following is the currently implemented feature list in more detail. It also shows some of the shortcomings. However, the list is _not complete in terms of planned features_, but probably _reflects more of where work is currently being put into_. -**For new contributors:** If you want to port a feature from version 2.X or implement a new feature, contact us. Open an issue or join us in the chat room. You may pick topics marked as open below, but also any other topic missing in the list below. As mentioned, that list is not complete in terms of open features. Check the [Contribution guide](https://github.com/MiczFlor/RPi-Jukebox-RFID/blob/future3/main/CONTRIBUTING.md). +**For new contributors:** If you want to port a feature from version 2.X or implement a new feature, contact us. Open an issue or join us in the chat room. You may pick topics marked as open below, but also any other topic missing in the list below. As mentioned, that list is not complete in terms of open features. Check the [Contribution guide](../../CONTRIBUTING.md). Topics marked _in progress_ are already in the process of implementation by community members. diff --git a/documentation/developers/webapp.md b/documentation/developers/webapp.md new file mode 100644 index 000000000..2e8504337 --- /dev/null +++ b/documentation/developers/webapp.md @@ -0,0 +1,149 @@ +# Web App + +The Web App sources are located in `src/webapp`. A pre-build bundle of the Web App is deployed when installing from an official release branch. If you install from a feature branch or a fork repository, the Web App needs to be built locally. This requires Node to be installed and is part of the installation process. + +## Install node manually + +If you installed from an official release branch, Node might not be installed. To install Node for local development, follow the [official setup](https://deb.nodesource.com/). + +``` bash +NODE_MAJOR=20 +sudo apt-get -y update && sudo apt-get -y install ca-certificates curl gnupg +sudo mkdir -p /etc/apt/keyrings +curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg +echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list +sudo apt-get -y update && sudo apt-get -y install nodejs +``` + +## Develop the Web App + +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: + +``` +cd ~/RPi-Jukebox-RFID/src/webapp +npm install # Just the first time or when dependencies change +npm start +``` + +## Build the Web App + +To build your Web App after its source code has changed (e.g. through a local change or through a pull from the repository), it needs to be rebuilt manually. +Use the provided script to rebuild whenever required. The artifacts can be found in the folder `build`. + +```bash +cd ~/RPi-Jukebox-RFID/src/webapp; \ +./run_rebuild.sh -u +``` + +After a successfull build you might need to restart the web server. + +``` +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: + +``` {.bash emphasize-lines="12"} +> webapp@0.1.0 build +> react-scripts build + +Creating an optimized production build... + +[...] + +<--- JS stacktrace ---> + +FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory +``` + +#### Reason + +Not enough memory for Node + +#### Solution + +Use the [provided script](#build-the-web-app) to rebuild the Web App. It sets the needed node options and also checks and adjusts the swap size if there is not enough memory available. + +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 +sudo sed -i "s|^\s*CONF_SWAPFACTOR=|#CONF_SWAPFACTOR=|g" /etc/dphys-swapfile +sudo dphys-swapfile setup +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 +``` + +### Process exited too early // kill -9 + +``` {.bash emphasize-lines="8,9"} +> webapp@0.1.0 build +> react-scripts build + +[...] + +The build failed because the process exited too early. +This probably means the system ran out of memory or someone called 'kill -9' on the process. +``` + +#### Reason + +Node tried to allocate more memory than available on the system. + +#### Solution + +See [JavaScript heap out of memory](#javascript-heap-out-of-memory) + + +### Client network socket disconnected + +``` {.bash emphasize-lines="8,9"} +[...] + +npm ERR! code ECONNRESET +npm ERR! network Client network socket disconnected before secure TLS connection was established +npm ERR! network This is a problem related to network connectivity. +npm ERR! network In most cases you are behind a proxy or have bad network settings. +npm ERR! network +npm ERR! network If you are behind a proxy, please make sure that the +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. + +#### 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). + +If the error still persists, try to raise the timeout for npm package resolution. + +1. Open the npm config file in an editor +1. Increase the `fetch-retry-*` values by '30000' (30 seconds) and save +1. Retry the build + +### Build produces EOF errors + +#### Reason + +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/installation/components/setup_hifiberry.sh b/installation/components/setup_hifiberry.sh new file mode 100755 index 000000000..dc2da8d6e --- /dev/null +++ b/installation/components/setup_hifiberry.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash + +# This script follows the official HiFiBerry documentation +# https://www.hifiberry.com/docs/software/configuring-linux-3-18-x/ + +source ../includes/02_helpers.sh + +script_name=$(basename "$0") +boot_config_path=$(get_boot_config_path) + +declare -A hifiberry_map=( + ["hifiberry-dac"]="DAC (HiFiBerry MiniAmp, I2S PCM5102A DAC)" + ["hifiberry-dacplus"]="HiFiBerry DAC+ Standard/Pro/Amp2" + ["hifiberry-dacplushd"]="HiFiBerry DAC2 HD" + ["hifiberry-dacplusadc"]="HiFiBerry DAC+ ADC" + ["hifiberry-dacplusadcpro"]="HiFiBerry DAC+ ADC Pro" + ["hifiberry-digi"]="HiFiBerry Digi+" + ["hifiberry-digi-pro"]="HiFiBerry Digi+ Pro" + ["hifiberry-amp"]="HiFiBerry Amp+ (not Amp2)" + ["hifiberry-amp3"]="HiFiBerry Amp3" +) + +example_usage() { + for key in "${!hifiberry_map[@]}"; do + description="${hifiberry_map[$key]}" + echo "$key) $description" + done + echo "Example usage: ./${script_name} enable hifiberry-dac" +} + +enable_hifiberry() { + echo "Enabling HiFiBerry board..." + grep -qxF "^dtoverlay=$1" "$boot_config_path" || echo "dtoverlay=$1" | sudo tee -a "$boot_config_path" > /dev/null + ./../options/onboard_sound.sh disable +} + +disable_hifiberry() { + echo "Removing existing HiFiBerry configuration..." + sudo sed -i '/^dtoverlay=hifiberry-/d' "$boot_config_path" + ./../options/onboard_sound.sh enable +} + +check_existing_hifiberry() { + existing_config=$(grep '^dtoverlay=hifiberry-' "$boot_config_path") + if [ ! -z "$existing_config" ]; then + if [ "$1" = "silent" ]; then + disable_hifiberry + return 0 + fi + + echo "Existing HiFiBerry configuration detected: $existing_config" + read -p "Do you want to proceed with a new configuration? This will remove the existing one. (Y/n): " choice + case $choice in + [nN][oO]|[nN]) + echo "Exiting without making changes."; + exit;; + *) + disable_hifiberry; + return 0;; + esac + fi +} + +# 1-line installation +if [ $# -ge 1 ]; then + if [[ "$1" != "enable" && "$1" != "disable" ]] || [[ "$1" == "enable" && -z "$2" ]]; then + echo "Error: Invalid arguments provided. +Usage: ./${script_name} +where can be 'enable' or 'disable'. + +The following board options exist:" + example_usage + exit 1 + fi + + if [ "$1" == "enable" ]; then + if [[ -v hifiberry_map["$2"] ]]; then + check_existing_hifiberry "silent" + enable_hifiberry "$2" + exit 1 + fi + + echo "'$2' is not a valid option. You can choose from:" + example_usage + exit 1 + fi + + disable_hifiberry + exit 1 +fi + +# Guided installation +board_count=${#hifiberry_map[@]} +counter=1 + +echo "Select your HiFiBerry board:" +for key in "${!hifiberry_map[@]}"; do + description="${hifiberry_map[$key]}" + echo "$counter) $description" + ((counter++)) +done +echo "0) Remove existing HiFiBerry configuration" + +read -p "Enter your choice (0-$board_count): " choice +case $choice in + [0]) + disable_hifiberry; + ;; + [1-$board_count]) + selected_board=$(get_key_by_item_number hifiberry_map "$choice") + check_existing_hifiberry + enable_hifiberry "$selected_board"; + ;; + *) + echo "Invalid selection. Exiting."; + exit 1;; +esac + +echo "Configuration complete. Please restart your device." diff --git a/installation/includes/00_constants.sh b/installation/includes/00_constants.sh index 380e1de2e..89299989c 100644 --- a/installation/includes/00_constants.sh +++ b/installation/includes/00_constants.sh @@ -16,4 +16,7 @@ GIT_BRANCH_DEVELOP=${GIT_BRANCH_DEVELOP:-future3/develop} # This message will be displayed at the end of the installation process # Functions wanting to have something important printed at the end should APPEND to this variable +# example: +# local tmp_fin_message="A Message" +# FIN_MESSAGE="${FIN_MESSAGE:+$FIN_MESSAGE\n}${tmp_fin_message}" FIN_MESSAGE="" diff --git a/installation/includes/01_default_config.sh b/installation/includes/01_default_config.sh index fa1bafb61..ec7b67b66 100644 --- a/installation/includes/01_default_config.sh +++ b/installation/includes/01_default_config.sh @@ -26,8 +26,6 @@ GIT_USE_SSH=${GIT_USE_SSH:-"true"} # For non-production builds, the Wep App must be build locally # Valid values # - release-only: download in release branch only -# - true: force download even in non-release branch, +# - true: force download even in non-release branch # - false: never download ENABLE_WEBAPP_PROD_DOWNLOAD=${ENABLE_WEBAPP_PROD_DOWNLOAD:-"release-only"} -# Install Node during setup for Web App building. This is only needed for development builds -ENABLE_INSTALL_NODE=${ENABLE_INSTALL_NODE:-"false"} diff --git a/installation/includes/02_helpers.sh b/installation/includes/02_helpers.sh index 8c1ffe982..73ed7fffe 100644 --- a/installation/includes/02_helpers.sh +++ b/installation/includes/02_helpers.sh @@ -2,6 +2,32 @@ ### Helpers +show_slow_hardware_message() { + if [[ $(uname -m) == "armv6l" ]]; then + print_c "-------------------------------------------------------------------- +| Your hardware is a little slower so this will take a while. | +| Go watch a movie but don't let your computer go to sleep for the | +| SSH connection to remain intact. | +-------------------------------------------------------------------- +" + fi +} + +# Get key by item number of associated array +get_key_by_item_number() { + local -n array="$1" + local item_number="$2" + local count=0 + + for key in "${!array[@]}"; do + ((count++)) + if [ "$count" -eq "$item_number" ]; then + echo "$key" + return + fi + done +} + # $1->start, $2->end calc_runtime_and_print() { runtime=$(($2-$1)) @@ -35,18 +61,49 @@ run_with_log_frame() { } get_architecture() { - local arch="" - if [ "$(uname -m)" = "armv7l" ]; then - arch="armv7" - elif [ "$(uname -m)" = "armv6l" ]; then - arch="armv6" - elif [ "$(uname -m)" = "aarch64" ]; then - arch="arm64" - else - arch="$(uname -m)" - fi - - echo $arch + local arch="" + if [ "$(uname -m)" = "armv7l" ]; then + arch="armv7" + elif [ "$(uname -m)" = "armv6l" ]; then + arch="armv6" + elif [ "$(uname -m)" = "aarch64" ]; then + arch="arm64" + else + arch="$(uname -m)" + fi + + echo $arch +} + +is_raspian() { + 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_config_path() { + if [ "$(is_raspian)" = true ]; then + local debian_version_number=$(get_debian_version_number) + + # Bullseye and lower + if [ "$debian_version_number" -le 11 ]; then + echo "/boot/config.txt" + # Bookworm and higher + elif [ "$debian_version_number" -ge 12 ]; then + echo "/boot/firmware/config.txt" + else + echo "unknown" + fi + else + echo "unknown" + fi } validate_url() { diff --git a/installation/includes/03_welcome.sh b/installation/includes/03_welcome.sh index 5b3ee84be..62c4910c8 100644 --- a/installation/includes/03_welcome.sh +++ b/installation/includes/03_welcome.sh @@ -16,16 +16,16 @@ You are turning your Raspberry Pi into a Phoniebox. Good choice! Depending on your hardware, this installation might last -around 60 minutes (usually it's faster). It updates OS -packages, installs Phoniebox dependencies and registers -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. +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. -By the way, you can follow the installation details here -in a separate SSH session: +To follow the installation closely, use this command +in another terminal. cd; tail -f ${INSTALLATION_LOGFILE} Let's set up your Phoniebox. diff --git a/installation/includes/05_finish.sh b/installation/includes/05_finish.sh index 22ba6ae80..c48fc31d2 100644 --- a/installation/includes/05_finish.sh +++ b/installation/includes/05_finish.sh @@ -11,7 +11,7 @@ ${FIN_MESSAGE} In order to start, you need to reboot your Raspberry Pi. Your SSH connection will disconnect. -After the reboot, you can access the WebApp in your browser at +After the reboot, you can access the Web App in your browser at http://${local_hostname}.local or http://${CURRENT_IP_ADDRESS} Don't forget to upload files. " diff --git a/installation/options/onboard_sound.sh b/installation/options/onboard_sound.sh new file mode 100755 index 000000000..475a9161d --- /dev/null +++ b/installation/options/onboard_sound.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +source ../includes/02_helpers.sh +script_name=$(basename "$0") +boot_config_path=$(get_boot_config_path) + +if [ -z "$1" ] || { [ "$1" != "enable" ] && [ "$1" != "disable" ]; }; then + echo "Error: Invalid or no argument provided. +Usage: ./${script_name} + where can be 'enable' or 'disable'" + exit 1 +fi + +arg="$1" + +if [ "$arg" = "enable" ]; then + echo "Enabling Onboard Sound..." + sudo sed -i "s/^\(dtparam=\([^,]*,\)*\)audio=\(off\|false\|no\|0\)\(.*\)/\1audio=on\4/g" "$boot_config_path" + sudo sed -i '/^dtoverlay=vc4-fkms-v3d/{s/,audio=off//g;}' "$boot_config_path" + sudo sed -i '/^dtoverlay=vc4-kms-v3d/{s/,noaudio//g;}' "$boot_config_path" +elif [ "$arg" = "disable" ]; then + echo "Disabling Onboard Sound..." + sudo sed -i "s/^\(dtparam=\([^,]*,\)*\)audio=\(on\|true\|yes\|1\)\(.*\)/\1audio=off\4/g" "$boot_config_path" + sudo sed -i '/^dtoverlay=vc4-fkms-v3d/{s/,audio=off//g;s/$/,audio=off/g;}' "$boot_config_path" + sudo sed -i '/^dtoverlay=vc4-kms-v3d/{s/,noaudio//g;s/$/,noaudio/g;}' "$boot_config_path" +fi + +# TODO Test diff --git a/installation/routines/customize_options.sh b/installation/routines/customize_options.sh index 74fbb5dc8..31c69ceae 100644 --- a/installation/routines/customize_options.sh +++ b/installation/routines/customize_options.sh @@ -189,12 +189,12 @@ Do you want to install Samba? [Y/n]" _option_webapp() { # ENABLE_WEBAPP clear_c - print_c "------------------------ WEBAPP ------------------------- + print_c "------------------------ WEB APP ------------------------ This is only required if you want to use a graphical interface to manage your Phoniebox! -Would you like to install the web application? [Y/n]" +Would you like to install the Web App? [Y/n]" read -r response case "$response" in [nN][oO]|[nN]) @@ -213,7 +213,7 @@ _option_kiosk_mode() { print_c "----------------------- KIOSK MODE ---------------------- If you have a screen attached to your RPi, -this will launch the web application right after boot. +this will launch the Web App right after boot. It will only install the necessary xserver dependencies and not the entire RPi desktop environment. @@ -282,48 +282,38 @@ Disable Pi's on-chip audio (headphone / jack output)? [y/N]" _option_webapp_devel_build() { # Let's detect if we are on the official release branch - if [[ "$GIT_BRANCH" != "${GIT_BRANCH_RELEASE}" || "$GIT_USER" != "$GIT_UPSTREAM_USER" || "$CI_RUNNING" == "true" ]]; then - ENABLE_INSTALL_NODE=true + if [[ "$GIT_BRANCH" != "${GIT_BRANCH_RELEASE}" && "$GIT_BRANCH" != "${GIT_BRANCH_DEVELOP}" ]] || [[ "$GIT_USER" != "$GIT_UPSTREAM_USER" ]] || [[ "$CI_RUNNING" == "true" ]] ; then # Unless ENABLE_WEBAPP_PROD_DOWNLOAD is forced to true by user override, do not download a potentially stale build if [[ "$ENABLE_WEBAPP_PROD_DOWNLOAD" == "release-only" ]]; then ENABLE_WEBAPP_PROD_DOWNLOAD=false fi - if [[ "$ENABLE_WEBAPP_PROD_DOWNLOAD" == false ]]; then + if [[ "$ENABLE_WEBAPP_PROD_DOWNLOAD" != true && "$ENABLE_WEBAPP_PROD_DOWNLOAD" != "release-only" ]]; then clear_c - print_c "--------------------- WEBAPP NODE --------------------- + print_c "--------------------- WEB APP BUILD --------------------- -You are installing from an unofficial branch. -Therefore a prebuilt web app is not available and -you will have to build it locally. +You are installing from a non-release branch +and/or an unofficial repository. +Therefore a pre-build Web App is not available +and it needs to be built locally. This requires Node to be installed. -You can choose to decline the Node installation and -the lastest prebuilt version from the main repository -will be installed. This can lead to incompatibilities. +If you decline, the lastest pre-build version +from the official repository will be installed. +This can lead to incompatibilities. -Do you want to install Node? [Y/n]" +Do you want to build the Web App? [Y/n]" read -r response case "$response" in [nN][oO]|[nN]) - ENABLE_INSTALL_NODE=false - ENABLE_WEBAPP_PROD_DOWNLOAD=true - ;; + ENABLE_WEBAPP_PROD_DOWNLOAD=true + ;; *) - # This message will be displayed at the end of the installation process - local tmp_fin_message="ATTENTION: You need to build the web app locally with - $ cd ~/RPi-Jukebox-RFID/src/webapp && ./run_rebuild.sh -u - This must be done after reboot, due to memory restrictions. - Read the documentation regarding local Web App builds!" - FIN_MESSAGE="${FIN_MESSAGE:+$FIN_MESSAGE\n}${tmp_fin_message}" - ;; + ;; esac fi fi - log "ENABLE_INSTALL_NODE=${ENABLE_INSTALL_NODE}" - if [ "$ENABLE_INSTALL_NODE" != true ]; then - log "ENABLE_WEBAPP_PROD_DOWNLOAD=${ENABLE_WEBAPP_PROD_DOWNLOAD}" - fi + log "ENABLE_WEBAPP_PROD_DOWNLOAD=${ENABLE_WEBAPP_PROD_DOWNLOAD}" } _run_customize_options() { diff --git a/installation/routines/install.sh b/installation/routines/install.sh index 62d602f17..f1f2a5f80 100644 --- a/installation/routines/install.sh +++ b/installation/routines/install.sh @@ -2,6 +2,7 @@ install() { clear_c customize_options clear_c + show_slow_hardware_message set_raspi_config set_ssh_qos update_raspi_os diff --git a/installation/routines/setup_jukebox_core.sh b/installation/routines/setup_jukebox_core.sh index 1c524abb0..d9c06b937 100644 --- a/installation/routines/setup_jukebox_core.sh +++ b/installation/routines/setup_jukebox_core.sh @@ -8,14 +8,6 @@ JUKEBOX_ZMQ_VERSION="4.3.5" JUKEBOX_PULSE_CONFIG="${HOME_PATH}"/.config/pulse/default.pa JUKEBOX_SERVICE_NAME="${SYSTEMD_USR_PATH}/jukebox-daemon.service" -_show_slow_hardware_message() { - print_c " -------------------------------------------------------------------- - | Your hardware is a little slower so this step will take a while. | - | Go watch a movie but don't let your computer go to sleep for the | - | SSH connection to remain intact. | - --------------------------------------------------------------------" -} - # Functions _jukebox_core_install_os_dependencies() { print_lc " Install Jukebox OS dependencies" @@ -86,11 +78,6 @@ _jukebox_core_build_and_install_pyzmq() { print_lc " Install pyzmq with libzmq-drafts to support WebSockets" if ! pip list | grep -F pyzmq >> /dev/null; then - - if [[ $(uname -m) == "armv6l" ]]; then - _show_slow_hardware_message - fi - mkdir -p "${JUKEBOX_ZMQ_TMP_DIR}" || exit_on_error if [ "$BUILD_LIBZMQ_WITH_DRAFTS_ON_DEVICE" = true ] ; then _jukebox_core_build_libzmq_with_drafts diff --git a/installation/routines/setup_jukebox_webapp.sh b/installation/routines/setup_jukebox_webapp.sh index 2884f18cb..7fcbce7ff 100644 --- a/installation/routines/setup_jukebox_webapp.sh +++ b/installation/routines/setup_jukebox_webapp.sh @@ -3,58 +3,87 @@ # Constants WEBAPP_NGINX_SITE_DEFAULT_CONF="/etc/nginx/sites-available/default" -# For ARMv7+ +# Node major version used. +# If changed also update in .github\actions\build-webapp\action.yml NODE_MAJOR=20 -# For ARMv6 -# To update version, follow these links -# https://github.com/sdesalas/node-pi-zero -# https://github.com/nodejs/unofficial-builds/ -NODE_SOURCE_EXPERIMENTAL="https://raw.githubusercontent.com/sdesalas/node-pi-zero/master/install-node-v16.3.0.sh" +# Node version for ARMv6 (unofficial builds) +NODE_ARMv6_VERSION=v20.10.0 -_jukebox_webapp_install_node() { - sudo apt-get -y update +OPTIONAL_WEBAPP_BUILD_FAILED=false - if which node > /dev/null; then - print_lc " Found existing NodeJS. Hence, updating NodeJS" - sudo npm cache clean -f - sudo npm install --silent -g n - sudo n --quiet latest - sudo npm update --silent -g - else +_jukebox_webapp_install_node() { print_lc " Install NodeJS" - # Zero and older versions of Pi with ARMv6 only - # support experimental NodeJS - if [[ $(uname -m) == "armv6l" ]]; then - wget -O - ${NODE_SOURCE_EXPERIMENTAL} | sudo bash - sudo apt-get -qq -y install nodejs - sudo npm install --silent -g npm + local node_version_installed=$(node -v 2>/dev/null) + local arch=$(uname -m) + if [[ "$arch" == "armv6l" ]]; then + if [ "$node_version_installed" == "$NODE_ARMv6_VERSION" ]; then + print_lc " Skipping. NodeJS already installed" + else + # For ARMv6 unofficial build + # https://github.com/nodejs/unofficial-builds/ + local node_tmp_dir="${HOME_PATH}/node" + local node_install_dir=/usr/local/lib/nodejs + local node_filename="node-${NODE_ARMv6_VERSION}-linux-${arch}" + local node_tar_filename="${node_filename}.tar.gz" + node_download_url="https://unofficial-builds.nodejs.org/download/release/${NODE_ARMv6_VERSION}/${node_tar_filename}" + + mkdir -p "${node_tmp_dir}" && cd "${node_tmp_dir}" || exit_on_error + download_from_url ${node_download_url} ${node_tar_filename} + tar -xzf ${node_tar_filename} + rm -rf ${node_tar_filename} + + # see https://github.com/nodejs/help/wiki/Installation + # Remove existing symlinks + sudo unlink /usr/bin/node 2>/dev/null + sudo unlink /usr/bin/npm 2>/dev/null + sudo unlink /usr/bin/npx 2>/dev/null + + # Clear existing nodejs and copy new files + sudo rm -rf "${node_install_dir}" + sudo mv "${node_filename}" "${node_install_dir}" + + sudo ln -s "${node_install_dir}/bin/node" /usr/bin/node + sudo ln -s "${node_install_dir}/bin/npm" /usr/bin/npm + sudo ln -s "${node_install_dir}/bin/npx" /usr/bin/npx + + cd "${HOME_PATH}" || exit_on_error + rm -rf "${node_tmp_dir}" + fi else - # install NodeJS and npm as recommended in - # https://github.com/nodesource/distributions - sudo apt-get install -y ca-certificates curl gnupg - sudo mkdir -p /etc/apt/keyrings - curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg - echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list - sudo apt-get update - sudo apt-get install -y nodejs + if [[ "$node_version_installed" == "v${NODE_MAJOR}."* ]]; then + print_lc " Skipping. NodeJS already installed" + else + sudo apt-get -y remove nodejs + # install NodeJS as recommended in + # https://deb.nodesource.com/ + sudo apt-get -y update && sudo apt-get -y install ca-certificates curl gnupg + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list + sudo apt-get -y update && sudo apt-get -y install nodejs + fi fi - fi } -# TODO: Avoid building the app locally -# Instead implement a Github Action that prebuilds on commititung a git tag _jukebox_webapp_build() { - print_lc " Building web application" - cd "${INSTALLATION_PATH}/src/webapp" || exit_on_error - npm ci --prefer-offline --no-audit --production - rm -rf build - # The build wrapper script checks available memory on system and sets Node options accordingly - ./run_rebuild.sh + print_lc " Building Web App" + cd "${INSTALLATION_PATH}/src/webapp" || exit_on_error + if ! ./run_rebuild.sh -u ; then + print_lc " Web App build failed! + Follow instructions shown at the end of installation!" + OPTIONAL_WEBAPP_BUILD_FAILED=true + # This message will be displayed at the end of the installation process + local tmp_fin_message="ATTENTION: The build of the Web App failed during installation. + Please run the build manually with the following command + $ cd ~/RPi-Jukebox-RFID/src/webapp && ./run_rebuild.sh -u + Read the documentation regarding local Web App builds!" + FIN_MESSAGE="${FIN_MESSAGE:+$FIN_MESSAGE\n}${tmp_fin_message}" + fi } _jukebox_webapp_download() { - print_lc " Downloading web application" + print_lc " Downloading Web App" local jukebox_version=$(python "${INSTALLATION_PATH}/src/jukebox/jukebox/version.py") local git_head_hash=$(git -C "${INSTALLATION_PATH}" rev-parse --verify --quiet HEAD) local git_head_hash_short=${git_head_hash:0:10} @@ -67,11 +96,11 @@ _jukebox_webapp_download() { if validate_url ${download_url_commit} ; then log " DOWNLOAD_URL ${download_url_commit}" download_from_url ${download_url_commit} ${tar_filename} - elif [[ $ENABLE_WEBAPP_PROD_DOWNLOAD == true ]] && validate_url ${download_url_latest} ; then + elif [[ "$ENABLE_WEBAPP_PROD_DOWNLOAD" == true ]] && validate_url ${download_url_latest} ; then log " DOWNLOAD_URL ${download_url_latest}" download_from_url ${download_url_latest} ${tar_filename} else - exit_on_error "No prebuild webapp bundle found!" + exit_on_error "No prebuild Web App bundle found!" fi tar -xzf ${tar_filename} rm -f ${tar_filename} @@ -80,7 +109,7 @@ _jukebox_webapp_download() { _jukebox_webapp_register_as_system_service_with_nginx() { print_lc " Install and configure nginx" - sudo apt-get -qq -y update + sudo apt-get -y update sudo apt-get -y purge apache2 sudo apt-get -y install nginx @@ -97,11 +126,22 @@ _jukebox_webapp_register_as_system_service_with_nginx() { _jukebox_webapp_check() { print_verify_installation - if [[ $ENABLE_WEBAPP_PROD_DOWNLOAD == true || $ENABLE_WEBAPP_PROD_DOWNLOAD == release-only ]] ; then + if [[ "$ENABLE_WEBAPP_PROD_DOWNLOAD" == true || "$ENABLE_WEBAPP_PROD_DOWNLOAD" == "release-only" ]] ; then verify_dirs_exists "${INSTALLATION_PATH}/src/webapp/build" - fi - if [[ $ENABLE_INSTALL_NODE == true ]] ; then - verify_apt_packages nodejs + else + local arch=$(uname -m) + if [[ "$arch" == "armv6l" ]]; then + local node_version_installed=$(node -v 2>/dev/null) + log " Verify 'node' is installed" + test ! "${node_version_installed}" == "${NODE_ARMv6_VERSION}" && exit_on_error "ERROR: 'node' not in expected version: '${node_version_installed}' instead of '${NODE_ARMv6_VERSION}'!" + log " CHECK" + else + verify_apt_packages nodejs + fi + + if [[ "$OPTIONAL_WEBAPP_BUILD_FAILED" == false ]]; then + verify_dirs_exists "${INSTALLATION_PATH}/src/webapp/build" + fi fi verify_apt_packages nginx @@ -111,14 +151,11 @@ _jukebox_webapp_check() { } _run_setup_jukebox_webapp() { - if [[ $ENABLE_WEBAPP_PROD_DOWNLOAD == true || $ENABLE_WEBAPP_PROD_DOWNLOAD == release-only ]] ; then + if [[ "$ENABLE_WEBAPP_PROD_DOWNLOAD" == true || "$ENABLE_WEBAPP_PROD_DOWNLOAD" == "release-only" ]] ; then _jukebox_webapp_download - fi - if [[ $ENABLE_INSTALL_NODE == true ]] ; then + else _jukebox_webapp_install_node - # Local Web App build during installation does not work at the moment - # Needs to be done after reboot! There will be a message at the end of the installation process - # _jukebox_webapp_build + _jukebox_webapp_build fi _jukebox_webapp_register_as_system_service_with_nginx _jukebox_webapp_check @@ -126,6 +163,6 @@ _run_setup_jukebox_webapp() { setup_jukebox_webapp() { if [ "$ENABLE_WEBAPP" == true ] ; then - run_with_log_frame _run_setup_jukebox_webapp "Install web application" + run_with_log_frame _run_setup_jukebox_webapp "Install Web App" fi } diff --git a/pydoc-markdown.yml b/pydoc-markdown.yml new file mode 100644 index 000000000..62519e694 --- /dev/null +++ b/pydoc-markdown.yml @@ -0,0 +1,13 @@ +loaders: +- type: python + search_path: [./src/jukebox] +processors: + - type: filter +# skip_empty_modules: true # Uncommenting this skips also run_jukebox etc. + - type: sphinx + - type: crossref +renderer: + type: markdown + render_toc: true + filename: ./documentation/developers/docstring/README.md + render_page_title: true diff --git a/requirements.txt b/requirements.txt index ea9546315..c172a7636 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,3 +34,6 @@ flake8>=4.0.0 pytest pytest-cov mock + +# API docs generation +pydoc-markdown diff --git a/resources/default-settings/gpio.example.yaml b/resources/default-settings/gpio.example.yaml index f19b83c87..59793210f 100644 --- a/resources/default-settings/gpio.example.yaml +++ b/resources/default-settings/gpio.example.yaml @@ -1,6 +1,6 @@ # Provides a few selected examples of GPIO configuration # Check out the documentation for many more configuration recipes -# https://rpi-jukebox-rfid.readthedocs.io/en/latest/index.html +# documentation/builders/gpio.md pin_factory: type: rpigpio.RPiGPIOFactory output_devices: diff --git a/resources/html/404.html b/resources/html/404.html index 1a60d2415..a79decc6c 100755 --- a/resources/html/404.html +++ b/resources/html/404.html @@ -15,7 +15,7 @@

Ups! Requested file not found.

Why not try again from the top-level of

diff --git a/resources/html/runbuildui.html b/resources/html/runbuildui.html index 04f92c704..6ade7bb0a 100755 --- a/resources/html/runbuildui.html +++ b/resources/html/runbuildui.html @@ -12,11 +12,11 @@

Ups! Looks like your Web UI has not been build!

No reason to panic. Please run through the following steps:

    -
  • cd ~/RPi-Jukebox-RFID/src/webapp
  • -
  • ./run_rebuild.sh -u
  • -
  • Reload this page
  • +
  • cd ~/RPi-Jukebox-RFID/src/webapp

  • +
  • ./run_rebuild.sh -u

  • +
  • Reload this page

-

In case of trouble when building the Web UI, consult the documentation! +

In case of trouble when building the Web UI, consult the official documentation! diff --git a/run_docgeneration.sh b/run_docgeneration.sh new file mode 100755 index 000000000..22ab8bc14 --- /dev/null +++ b/run_docgeneration.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# Runner script for pydoc-markdown to ensure +# - independent from working directory + +# Change working directory to location of script +SOURCE=${BASH_SOURCE[0]} +SCRIPT_DIR="$(dirname "$SOURCE")" +cd "$SCRIPT_DIR" || (echo "Could not change to top-level project directory" && exit 1) + +# Run pydoc-markdown +# make sure, directory exists +mkdir -p ./documentation/developers/docstring +# expects pydoc-markdown.yml at working dir +pydoc-markdown 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 04459c9ea..af193a37f 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 @@ -35,42 +35,39 @@ class battmon_ads1015(BatteryMonitorBase.BattmonBase): - '''Battery Monitor based on a ADS1015 + """Battery Monitor based on a ADS1015 - CAUTION - WARNING - ======================================================================== - 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! - ========================================================================= + > [!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| '-' | | - | | | | - === === === === + 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 + * 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! - ''' + """ def __init__(self, cfg): super().__init__(cfg, logger) diff --git a/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py b/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py index 999a4f218..4d17f398e 100644 --- a/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py +++ b/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py @@ -4,9 +4,9 @@ This effectively does: - * register a callback with components.volume to get notified when a new sound card connects - * if that is a bluetooth device, try opening an input device with similar name using - * button listeners are run each in its own thread +* register a callback with components.volume to get notified when a new sound card connects +* if that is a bluetooth device, try opening an input device with similar name using +* button listeners are run each in its own thread """ import logging diff --git a/src/jukebox/components/controls/common/evdev_listener.py b/src/jukebox/components/controls/common/evdev_listener.py index 05b92005c..a4279afda 100644 --- a/src/jukebox/components/controls/common/evdev_listener.py +++ b/src/jukebox/components/controls/common/evdev_listener.py @@ -49,10 +49,8 @@ def _filter_by_device_name(all_devices: List[evdev.InputDevice], def find_device(device_name: str, exact_name: bool = True, mandatory_keys: Optional[Set[int]] = None) -> str: """Find an input device with device_name and mandatory keys. - Raises - - #. FileNotFoundError, if no device is found. - #. AttributeError, if device does not have the mandatory keys + :raise FileNotFoundError: if no device is found. + :raise AttributeError: if device does not have the mandatory key If multiple devices match, the first match is returned diff --git a/src/jukebox/components/gpio/gpioz/core/converter.py b/src/jukebox/components/gpio/gpioz/core/converter.py index ba9581113..849bc8e17 100644 --- a/src/jukebox/components/gpio/gpioz/core/converter.py +++ b/src/jukebox/components/gpio/gpioz/core/converter.py @@ -43,8 +43,6 @@ class VolumeToRGB: Map input :data:`0...100` to color range :data:`green...magenta` and get the color for level 50 - .. code-block:: python - conv = VolumeToRGB(100, offset=120, section=180) (r, g, b) = conv(50) diff --git a/src/jukebox/components/gpio/gpioz/core/input_devices.py b/src/jukebox/components/gpio/gpioz/core/input_devices.py index 5090762f4..bae049cc5 100644 --- a/src/jukebox/components/gpio/gpioz/core/input_devices.py +++ b/src/jukebox/components/gpio/gpioz/core/input_devices.py @@ -9,7 +9,8 @@ All callback handlers are replaced by GPIOZ callback handlers. These are usually configured by using the :func:`set_rpc_actions` each input device exhibits. -For examples how to use the devices from the configuration files, see :ref:`userguide/gpioz:Input devices` +For examples how to use the devices from the configuration files, see +[GPIO: Input Devices](../../builders/gpio.md#input-devices). """ import functools @@ -75,7 +76,7 @@ def set_rpc_actions(self, action_config) -> None: Set all input device callbacks from :attr:`action_config` :param action_config: Dictionary with one - :ref:`RPC Command ` definition entry for every device callback + [RPC Commands](../../builders/rpc-commands.md) definition entry for every device callback """ pass @@ -233,11 +234,11 @@ class LongPressButton(NameMixin, ButtonBase): """ A Button that runs a single actions only when the button is pressed long enough - :param pull_up: See `Button`_ + :param pull_up: See #Button - :param active_state: See `Button`_ + :param active_state: See #Button - :param bounce_time: See `Button`_ + :param bounce_time: See #Button :param hold_repeat: If :data:`True` repeat the :attr:`on_press` every :attr:`hold_time` seconds. Else only action is run only once independent of the length of time the button is pressed for. @@ -291,11 +292,11 @@ class ShortLongPressButton(NameMixin, ButtonBase): event. Furthermore, if there is a long hold, only the long hold action is executed - the short press action is not run in this case! - :param pull_up: See `Button`_ + :param pull_up: See #Button - :param active_state: See `Button`_ + :param active_state: See #Button - :param bounce_time: See `Button`_ + :param bounce_time: See #Button :param hold_time: The time in seconds to differentiate if it is a short or long press. If the button is released before this time, it is a short press. As soon as the button is held for :attr:`hold_time` it is a long press and the @@ -304,9 +305,9 @@ class ShortLongPressButton(NameMixin, ButtonBase): :param hold_repeat: If :data:`True` repeat the long press action every :attr:`hold_time` seconds after first long press action - :param pin_factory: See `Button`_ + :param pin_factory: See #Button - :param name: See `Button`_ + :param name: See #Button """ def __init__( self, pin=None, *, pull_up=True, active_state=None, bounce_time=None, @@ -370,11 +371,11 @@ class RotaryEncoder(NameMixin): """ A rotary encoder to run one of two actions depending on the rotation direction. - :param bounce_time: See `Button`_ + :param bounce_time: See #Button - :param pin_factory: See `Button`_ + :param pin_factory: See #Button - :param name: See `Button`_ + :param name: See #Button """ def __init__(self, a, b, *, bounce_time=None, pin_factory=None, name=None): super().__init__(name=name) @@ -442,11 +443,11 @@ class TwinButton(NameMixin): It is not necessary to configure all actions. - :param pull_up: See `Button`_ + :param pull_up: See #Button - :param active_state: See `Button`_ + :param active_state: See #Button - :param bounce_time: See `Button`_ + :param bounce_time: See #Button :param hold_time: The time in seconds to differentiate if it is a short or long press. If the button is released before this time, it is a short press. As soon as the button is held for :attr:`hold_time` it is a long press and the @@ -455,9 +456,9 @@ class TwinButton(NameMixin): :param hold_repeat: If :data:`True` repeat the long press action every :attr:`hold_time` seconds after first long press action. A long dual press is never repeated independent of this setting - :param pin_factory: See `Button`_ + :param pin_factory: See #Button - :param name: See `Button`_ + :param name: See #Button """ class StateVar(Enum): diff --git a/src/jukebox/components/gpio/gpioz/core/mock.py b/src/jukebox/components/gpio/gpioz/core/mock.py index bccd5e0e1..ae2e49e15 100644 --- a/src/jukebox/components/gpio/gpioz/core/mock.py +++ b/src/jukebox/components/gpio/gpioz/core/mock.py @@ -19,7 +19,8 @@ def patch_mock_outputs_with_callback(): This targets to represent the state in the TK GUI. Other output devices cannot be represented in the GUI and are silently ignored. - ..note:: Only for developing purposes!""" + > [!NOTE] + > Only for developing purposes!""" gpiozero.LED._write_orig = gpiozero.LED._write gpiozero.LED._write = rewrite gpiozero.LED.on_change_callback = None diff --git a/src/jukebox/components/gpio/gpioz/core/output_devices.py b/src/jukebox/components/gpio/gpioz/core/output_devices.py index 50949f82b..78f1d23da 100644 --- a/src/jukebox/components/gpio/gpioz/core/output_devices.py +++ b/src/jukebox/components/gpio/gpioz/core/output_devices.py @@ -11,7 +11,8 @@ with parameters for this device and optional parameters from another device. Unused/unsupported parameters are silently ignored. This is done to reduce the amount of coding required for connectivity functions. -For examples how to use the devices from the configuration files, see :ref:`userguide/gpioz:Output devices` +For examples how to use the devices from the configuration files, see +[GPIO: Output Devices](../../builders/gpio.md#output-devices). """ from typing import Optional, List diff --git a/src/jukebox/components/gpio/gpioz/plugin/__init__.py b/src/jukebox/components/gpio/gpioz/plugin/__init__.py index 9bc151e55..6fc9ab973 100644 --- a/src/jukebox/components/gpio/gpioz/plugin/__init__.py +++ b/src/jukebox/components/gpio/gpioz/plugin/__init__.py @@ -56,15 +56,15 @@ class ServiceIsRunningCallbacks(CallbackHandler): """ Callbacks are executed when - * Jukebox app started - * Jukebox shuts down + * Jukebox app started + * Jukebox shuts down This is intended to e.g. signal an LED to change state. This is integrated into this module because: - * we need the GPIO to control a LED (it must be available when the status callback comes) - * the plugin callback functions provide all the functionality to control the status of the LED - * which means no need to adapt other modules + * we need the GPIO to control a LED (it must be available when the status callback comes) + * the plugin callback functions provide all the functionality to control the status of the LED + * which means no need to adapt other modules """ def register(self, func: Callable[[int], None]): @@ -76,7 +76,7 @@ def register(self, func: Callable[[int], None]): .. py:function:: func(status: int) :noindex: - :param status: 1 if app started, 0 if app shuts down + :param status: 1 if app started, 0 if app shuts down """ super().register(func) diff --git a/src/jukebox/components/gpio/gpioz/plugin/connectivity.py b/src/jukebox/components/gpio/gpioz/plugin/connectivity.py index 3e5baea2d..abbcb1a32 100644 --- a/src/jukebox/components/gpio/gpioz/plugin/connectivity.py +++ b/src/jukebox/components/gpio/gpioz/plugin/connectivity.py @@ -55,11 +55,11 @@ def register_rfid_callback(device): Compatible devices: - - :class:`components.gpio.gpioz.core.output_devices.LED` - - :class:`components.gpio.gpioz.core.output_devices.PWMLED` - - :class:`components.gpio.gpioz.core.output_devices.RGBLED` - - :class:`components.gpio.gpioz.core.output_devices.Buzzer` - - :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` + * :class:`components.gpio.gpioz.core.output_devices.LED` + * :class:`components.gpio.gpioz.core.output_devices.PWMLED` + * :class:`components.gpio.gpioz.core.output_devices.RGBLED` + * :class:`components.gpio.gpioz.core.output_devices.Buzzer` + * :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` """ def rfid_callback(card_id: str, state: RfidCardDetectState): @@ -78,9 +78,9 @@ def register_status_led_callback(device): Compatible devices: - - :class:`components.gpio.gpioz.core.output_devices.LED` - - :class:`components.gpio.gpioz.core.output_devices.PWMLED` - - :class:`components.gpio.gpioz.core.output_devices.RGBLED` + * :class:`components.gpio.gpioz.core.output_devices.LED` + * :class:`components.gpio.gpioz.core.output_devices.PWMLED` + * :class:`components.gpio.gpioz.core.output_devices.RGBLED` """ def set_status_led(state): @@ -101,8 +101,8 @@ def register_status_buzzer_callback(device): Compatible devices: - - :class:`components.gpio.gpioz.core.output_devices.Buzzer` - - :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` + * :class:`components.gpio.gpioz.core.output_devices.Buzzer` + * :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` """ def set_status_buzzer(state): @@ -121,7 +121,7 @@ def register_status_tonalbuzzer_callback(device): Compatible devices: - - :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` + * :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` """ def set_status_buzzer(state): @@ -143,9 +143,9 @@ def register_audio_sink_change_callback(device): Compatible devices: - - :class:`components.gpio.gpioz.core.output_devices.LED` - - :class:`components.gpio.gpioz.core.output_devices.PWMLED` - - :class:`components.gpio.gpioz.core.output_devices.RGBLED` + * :class:`components.gpio.gpioz.core.output_devices.LED` + * :class:`components.gpio.gpioz.core.output_devices.PWMLED` + * :class:`components.gpio.gpioz.core.output_devices.RGBLED` """ def audio_sink_change_callback(alias, sink_name, sink_index, error_state): @@ -167,7 +167,7 @@ def register_volume_led_callback(device): Compatible devices: - - :class:`components.gpio.gpioz.core.output_devices.PWMLED` + * :class:`components.gpio.gpioz.core.output_devices.PWMLED` """ def audio_volume_change_callback(volume, is_min, is_max): @@ -191,8 +191,8 @@ def register_volume_buzzer_callback(device): Compatible devices: - - :class:`components.gpio.gpioz.core.output_devices.Buzzer` - - :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` + * :class:`components.gpio.gpioz.core.output_devices.Buzzer` + * :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` """ def set_volume_buzzer(volume, is_min, is_max): @@ -210,7 +210,7 @@ def register_volume_rgbled_callback(device): Compatible devices: - - :class:`components.gpio.gpioz.core.output_devices.RGBLED` + * :class:`components.gpio.gpioz.core.output_devices.RGBLED` """ volume_to_rgb = VolumeToRGB(100, 120, 180) diff --git a/src/jukebox/components/hostif/linux/__init__.py b/src/jukebox/components/hostif/linux/__init__.py index 582074413..32dfded08 100644 --- a/src/jukebox/components/hostif/linux/__init__.py +++ b/src/jukebox/components/hostif/linux/__init__.py @@ -103,7 +103,8 @@ def jukebox_is_service(): def is_any_jukebox_service_active(): """Check if a Jukebox service is running - .. note:: Does not have the be the current app, that is running as a service! + > [!NOTE] + > Does not have the be the current app, that is running as a service! """ ret = subprocess.run(["systemctl", "--user", "show", "jukebox-daemon", "--property", "ActiveState", "--value"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, diff --git a/src/jukebox/components/jingle/__init__.py b/src/jukebox/components/jingle/__init__.py index 43e55cd74..940015dd5 100644 --- a/src/jukebox/components/jingle/__init__.py +++ b/src/jukebox/components/jingle/__init__.py @@ -56,11 +56,12 @@ def initialize(): def play(filename): """Play the jingle using the configured jingle service - Note: This runs in a separate thread. And this may cause troubles - when changing the volume level before - and after the sound playback: There is nothing to prevent another - thread from changing the volume and sink while playback happens - and afterwards we change the volume back to where it was before! + > [!NOTE] + > This runs in a separate thread. And this may cause troubles + > when changing the volume level before + > and after the sound playback: There is nothing to prevent another + > thread from changing the volume and sink while playback happens + > and afterwards we change the volume back to where it was before! There is no way around this dilemma except for not running the jingle as a separate thread. Currently (as thread) even the RPC is started before the sound diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py index c77c21051..9073a9b4a 100644 --- a/src/jukebox/components/playermpd/__init__.py +++ b/src/jukebox/components/playermpd/__init__.py @@ -56,8 +56,9 @@ """ # noqa: E501 # Warum ist "Second Swipe" im Player und nicht im RFID Reader? # Second swipe ist abhängig vom Player State - nicht vom RFID state. -# Beispiel: RFID triggered Folder1, Webapp triggered Folder2, RFID Folder1: Dann muss das 2. Mal Folder1 auch als "first swipe" -# gewertet werden. Wenn der RFID das basierend auf IDs macht, kann der nicht unterscheiden und glaubt es ist 2. Swipe. +# Beispiel: RFID triggered Folder1, Web App triggered Folder2, RFID Folder1: +# Dann muss das 2. Mal Folder1 auch als "first swipe" gewertet werden. +# Wenn der RFID das basierend auf IDs macht, kann der nicht unterscheiden und glaubt es ist 2. Swipe. # Beispiel 2: Jemand hat RFID Reader (oder 1x RFID und 1x Barcode Scanner oder so) angeschlossen. Liest zuerst Karte mit # Reader 1 und dann mit Reader 2: Reader 2 weiß nicht, was bei Reader 1 passiert ist und denkt es ist 1. swipe. # Beispiel 3: RFID trigered Folder1, Playlist läuft durch und hat schon gestoppt, dann wird die Karte wieder vorgehalten. @@ -68,7 +69,7 @@ # # In der aktuellen Implementierung weiß der Player (der second "swipe" dekodiert) überhaupt nichts vom RFID. # Im Prinzip gibt es zwei "Play" Funktionen: (1) play always from start und (2) play with toggle action. -# Die Webapp ruft immer (1) auf und die RFID immer (2). Jetzt kann man sogar für einige Karten sagen +# Die Web App ruft immer (1) auf und die RFID immer (2). Jetzt kann man sogar für einige Karten sagen # immer (1) - also kein Second Swipe und für andere (2). # Sollte der Reader das Swcond swipe dekodieren, muss aber der Reader den Status des Player kennen. # Das ist allerdings ein Problem. In Version 2 ist das nicht aufgefallen, @@ -76,7 +77,7 @@ # # Beispiel: Second swipe bei anderen Funktionen, hier: WiFi on/off. # Was die Karte Action tut ist ein Toggle. Der Toggle hängt vom Wifi State ab, den der RFID Kartenleser nicht kennt. -# Den kann der Leser auch nicht tracken. Der State kann ja auch über die WebApp oder Kommandozeile geändert werden. +# Den kann der Leser auch nicht tracken. Der State kann ja auch über die Web App oder Kommandozeile geändert werden. # Toggle (und 2nd Swipe generell) ist immer vom Status des Zielsystems abhängig und kann damit nur vom Zielsystem geändert # werden. Bei Wifi also braucht man 3 Funktionen: on / off / toggle. Toggle ist dann first swipe / second swipe @@ -366,8 +367,9 @@ def replay_if_stopped(self): """ Re-start playing the last-played folder unless playlist is still playing - .. note:: To me this seems much like the behaviour of play, - but we keep it as it is specifically implemented in box 2.X""" + > [!NOTE] + > To me this seems much like the behaviour of play, + > but we keep it as it is specifically implemented in box 2.X""" with self.mpd_lock: if self.mpd_status['state'] == 'stop': self.play_folder(self.music_player_status['player_status']['last_played_folder']) diff --git a/src/jukebox/components/playermpd/playcontentcallback.py b/src/jukebox/components/playermpd/playcontentcallback.py index a60452a23..ce5a1b8fb 100644 --- a/src/jukebox/components/playermpd/playcontentcallback.py +++ b/src/jukebox/components/playermpd/playcontentcallback.py @@ -27,8 +27,8 @@ def register(self, func: Callable[[str, STATE], None]): .. py:function:: func(folder: str, state: STATE) :noindex: - :param folder: relativ path to folder to play - :param state: indicator of the state inside the calling + :param folder: relativ path to folder to play + :param state: indicator of the state inside the calling """ super().register(func) diff --git a/src/jukebox/components/rfid/hardware/template_new_reader/template_new_reader.py b/src/jukebox/components/rfid/hardware/template_new_reader/template_new_reader.py index b33399b0a..a4481efd6 100644 --- a/src/jukebox/components/rfid/hardware/template_new_reader/template_new_reader.py +++ b/src/jukebox/components/rfid/hardware/template_new_reader/template_new_reader.py @@ -48,7 +48,7 @@ class ReaderClass(ReaderBaseClass): All the required interfaces are implemented there. Put your code into these functions (see below for more information) - - __init__ + - `__init__` - read_card - cleanup - stop @@ -101,10 +101,11 @@ def stop(self): This function is called before cleanup is called. - .. note: This is usually called from a different thread than the reader's thread! And this is the reason for the - two-step exit strategy. This function works across threads to indicate to the reader that is should stop attempt - to read a card. Once called, the function read_card will not be called again. When the reader thread exits - cleanup is called from the reader thread itself. + > [!NOTE] + > This is usually called from a different thread than the reader's thread! And this is the reason for the + > two-step exit strategy. This function works across threads to indicate to the reader that is should stop attempt + > to read a card. Once called, the function read_card will not be called again. When the reader thread exits + > cleanup is called from the reader thread itself. """ self._keep_running = False diff --git a/src/jukebox/components/rfid/reader/__init__.py b/src/jukebox/components/rfid/reader/__init__.py index db0ccb1da..37d4a363d 100644 --- a/src/jukebox/components/rfid/reader/__init__.py +++ b/src/jukebox/components/rfid/reader/__init__.py @@ -41,8 +41,8 @@ def register(self, func: Callable[[str, RfidCardDetectState], None]): .. py:function:: func(card_id: str, state: int) :noindex: - :param card_id: Card ID - :param state: See :class:`RfidCardDetectState` + :param card_id: Card ID + :param state: See #RfidCardDetectState """ super().register(func) @@ -52,7 +52,7 @@ def run_callbacks(self, card_id: str, state: RfidCardDetectState): #: Callback handler instance for rfid_card_detect_callbacks events. -#: See :class:`RfidCardDetectCallbacks` +#: See #RfidCardDetectCallbacks rfid_card_detect_callbacks: RfidCardDetectCallbacks = RfidCardDetectCallbacks('rfid_card_detect_callbacks', log) diff --git a/src/jukebox/components/rpc_command_alias.py b/src/jukebox/components/rpc_command_alias.py index e56727ff4..f6e238559 100644 --- a/src/jukebox/components/rpc_command_alias.py +++ b/src/jukebox/components/rpc_command_alias.py @@ -1,7 +1,7 @@ """ This file provides definitions for RPC command aliases -See :ref:`userguide/rpc_commands` +See [RPC Commands](../../builders/rpc-commands.md) """ # -------------------------------------------------------------- diff --git a/src/jukebox/components/volume/__init__.py b/src/jukebox/components/volume/__init__.py index 6653baa77..ccc4873d7 100644 --- a/src/jukebox/components/volume/__init__.py +++ b/src/jukebox/components/volume/__init__.py @@ -2,33 +2,35 @@ # Copyright (c) See file LICENSE in project root folder """PulseAudio Volume Control Plugin Package -Features +## Features - * Volume Control - * Two outputs - * Watcher thread on volume / output change +* Volume Control +* Two outputs +* Watcher thread on volume / output change -Publishes +## Publishes - * volume.level - * volume.sink +* volume.level +* volume.sink -PulseAudio References +## PulseAudio References -https://brokkr.net/2018/05/24/down-the-drain-the-elusive-default-pulseaudio-sink/ + Check fallback device (on device de-connect): -$ pacmd list-sinks | grep -e 'name:' -e 'index' + $ pacmd list-sinks | grep -e 'name:' -e 'index' -Integration + +## Integration Pulse Audio runs as a user process. Processes who want to communicate / stream to it must also run as a user process. -This means must also run as user process, as described in :ref:`userguide/system:Music Player Daemon (MPD)` +This means must also run as user process, as described in +[Music Player Daemon](../../builders/system.md#music-player-daemon-mpd). -Misc +## Misc PulseAudio may switch the sink automatically to a connecting bluetooth device depending on the loaded module with name module-switch-on-connect. On RaspianOS Bullseye, this module is not part of the default configuration @@ -36,27 +38,25 @@ If the module gets loaded it conflicts with the toggle on connect and the selected primary / secondary outputs from the Jukebox. Remove it from the configuration! -.. code-block:: text - ### Use hot-plugged devices like Bluetooth or USB automatically (LP: #1702794) ### not available on PI? .ifexists module-switch-on-connect.so load-module module-switch-on-connect .endif -Why PulseAudio? +## Why PulseAudio? The audio configuration of the system is one of those topics, which has a myriad of options and possibilities. Every system is different and PulseAudio unifies these and makes our life easier. Besides, it is only option to support Bluetooth at the moment. -Callbacks: +## Callbacks: The following callbacks are provided. Register callbacks with these adder functions (see their documentation for details): - #. :func:`add_on_connect_callback` - #. :func:`add_on_output_change_callbacks` - #. :func:`add_on_volume_change_callback` +1. :func:`add_on_connect_callback` +2. :func:`add_on_output_change_callbacks` +3. :func:`add_on_volume_change_callback` """ import collections import logging @@ -116,10 +116,10 @@ def register(self, func: Callable[[str, str], None]): .. py:function:: func(card_driver: str, device_name: str) :noindex: - :param card_driver: The PulseAudio card driver module, - e.g. :data:`module-bluez5-device.c` or :data:`module-alsa-card.c` - :param device_name: The sound card device name as reported - in device properties + :param card_driver: The PulseAudio card driver module, + e.g. :data:`module-bluez5-device.c` or :data:`module-alsa-card.c` + :param device_name: The sound card device name as reported + in device properties """ super().register(func) @@ -140,7 +140,7 @@ def __init__(self): # For the callback handler: We use the context lock only explicitly for registering new functions # When the callbacks are run, it happens from inside the pulse_monitor which an already acquired lock #: Callback handler instance for on_connect_callbacks events. - #: See :class:`PulseMonitor.SoundCardConnectCallbacks` + #: See #PulseMonitor.SoundCardConnectCallbacks self.on_connect_callbacks: PulseMonitor.SoundCardConnectCallbacks = PulseMonitor.SoundCardConnectCallbacks( 'on_connect_callback', logger, context=self) @@ -149,10 +149,11 @@ def toggle_on_connect(self): """Returns :data:`True` if the sound card shall be changed when a new card connects/disconnects. Setting this property changes the behavior. - .. note:: A new card is always assumed to be the secondary device from the audio configuration. - At the moment there is no check it actually is the configured device. This means any new - device connection will initiate the toggle. This, however, is no real issue as the RPi's audio - system will be relatively stable once setup + > [!NOTE] + > A new card is always assumed to be the secondary device from the audio configuration. + > At the moment there is no check it actually is the configured device. This means any new + > device connection will initiate the toggle. This, however, is no real issue as the RPi's audio + > system will be relatively stable once setup """ return self._toggle_on_connect @@ -282,8 +283,6 @@ class PulseVolumeControl: When accessing the pulse library, it needs to be put into a special state. Which is ensured by the context manager - .. code-block: python - with pulse_monitor as pulse ... @@ -309,12 +308,12 @@ def register(self, func: Callable[[str, str, int, int], None]): .. py:function:: func(sink_name: str, alias: str, sink_index: int, error_state: int) :noindex: - :param sink_name: PulseAudio's sink name - :param alias: The alias for :attr:`sink_name` - :param sink_index: The index of the sink in the configuration list - :param error_state: 1 if there was an attempt to change the output - but an error occurred. Above parameters always give the now valid sink! - If a sink change is successful, it is 0. + :param sink_name: PulseAudio's sink name + :param alias: The alias for :attr:`sink_name` + :param sink_index: The index of the sink in the configuration list + :param error_state: 1 if there was an attempt to change the output + but an error occurred. Above parameters always give the now valid sink! + If a sink change is successful, it is 0. """ super().register(func) @@ -338,9 +337,9 @@ def register(self, func: Callable[[int, bool, bool], None]): .. py:function:: func(volume: int, is_min: bool, is_max: bool) :noindex: - :param volume: Volume level - :param is_min: 1, if volume level is minimum, else 0 - :param is_max: 1, if volume level is maximum, else 0 + :param volume: Volume level + :param is_min: 1, if volume level is minimum, else 0 + :param is_max: 1, if volume level is maximum, else 0 """ super().register(func) @@ -359,12 +358,12 @@ def __init__(self, sink_list: List[PulseAudioSinkClass]): # When the callbacks are run, it happens from inside the pulse_control which an already acquired lock #: Callback handler instance for on_output_change_callbacks events. - #: See :class:`PulseVolumeControl.OutputChangeCallbackHandler` + #: See #PulseVolumeControl.OutputChangeCallbackHandler self.on_output_change_callbacks = PulseVolumeControl.OutputChangeCallbackHandler( 'on_output_change_callbacks', logger, context=pulse_monitor) #: Callback handler instance for on_output_change_callbacks events. - #: See :class:`PulseVolumeControl.OutputVolumeCallbackHandler` + #: See #PulseVolumeControl.OutputVolumeCallbackHandler self.on_volume_change_callbacks = PulseVolumeControl.OutputVolumeCallbackHandler( 'on_volume_change_callbacks', logger, context=pulse_monitor) diff --git a/src/jukebox/jukebox/cfghandler.py b/src/jukebox/jukebox/cfghandler.py index 3482a1b42..8108f1d33 100644 --- a/src/jukebox/jukebox/cfghandler.py +++ b/src/jukebox/jukebox/cfghandler.py @@ -236,8 +236,9 @@ def is_modified(self) -> bool: """ Check if the data has changed since the last load/store - .. note: This relies on the *__str__* representation of the underlying data structure - In case of ruamel, this ignores comments and only looks at the data + > [!NOTE] + > This relies on the *__str__* representation of the underlying data structure + > In case of ruamel, this ignores comments and only looks at the data """ with self._lock: is_modified_value = self._hash != hashlib.md5(self._data.__str__().encode('utf8')).digest() diff --git a/src/jukebox/jukebox/playlistgenerator.py b/src/jukebox/jukebox/playlistgenerator.py index db64d3eff..b9f0223c6 100755 --- a/src/jukebox/jukebox/playlistgenerator.py +++ b/src/jukebox/jukebox/playlistgenerator.py @@ -12,8 +12,6 @@ An directory may contain a mixed set of files and multiple ``*.txt`` files, e.g. -.. code-block:: bash - 01-livestream.txt 02-livestream.txt music.mp3 diff --git a/src/jukebox/jukebox/plugs.py b/src/jukebox/jukebox/plugs.py index afad5da28..5e4a95f21 100644 --- a/src/jukebox/jukebox/plugs.py +++ b/src/jukebox/jukebox/plugs.py @@ -14,39 +14,39 @@ If you want to provide additional functionality to the same feature (probably even for run-time switching) you can implement a Factory Pattern using this package. Take a look at volume.py as an example. -**Example:** Decorate a function for auto-registering under it's own name:: +**Example:** Decorate a function for auto-registering under it's own name: import jukebox.plugs as plugs @plugs.register def func1(param): pass -**Example:** Decorate a function for auto-registering under a new name:: +**Example:** Decorate a function for auto-registering under a new name: @plugs.register(name='better_name') def func2(param): pass -**Example:** Register a function during run-time under it's own name:: +**Example:** Register a function during run-time under it's own name: def func3(param): pass plugs.register(func3) -**Example:** Register a function during run-time under a new name:: +**Example:** Register a function during run-time under a new name: def func4(param): pass plugs.register(func4, name='other_name', package='other_package') **Example:** Decorate a class for auto registering during initialization, -including all methods (see _register_class for more info):: +including all methods (see _register_class for more info): @plugs.register(auto_tag=True) class MyClass1: pass -**Example:** Register a class instance, from which only report is a callable method through the plugs interface:: +**Example:** Register a class instance, from which only report is a callable method through the plugs interface: class MyClass2: @plugs.tag @@ -57,20 +57,17 @@ def report(self): Naming convention: -package - 1. Either a python package - 2. or a plugin package (which is the python package but probably loaded under a different name inside plugs) - -plugin - 1. An object from the package that can be accessed through the plugs call function (i.e. a function or a class instance) - 2. The string name to above object - -name - The string name of the plugin object for registration - -method - 1. In case the object is a class instance a bound method to call from the class instance - 2. The string name to above object +* package + * Either a python package + * or a plugin package (which is the python package but probably loaded under a different name inside plugs) +* plugin + * An object from the package that can be accessed through the plugs call function (i.e. a function or a class instance) + * The string name to above object +* name + * The string name of the plugin object for registration +* method + * In case the object is a class instance a bound method to call from the class instance + * The string name to above object """ @@ -405,15 +402,13 @@ def register(plugin: Optional[Callable] = None, *, 3. ``@plugs.register(auto_tag=bool)``: decorator for a class with 1 arguments 4. ``@plugs.register(name=name, package=package)``: decorator for a function with 1 or 2 arguments 5. ``plugs.register(plugin, name=name, package=package)``: run-time registration of - * function * bound method * class instance For more documentation see the functions - - * :func:`_register_obj` - * :func:`_register_class` + * :func:`_register_obj` + * :func:`_register_class` See the examples in Module :mod:`plugs` how to use this decorator / function @@ -504,9 +499,10 @@ def atexit(func: Callable[[int], Any]) -> Callable[[int], Any]: """ Decorator for functions that shall be called by the plugs package directly after at exit of program. - .. important:: There is no automatism as in atexit.atexit. The function plugs.shutdown() must be explicitly called - during the shutdown procedure of your program. This is by design, so you can choose the exact situation in your - shutdown handler. + > [!IMPORTANT] + > There is no automatism as in atexit.atexit. The function plugs.shutdown() must be explicitly called + > during the shutdown procedure of your program. This is by design, so you can choose the exact situation in your + > shutdown handler. The atexit-functions are called with a single integer argument, which is passed down from plugin.exit(int) It is intended for passing down the signal number that initiated the program termination @@ -527,11 +523,11 @@ def load(package: str, load_as: Optional[str] = None, prefix: Optional[str] = No """ Loads a python package as plugin package - Executes a regular python package load. That means a potentially existing __init__.py is executed. - Decorator @register can by used to register functions / classes / class istances as plugin callable - Decorator @initializer can be used to tag functions that shall be called after package loading - Decorator @finalizer can be used to tag functions that shall be called after ALL plugin packges have been loaded - Instead of using @initializer, you may of course use __init__.py + Executes a regular python package load. That means a potentially existing `__init__.py` is executed. + Decorator `@register` can by used to register functions / classes / class istances as plugin callable + Decorator `@initializer` can be used to tag functions that shall be called after package loading + Decorator `@finalizer` can be used to tag functions that shall be called after ALL plugin packges have been loaded + Instead of using `@initializer`, you may of course use `__init__.py` Python packages may be loaded under a different plugs package name. Python packages must be unique and the name under which they are loaded as plugin package also. @@ -723,9 +719,9 @@ def call(package: str, plugin: str, method: Optional[str] = None, *, Calls are serialized by a thread lock. The thread lock is shared with call_ignore_errors. - .. note:: - There is no logger in this function as they all belong up-level where the exceptions are handled. - If you want logger messages instead of exceptions, use :func:`call_ignore_errors` + > [!NOTE] + > There is no logger in this function as they all belong up-level where the exceptions are handled. + > If you want logger messages instead of exceptions, use :func:`call_ignore_errors` :param package: Name of the plugin package in which to look for function/class instance :param plugin: Function name or instance name of a class @@ -824,7 +820,9 @@ def loaded_as(module_name: str) -> str: def delete(package: str, plugin: Optional[str] = None, ignore_errors=False): """Delete a plugin object from the registered plugs callables - Note: This does not 'unload' the python module. It merely makes it un-callable via plugs!""" + > [!NOTE] + > This does not 'unload' the python module. It merely makes it un-callable via plugs! + """ with _lock_module: if exists(package, plugin): if plugin is None: @@ -971,13 +969,13 @@ def get_all_loaded_packages() -> Dict[str, str]: def get_all_failed_packages() -> Dict[str, str]: """Report those packages that did not load error free - .. note:: Package could fail to load - - 1. altogether: these package are not registered - 2. partially: during initializer, finalizer functions: The package is loaded, - but the function did not execute error-free - - Partially loaded packages are listed in both _PLUGINS and _PLUGINS_FAILED + > [!NOTE] + > Package could fail to load + > * altogether: these package are not registered + > * partially: during initializer, finalizer functions: The package is loaded, + > but the function did not execute error-free + > + > Partially loaded packages are listed in both _PLUGINS and _PLUGINS_FAILED :return: Dictionary of the form `{loaded_as: loaded_from, ...}`""" with _lock_module: diff --git a/src/jukebox/jukebox/publishing/server.py b/src/jukebox/jukebox/publishing/server.py index 7db5b5846..66729da6e 100644 --- a/src/jukebox/jukebox/publishing/server.py +++ b/src/jukebox/jukebox/publishing/server.py @@ -1,31 +1,28 @@ """ -Publishing Server -******************** +## Publishing Server The common publishing server for the entire Jukebox using ZeroMQ -Structure ----------------- - -.. code-block:: text - - +-----------------------+ - | functional interface | Publisher - | | - functional interface for single Thread - | PUB | - sends data to publisher (and thus across threads) - +-----------------------+ - | (1) - v - +-----------------------+ - | SUB (bind) | PublishServer - | | - Last Value (LV) Cache - | XPUB (bind) | - Subscriber notification and LV resend - +-----------------------+ - independent thread - | (2) - v - -Connection (1): Internal connection - Internal connection only - do not use (no, not even inside this App for you own plugins - always bind to the PublishServer) +### Structure + + +-----------------------+ + | functional interface | Publisher + | | - functional interface for single Thread + | PUB | - sends data to publisher (and thus across threads) + +-----------------------+ + | (1) + v + +-----------------------+ + | SUB (bind) | PublishServer + | | - Last Value (LV) Cache + | XPUB (bind) | - Subscriber notification and LV resend + +-----------------------+ - independent thread + | (2) + v + +#### Connection (1): Internal connection + +Internal connection only - do not use (no, not even inside this App for you own plugins - always bind to the PublishServer) Protocol: Multi-part message @@ -39,10 +36,11 @@ Usually empty, i.e. ``b''``. If not empty the message is treated as command for the PublishServer and the message is not forwarded to the outside. This third part of the message is never forwarded -Connection (2): External connection - Upon connection of a new subscriber, the entire current state is resend from cache to ALL subscribers! - Subscribers must subscribe to topics. Topics are treated as topic trees! Subscribing to a root tree will - also get you all the branch topics. To get everything, subscribe to ``b''`` +#### Connection (2): External connection + +Upon connection of a new subscriber, the entire current state is resend from cache to ALL subscribers! +Subscribers must subscribe to topics. Topics are treated as topic trees! Subscribing to a root tree will +also get you all the branch topics. To get everything, subscribe to ``b''`` Protocol: Multi-part message @@ -52,24 +50,22 @@ Part 2: Payload or Message in json serialization If empty (i.e. b''), it means the subscriber must delete this key locally (not valid anymore) -Why? Why? -------------- +### Why? Why? -Check out the `ZeroMQ Documentation `_ +Check out the [ZeroMQ Documentation](https://zguide.zeromq.org/docs/chapter5) for why you need a proxy in a good design. For use case, we made a few simplifications -Design Rationales -------------------- +### Design Rationales -* "If you need `millions of messages per second `_ +* "If you need [millions of messages per second](https://zguide.zeromq.org/docs/chapter5/#Pros-and-Cons-of-Pub-Sub) sent to thousands of points, - you’ll appreciate pub-sub a lot more than if you need a few messages a second sent to a handful of recipients." + you'll appreciate pub-sub a lot more than if you need a few messages a second sent to a handful of recipients." * "lower-volume network with a few dozen subscribers and a limited number of topics, we can use TCP and then - the `XSUB and XPUB `_" -* "Let’s imagine `our feed has an average of 100,000 100-byte messages a second - `_ [...]. + the [XSUB and XPUB](https://zguide.zeromq.org/docs/chapter5/#Last-Value-Caching)" +* "Let's imagine [our feed has an average of 100,000 100-byte messages a + second](https://zguide.zeromq.org/docs/chapter5/#High-Speed-Subscribers-Black-Box-Pattern) [...]. While 100K messages a second is easy for a ZeroMQ application, ..." **But we have:** @@ -100,8 +96,7 @@ * Publisher plugin is first plugin to be loaded * Due to Publisher - PublisherServer structure no further sequencing required -Plugin interactions and usage ------------------------------- +### Plugin interactions and usage RPC can trigger through function call in components/publishing plugin that @@ -110,23 +105,24 @@ Plugins publishing state information should publish initial state at @plugin.finalize -.. important:: Do not direclty instantiate the Publisher in your plugin module. Only one Publisher is - required per thread. But the publisher instance **must** be thread-local! - Always go through :func:`publishing.get_publisher()`. +> [!IMPORTANT] +> Do not direclty instantiate the Publisher in your plugin module. Only one Publisher is +> required per thread. But the publisher instance **must** be thread-local! +> Always go through :func:`publishing.get_publisher()`. **Sockets** Three sockets are opened: -#. TCP (on a configurable port) -#. Websocket (on a configurable port) -#. Inproc: On ``inproc://PublisherToProxy`` all topics are published app-internally. This can be used for plugin modules +1. TCP (on a configurable port) +2. Websocket (on a configurable port) +3. Inproc: On ``inproc://PublisherToProxy`` all topics are published app-internally. This can be used for plugin modules that want to know about the current state on event based updates. **Further ZeroMQ References:** -* `Working with Messages `_ -* `Multiple Threads `_ +* [Working with Messages](https://zguide.zeromq.org/docs/chapter2/#Working-with-Messages) +* [Multiple Threads](https://zguide.zeromq.org/docs/chapter2/#Multithreading-with-ZeroMQ) """ # Developer's notes: @@ -190,7 +186,7 @@ class PublishServer(threading.Thread): Handles new subscriptions by sending out the entire cached state to **all** subscribers - The code is structures using a `Reactor Pattern `_ + The code is structures using a [Reactor Pattern](https://zguide.zeromq.org/docs/chapter5/#Using-a-Reactor) """ def __init__(self, tcp_port, websocket_port): super().__init__(name='PubServer') @@ -271,9 +267,9 @@ class Publisher: """ The publisher that provides the functional interface to the application - .. note:: - * An instance must not be shared across threads! - * One instance per thread is enough + > [!NOTE] + > * An instance must not be shared across threads! + > * One instance per thread is enough """ def __init__(self, check_thread_owner=True): diff --git a/src/jukebox/jukebox/rpc/server.py b/src/jukebox/jukebox/rpc/server.py index 8615bced7..b7e55b243 100644 --- a/src/jukebox/jukebox/rpc/server.py +++ b/src/jukebox/jukebox/rpc/server.py @@ -1,17 +1,14 @@ # -*- coding: utf-8 -*- """ -Remote Procedure Call Server (RPC) -************************************* +## Remote Procedure Call Server (RPC) Bind to tcp and/or websocket port and translates incoming requests to procedure calls. Avaiable procedures to call are all functions registered with the plugin package. -To protocol is loosely based on `jsonrpc `_ +The protocol is loosely based on [jsonrpc](https://www.jsonrpc.org/specification) But with different elements directly relating to the plugin concept and Python function argument options -.. code-block:: yaml - { 'package' : str # The plugin package loaded from python module 'plugin' : str # The plugin object to be accessed from the package @@ -38,9 +35,9 @@ Three sockets are opened -#. TCP (on a configurable port) -#. Websocket (on a configurable port) -#. Inproc: On ``inproc://JukeBoxRpcServer`` connection from the internal app are accepted. This is indented be +1. TCP (on a configurable port) +2. Websocket (on a configurable port) +3. Inproc: On ``inproc://JukeBoxRpcServer`` connection from the internal app are accepted. This is indented be call arbitrary RPC functions from plugins that provide an interface to the outside world (e.g. GPIO). By also going though the RPC instead of calling function directly we increase thread-safety and provide easy configurability (e.g. which button triggers what action) diff --git a/src/jukebox/jukebox/utils.py b/src/jukebox/jukebox/utils.py index 9be97b6db..dbd647490 100644 --- a/src/jukebox/jukebox/utils.py +++ b/src/jukebox/jukebox/utils.py @@ -17,7 +17,8 @@ def decode_rpc_call(cfg_rpc_call: Dict) -> Optional[Dict]: """Makes sure that the core rpc call parameters have valid default values in cfg_rpc_call. - .. important: Leaves all other parameters in cfg_action untouched or later downstream processing! + > [!IMPORTANT] + > Leaves all other parameters in cfg_action untouched or later downstream processing! :param cfg_rpc_call: RPC command as configuration entry :return: A fully populated deep copy of cfg_rpc_call @@ -41,8 +42,8 @@ def decode_rpc_command(cfg_rpc_cmd: Dict, logger: logging.Logger = log) -> Optio This means - * Decode RPC command alias (if present) - * Ensure all RPC call parameters have valid default values + * Decode RPC command alias (if present) + * Ensure all RPC call parameters have valid default values If the command alias cannot be decoded correctly, the command is mapped to misc.empty_rpc_call which emits a misuse warning when called diff --git a/src/jukebox/misc/loggingext.py b/src/jukebox/misc/loggingext.py index 9328cfea8..36b040339 100644 --- a/src/jukebox/misc/loggingext.py +++ b/src/jukebox/misc/loggingext.py @@ -1,7 +1,6 @@ """ -############## -Logger -############## +## Logger + We use a hierarchical Logger structure based on pythons logging module. It can be finely configured with a yaml file. The top-level logger is called 'jb' (to make it short). In any module you may simple create a child-logger at any hierarchy @@ -9,25 +8,27 @@ Hierarchy separator is the '.'. If the logger already exits, getLogger will return a reference to the same, else it will be created on the spot. -:Example: How to get logger and log away at your heart's content: +Example: How to get logger and log away at your heart's content: + >>> import logging >>> logger = logging.getLogger('jb.awesome_module') >>> logger.info('Started general awesomeness aura') -Example: YAML snippet, setting WARNING as default level everywhere and DEBUG for jb.awesome_module:: -`` -loggers: - jb: - level: WARNING - handlers: [console, debug_file_handler, error_file_handler] - propagate: no - jb.awesome_module: - level: DEBUG -`` - -.. note:: -The name (and hierarchy path) of the logger can be arbitrary and must not necessarily match the module name (still makes sense) -There can be multiple loggers per module, e.g. for special classes, to further control the amount of log output +Example: YAML snippet, setting WARNING as default level everywhere and DEBUG for jb.awesome_module: + + loggers: + jb: + level: WARNING + handlers: [console, debug_file_handler, error_file_handler] + propagate: no + jb.awesome_module: + level: DEBUG + + +> [!NOTE] +> The name (and hierarchy path) of the logger can be arbitrary and must not necessarily match the module name (still makes +> sense). +> There can be multiple loggers per module, e.g. for special classes, to further control the amount of log output """ import sys import logging @@ -80,21 +81,22 @@ def filter(self, record): class PubStream: - """" + """ Stream handler wrapper around the publisher for logging.StreamHandler Allows logging to send all log information (based on logging configuration) to the Publisher. - ATTENTION: This can lead to recursions! - - Recursions come up when - (a) Publish.send / PublishServer.send also emits logs, which cause a another send, which emits a log, - which causes a send, ..... - (b) Publisher initialization emits logs, which need a Publisher instance to send logs + > [!CAUTION] + > This can lead to recursions! + > Recursions come up when + > * Publish.send / PublishServer.send also emits logs, which cause a another send, which emits a log, + > which causes a send, ..... + > * Publisher initialization emits logs, which need a Publisher instance to send logs - IMPORTANT: To avoid endless recursions: The creation of a Publisher MUST NOT generate any log messages! Nor any of the - functions in the send-function stack! + > [!IMPORTANT] + > To avoid endless recursions: The creation of a Publisher MUST NOT generate any log messages! Nor any of the + > functions in the send-function stack! """ def __init__(self): self._topic = 'core.logger' diff --git a/src/jukebox/run_configure_audio.py b/src/jukebox/run_configure_audio.py index 6bb0e70f6..93f0a4c6a 100755 --- a/src/jukebox/run_configure_audio.py +++ b/src/jukebox/run_configure_audio.py @@ -5,7 +5,7 @@ Will also setup equalizer and mono down mixer in the pulseaudio config file. Run this once after installation. Can be re-run at any time to change the settings. -For more information see :ref:`userguide/audio:Audio Configuration`. +For more information see [Audio Configuration](../../builders/audio.md#audio-configuration). """ import os import argparse diff --git a/src/jukebox/run_jukebox.py b/src/jukebox/run_jukebox.py index 0735e2b8e..789e57aca 100755 --- a/src/jukebox/run_jukebox.py +++ b/src/jukebox/run_jukebox.py @@ -5,11 +5,11 @@ Usually this runs as a service, which is started automatically after boot-up. At times, it may be necessary to restart the service. For example after a configuration change. Not all configuration changes can be applied on-the-fly. -See :ref:`userguide/configuration:Jukebox Configuration`. +See [Jukebox Configuration](../../builders/configuration.md#jukebox-configuration). For debugging, it is usually desirable to run the Jukebox directly from the console rather than as service. This gives direct logging info in the console and allows changing command line parameters. -See :ref:`userguide/troubleshooting:Troubleshooting`. +See [Troubleshooting](../../builders/troubleshooting.md). """ import os.path import argparse diff --git a/src/jukebox/run_register_rfid_reader.py b/src/jukebox/run_register_rfid_reader.py index 3aa69735e..18a1614d8 100755 --- a/src/jukebox/run_register_rfid_reader.py +++ b/src/jukebox/run_register_rfid_reader.py @@ -3,10 +3,11 @@ Setup tool to configure the RFID Readers. Run this once to register and configure the RFID readers with the Jukebox. Can be re-run at any time to change -the settings. For more information see :ref:`rfid/rfid:RFID Readers`. +the settings. For more information see [RFID Readers](../rfid/README.md). -.. note:: This tool will always write a new configurations file. Thus, overwrite the old one (after checking with the user). - Any manual modifications to the settings will have to be re-applied +> [!NOTE] +> This tool will always write a new configurations file. Thus, overwrite the old one (after checking with the user). +> Any manual modifications to the settings will have to be re-applied """ import os diff --git a/src/jukebox/run_rpc_tool.py b/src/jukebox/run_rpc_tool.py index 1593d7796..4bd834e12 100755 --- a/src/jukebox/run_rpc_tool.py +++ b/src/jukebox/run_rpc_tool.py @@ -11,7 +11,7 @@ The list of available commands is fetched from the running Jukebox service. .. todo: - - kwargs support + - kwargs support """ diff --git a/src/webapp/.gitignore b/src/webapp/.gitignore index 4d29575de..b32ff75cd 100644 --- a/src/webapp/.gitignore +++ b/src/webapp/.gitignore @@ -11,6 +11,9 @@ # production /build +# development +/build.bak + # misc .DS_Store .env.local diff --git a/src/webapp/.npmrc b/src/webapp/.npmrc new file mode 100644 index 000000000..f05c1e85f --- /dev/null +++ b/src/webapp/.npmrc @@ -0,0 +1,3 @@ +fetch-retries=10 +fetch-retry-mintimeout=20000 +fetch-retry-maxtimeout=120000 diff --git a/src/webapp/public/index.html b/src/webapp/public/index.html index 71ea85188..30c055a5e 100644 --- a/src/webapp/public/index.html +++ b/src/webapp/public/index.html @@ -7,7 +7,7 @@ diff --git a/src/webapp/run_rebuild.sh b/src/webapp/run_rebuild.sh index 870813d78..e8e4d06c0 100755 --- a/src/webapp/run_rebuild.sh +++ b/src/webapp/run_rebuild.sh @@ -1,30 +1,35 @@ #!/usr/bin/env bash usage() { - echo -e "\nRebuild the Web App\n" - echo "${BASH_SOURCE[0]} [-u] [-m SIZE]" - echo " -u : Update NPM dependencies before rebuild (only necessary if package.json changed)" - echo " -m SIZE : Set Node memory limit in MB (if omitted limit is deduced automatically)" - echo -e "\n\n" + echo -e "\nRebuild the Web App\n" + echo "${BASH_SOURCE[0]} [-u] [-m SIZE]" + echo " -u : Update NPM dependencies before rebuild (only necessary on first build or if package.json changed" + echo " -m SIZE : Set Node memory limit in MB (if omitted limit is deduced automatically and swap might be adjusted)" + echo " -v : Increase verbosity" + echo -e "\n\n" } UPDATE_DEPENDENCIES=false +VERBOSE=false -while getopts ":uhm:" opt; do - case ${opt} in - u) - UPDATE_DEPENDENCIES=true - ;; - m) - NODEMEM="${OPTARG}" - ;; - h) - usage - ;; - \?) - usage - ;; - esac +while getopts ":uhvm:" opt; do + case ${opt} in + u) + UPDATE_DEPENDENCIES=true + ;; + m) + NODEMEM="${OPTARG}" + ;; + v) + VERBOSE=true + ;; + h) + usage + ;; + \?) + usage + ;; + esac done # Change working directory to location of script @@ -32,44 +37,87 @@ SOURCE=${BASH_SOURCE[0]} SCRIPT_DIR="$(dirname "$SOURCE")" cd "$SCRIPT_DIR" || exit 1 -# Need to check free space and limit Node memory usage -# for PIs with little memory -MemTotal=$(grep MemTotal /proc/meminfo | awk '{print $2}') -MemFree=$(grep MemFree /proc/meminfo | awk '{print $2}') -SwapFree=$(grep SwapFree /proc/meminfo | awk '{print $2}') -TotalFree=$((SwapFree + MemFree)) - -MemTotal=$((MemTotal / 1024)) -MemFree=$((MemFree / 1024)) -SwapFree=$((SwapFree / 1024)) -TotalFree=$((TotalFree / 1024)) - -echo "Total phys memory: ${MemTotal} MB" -echo "Free phys memory : ${MemFree} MB" -echo "Free swap memory : ${SwapFree} MB" -echo "Free total memory: ${TotalFree} MB" - - -if [[ -z $NODEMEM ]]; then - # Keep a buffer of minimum 20 MB - if [[ $TotalFree -gt 1044 ]]; then - NODEMEM=1024 - elif [[ $TotalFree -gt 532 ]]; then - NODEMEM=512 - elif [[ $TotalFree -gt 276 ]]; then - NODEMEM=256 - else - echo "ERROR: Not enough memory available on system. Please increase swap size to give at least 276 MByte free memory." - echo "Current free memory = $TotalFree MB" - echo "Hint: if only a little memory is missing, stopping spocon, mpd, and jukebox-daemon might give you enough space" - exit 1 - fi -fi +change_swap() { + local new_swap_size="$1" + sudo dphys-swapfile swapoff || return 1 + sudo sed -i "s|.*CONF_SWAPSIZE=.*|CONF_SWAPSIZE=${new_swap_size}|g" /etc/dphys-swapfile || return 1 + sudo sed -i "s|^\s*CONF_SWAPFACTOR=|#CONF_SWAPFACTOR=|g" /etc/dphys-swapfile || return 1 + sudo dphys-swapfile setup 1&>/dev/null || return 1 + sudo dphys-swapfile swapon || return 1 +} -if [[ $NODEMEM -gt $TotalFree ]]; then - echo "ERROR: Requested node memory setting is larger than available free memory: $NODEMEM MB > $TotalFree MB" - exit 1 -fi +# Need to check free space and limit Node memory usage for PIs with little memory. +# Adjust swap if needed to have minimum memory available +calc_nodemem() { + echo "calculate usable memory" + # keep a buffer for the kernel etc. + local mem_buffer=256 + + local mem_total=$(grep MemTotal /proc/meminfo | awk '{print $2}') + local mem_free=$(grep MemFree /proc/meminfo | awk '{print $2}') + local swap_total=$(grep SwapTotal /proc/meminfo | awk '{print $2}') + local swap_free=$(grep SwapFree /proc/meminfo | awk '{print $2}') + local total_free=$((swap_free + mem_free)) + + mem_total=$((mem_total / 1024)) + mem_free=$((mem_free / 1024)) + swap_total=$((swap_total / 1024)) + swap_free=$((swap_free / 1024)) + total_free=$((total_free / 1024)) + + local free_to_use=$((total_free - mem_buffer)) + + if [ "$VERBOSE" == true ]; then + echo " Total phys memory : ${mem_total} MB" + echo " Free phys memory : ${mem_free} MB" + echo " Total swap memory : ${swap_total} MB" + echo " Free swap memory : ${swap_free} MB" + echo " Free total memory : ${total_free} MB" + echo " Keep as buffer : ${mem_buffer} MB" + echo -e " Free usable memory: ${free_to_use} MB\n" + fi + + if [[ -z $NODEMEM ]]; then + # mininum memory used for node + local mem_min=512 + if [[ $free_to_use -gt $mem_min ]]; then + NODEMEM=$free_to_use + else + echo " WARN: Not enough memory left on system for node (usable ${free_to_use} MB, min. ${mem_min} MB)." + echo " Trying to adjust swap size ..." + + local add_swap_size=$((mem_min / 2)) + local new_swap_size=$((swap_total + add_swap_size)) + + # keep a buffer on the filesystem + local filesystem_needed=$((add_swap_size + 512)) + local filesystem_free=$(df -BM -P / | tail -n 1 | awk '{print $4}') + filesystem_free=${filesystem_free//M} + + if [ "$VERBOSE" == true ]; then + echo " New swap size = $new_swap_size MB" + echo " Additional filesystem space needed = $filesystem_needed MB" + echo " Current free filesystem space = $filesystem_free MB" + fi + + if [ "${filesystem_free}" -lt "${filesystem_needed}" ]; then + echo " ERROR: Not enough space available on filesystem for swap (free ${filesystem_free} MB, min. ${filesystem_needed} MB). Abort!" + exit 1 + elif ! change_swap $new_swap_size ; then + echo " ERROR: failed to change swap size. Abort!" + exit 1 + fi + + calc_nodemem || return 1 + fi + + elif [[ $NODEMEM -gt $free_to_use ]]; then + echo " ERROR: Requested node memory setting is larger than usable free memory: ${NODEMEM} MB > ${free_to_use} MB (free ${total_free} MB - buffer ${mem_buffer} MB). Abort!" + exit 1 + fi +} + +calc_nodemem export NODE_OPTIONS=--max-old-space-size=${NODEMEM} @@ -77,16 +125,27 @@ echo "Setting Node Options:" env | grep NODE if [[ $(uname -m) == armv6l ]]; then - echo " You are running on a hardware with less resources. Building - the webapp might fail. If so, try to install the stable - release installation instead." + echo " +----------------------------------------------------------- +| You are running a hardware with limited resources. | +| Building the Web App takes significantly more time. | +| In case it fails, check the documentation | +| to trouble shoot. | +----------------------------------------------------------- +" fi -# In rare cases you will need to update the npm dependencies -# This is the case when the file package.json changed if [[ $UPDATE_DEPENDENCIES == true ]]; then - npm install + npm install --prefer-offline --no-audit fi +build_output_folder="build" # Rebuild Web App -npm run build +rm -rf "${build_output_folder}.bak" +if [ -d "${build_output_folder}" ]; then + mv -f "${build_output_folder}" "${build_output_folder}.bak" +fi +if ! npm run build ; then + echo "ERROR: rebuild of Web App failed!" + exit 1 +fi