diff --git a/.flake8 b/.flake8 index 7f201b078..6f7bcd443 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,8 @@ [flake8] max-line-length = 127 ignore = + # continuation line over-indented for hanging indent + E126, # continuation line over-indented for visual indent E127, # continuation line under-indented for visual indent diff --git a/.githooks/pre-commit b/.githooks/pre-commit index b1b0c0348..8ffc7de0c 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -9,6 +9,9 @@ # Note: This only checks the modified files # - docs build of if any python file is staged # Note: This builds the entire documentation if a changed file goes into the documentation +# - Markdownlint if any markdown file is staged +# Note: This checks all markdown files as configured in .markdownlint-cli2.yaml + # # If there are problem with this script, commit may still be done with # git commit --no-verify @@ -40,6 +43,20 @@ fi code=$(( flake8_code + doc_code )) +# Pass all staged markdown files through markdownlint-cli2 +MD_FILES="$(git diff --diff-filter=d --staged --name-only -- **/*.md)" +markdownlint_code=0 +if [[ -n $MD_FILES ]]; then + echo -e "\n**************************************************************" + echo "Modified Markdown files. Running markdownlint-cli2 ... " + echo -e "**************************************************************\n" + ./run_markdownlint.sh + markdownlint_code=$? + echo "Markdownlint-cli2 return code: $markdownlint_code" +fi + +code=$(( flake8_code + doc_code + markdownlint_code)) + if [[ code -gt 0 ]]; then echo -e "\n**************************************************************" echo -e "ERROR(s) during pre-commit checks. Aborting commit!" diff --git a/.github/ISSUE_TEMPLATE/bug_template.md b/.github/ISSUE_TEMPLATE/bug_template.md index 508cf50b9..afcb9bd0c 100644 --- a/.github/ISSUE_TEMPLATE/bug_template.md +++ b/.github/ISSUE_TEMPLATE/bug_template.md @@ -33,7 +33,6 @@ Please post here the output of 'tail -n 500 /var/log/syslog' or 'journalctl -u m i.e. `find logfiles at https://paste.ubuntu.com/p/cRS7qM8ZmP/` --> - ## Software ### Base image and version @@ -59,7 +58,6 @@ the following command will help with that i.e. `scripts/installscripts/buster-install-default.sh` --> - ## Hardware ### RaspberryPi version diff --git a/.github/workflows/bundle_webapp_and_release_v3.yml b/.github/workflows/bundle_webapp_and_release_v3.yml index 13bffe472..a64d6e288 100644 --- a/.github/workflows/bundle_webapp_and_release_v3.yml +++ b/.github/workflows/bundle_webapp_and_release_v3.yml @@ -101,7 +101,7 @@ jobs: tar -czvf ${{ steps.vars.outputs.webapp_bundle_name }} build - name: Artifact Upload - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ steps.vars.outputs.webapp_bundle_name }} path: ${{ steps.build-webapp.outputs.webapp-root-path }}/${{ steps.vars.outputs.webapp_bundle_name }} @@ -119,7 +119,7 @@ jobs: steps: - name: Artifact Download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: ${{ needs.build.outputs.webapp_bundle_name }} diff --git a/.github/workflows/codeql-analysis_v3.yml b/.github/workflows/codeql-analysis_v3.yml index 89693df2a..211baae86 100644 --- a/.github/workflows/codeql-analysis_v3.yml +++ b/.github/workflows/codeql-analysis_v3.yml @@ -37,38 +37,20 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.x' - - name: Install dependencies - run: | - # Install necessary packages - sudo apt-get install libasound2-dev pulseaudio - python3 -m venv .venv - source ".venv/bin/activate" - - python -m pip install --upgrade pip - pip install -r requirements.txt - # Set the `CODEQL-PYTHON` environment variable to the Python executable - # that includes the dependencies - echo "CODEQL_PYTHON=$(which python)" >> $GITHUB_ENV - # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main - setup-python-dependencies: false # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -82,4 +64,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/markdown_v3.yml b/.github/workflows/markdown_v3.yml new file mode 100644 index 000000000..38284038e --- /dev/null +++ b/.github/workflows/markdown_v3.yml @@ -0,0 +1,28 @@ +name: Markdown Linting + +on: + push: + branches: + - 'future3/**' + paths: + - '**.md' + pull_request: + branches: + - 'future3/**' + paths: + - '**.md' + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Linting markdown + uses: DavidAnson/markdownlint-cli2-action@v15 + with: + config: .markdownlint-cli2.yaml + #continue-on-error: true diff --git a/.github/workflows/pythonpackage_future3.yml b/.github/workflows/pythonpackage_future3.yml index 2236a5a47..3bc2c1174 100644 --- a/.github/workflows/pythonpackage_future3.yml +++ b/.github/workflows/pythonpackage_future3.yml @@ -19,12 +19,12 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ['3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/test_docker_debian_codename_sub_v3.yml b/.github/workflows/test_docker_debian_codename_sub_v3.yml index a9ec217dc..e3ef02bbe 100644 --- a/.github/workflows/test_docker_debian_codename_sub_v3.yml +++ b/.github/workflows/test_docker_debian_codename_sub_v3.yml @@ -134,11 +134,11 @@ jobs: BASE_TEST_IMAGE=${{ steps.vars.outputs.image_tag_name_local_base }} - name: Artifact Upload Docker Image - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ steps.vars.outputs.image_file_name }} path: ${{ steps.vars.outputs.image_file_path }} - retention-days: 1 + retention-days: 2 # Run tests with build image @@ -159,7 +159,7 @@ jobs: uses: docker/setup-buildx-action@v3.0.0 - name: Artifact Download Docker Image - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: ${{ needs.build.outputs.image_file_name }} @@ -179,13 +179,14 @@ jobs: # cleanup after test execution cleanup: - # run only if tests didn't fail: keep the artifact to make job reruns possible - if: ${{ !failure() }} - needs: [build, test] - runs-on: ${{ inputs.runs_on }} - - steps: - - name: Artifact Delete Docker Image - uses: geekyeggo/delete-artifact@v2 - with: - name: ${{ needs.build.outputs.image_file_name }} + # run only if tests didn't fail: keep the artifact to make job reruns possible + if: ${{ !failure() }} + needs: [build, test] + runs-on: ${{ inputs.runs_on }} + + steps: + - name: Artifact Delete Docker Image + uses: geekyeggo/delete-artifact@v5 + with: + name: ${{ needs.build.outputs.image_file_name }} + failOnError: false diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 000000000..7ee48734a --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,58 @@ +# +# markdownlint-cli2 configuration, see https://github.com/DavidAnson/markdownlint-cli2?tab=readme-ov-file#configuration +# + +# rules, see https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md +config: + line-length: false + # ignore dollar signs + commands-show-output: false + no-trailing-punctuation: false + no-duplicate-heading: + siblings_only: true + # allow some tags we use for formatting + no-inline-html: + allowed_elements: [ "details", "summary" ] + +# Include a custom rule package +#customRules: +# - markdownlint-rule-titlecase + +# Fix no fixable errors +fix: false + +# Define a custom front matter pattern +#frontMatter: "[^]*<\/head>" + +# Define glob expressions to use (only valid at root) +globs: + - "**.md" + +# Define glob expressions to ignore +ignores: + - "documentation/developers/docstring/*" + - "src/**" + - "LICENSE" + +# Use a plugin to recognize math +#markdownItPlugins: +# - +# - "@iktakahiro/markdown-it-katex" + +# Additional paths to resolve module locations from +#modulePaths: +# - "./modules" + +# Enable inline config comments +noInlineConfig: false + +# Disable progress on stdout (only valid at root) +noProgress: true + +# Use a specific formatter (only valid at root) +#outputFormatters: +# - +# - markdownlint-cli2-formatter-default + +# Show found files on stdout (only valid at root) +showFound: true diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 332baee88..a28c343f0 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,14 +1,14 @@ +# Contributor Covenant Code of Conduct -Dear Phonieboxians, - -As the Phoniebox community is growing, somebody suggested a pull request with the below document. I was hesitant to include it right away, but at the same time I thought: it might be good to have some kind of document to formulate the foundation this project is built on. To tell you the truth, this document is not it. However, it is a start and I thought: why not open this in the spirit of open source, sharing and pull requests and see if and how you or you or you want to change or add parts of this very *standard and corporate* document. Like most of you, I also have a small kid and my time is scarce, I might find some time though to add a bit. - -All the best, Micz +> [!NOTE] +> Dear Phonieboxians, +> +> As the Phoniebox community is growing, somebody suggested a pull request with the below document. I was hesitant to include it right away, but at the same time I thought: it might be good to have some kind of document to formulate the foundation this project is built on. To tell you the truth, this document is not it. However, it is a start and I thought: why not open this in the spirit of open source, sharing and pull requests and see if and how you or you or you want to change or add parts of this very *standard and corporate* document. Like most of you, I also have a small kid and my time is scarce, I might find some time though to add a bit. +> +> All the best, Micz 2018-08-21 -# Contributor Covenant Code of Conduct - This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] ## Our Pledge diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4ac9cd16..feb47f2c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,15 +45,16 @@ as local, temporary scratch areas. Contributors have played a bigger role over time to keep Phoniebox on the edge of innovation :) -Our goal is to make it simple for you to contribute changes that improve functionality in your specific environment. -To achieve this, we have a set of guidelines that we kindly request contributors to adhere to. +Our goal is to make it simple for you to contribute changes that improve functionality in your specific environment. +To achieve this, we have a set of guidelines that we kindly request contributors to adhere to. These guidelines help us maintain a streamlined process and stay on top of incoming contributions. To report bug fixes and improvements, please follow the steps outlined below: + 1. For bug fixes and minor improvements, simply open a new issue or pull request (PR). 2. If you intend to port a feature from Version 2.x to future3 or wish to implement a new feature, we recommend reaching out to us beforehand. - - In such cases, please create an issue outlining your plans and intentions. - - We will ensure that there are no ongoing efforts on the same topic. + * In such cases, please create an issue outlining your plans and intentions. + * We will ensure that there are no ongoing efforts on the same topic. We eagerly await your contributions! You can review the current [feature list](documentation/developers/status.md) to check for available features and ongoing work. @@ -108,7 +109,7 @@ Run the checks below on the code. Fix those issues! Or you are running in delays We provide git hooks for those checks for convenience. To activate ~~~bash -cp .githooks/pre-commit` .git/hooks/. +cp .githooks/pre-commit .git/hooks/. ~~~ ### Python Code @@ -152,7 +153,7 @@ to detect in advance. If the code change results in a test failure, we will make our best effort to correct the error. If a fix cannot be determined and committed within 24 hours -of its discovery, the commit(s) responsible _may_ be reverted, at the +of its discovery, the commit(s) responsible *may* be reverted, at the discretion of the committer and Phonie maintainers. The original contributor will be notified of the revert. @@ -163,7 +164,7 @@ The original contributor will be notified of the revert. ## Guidelines -* Phoniebox runs on Raspberry Pi OS. +* Phoniebox runs on Raspberry Pi OS. * Minimum python version is currently **Python 3.9**. ## Additional Resources diff --git a/docker/jukebox.Dockerfile b/docker/Dockerfile.jukebox similarity index 67% rename from docker/jukebox.Dockerfile rename to docker/Dockerfile.jukebox index 0936f5d46..9cee52d1a 100644 --- a/docker/jukebox.Dockerfile +++ b/docker/Dockerfile.jukebox @@ -1,3 +1,4 @@ +FROM libzmq:local AS libzmq FROM debian:bullseye-slim # These are only dependencies that are required to get as close to the @@ -6,8 +7,7 @@ RUN apt-get update && apt-get install -y \ libasound2-dev \ pulseaudio \ pulseaudio-utils \ - --no-install-recommends \ - && rm -rf /var/lib/apt/lists/* + --no-install-recommends ARG UID ARG USER @@ -21,7 +21,7 @@ RUN usermod -aG pulse ${USER} # Install all Jukebox dependencies RUN apt-get update && apt-get install -qq -y \ --allow-downgrades --allow-remove-essential --allow-change-held-packages \ - g++ at wget \ + build-essential at wget \ espeak mpc mpg123 git ffmpeg spi-tools netcat \ python3 python3-venv python3-dev python3-mutagen @@ -37,21 +37,14 @@ ENV VIRTUAL_ENV=${INSTALLATION_PATH}/.venv RUN python3 -m venv $VIRTUAL_ENV ENV PATH="$VIRTUAL_ENV/bin:$PATH" - +# Install all Python dependencies RUN pip install --no-cache-dir -r ${INSTALLATION_PATH}/requirements.txt -ENV ZMQ_TMP_DIR libzmq -ENV ZMQ_VERSION 4.3.5 -ENV ZMQ_PREFIX /usr/local - -RUN [ "$(uname -m)" = "aarch64" ] && ARCH="arm64" || ARCH="$(uname -m)"; \ - wget https://github.com/pabera/libzmq/releases/download/v${ZMQ_VERSION}/libzmq5-${ARCH}-${ZMQ_VERSION}.tar.gz -O libzmq.tar.gz; \ - tar -xzf libzmq.tar.gz -C ${ZMQ_PREFIX}; \ - rm -f libzmq.tar.gz; - -RUN export ZMQ_PREFIX=${PREFIX} && export ZMQ_DRAFT_API=1 -RUN pip install -v --no-binary pyzmq pyzmq +# Install pyzmq Python dependency separately +ENV ZMQ_PREFIX /opt/libzmq +ENV ZMQ_DRAFT_API 1 +COPY --from=libzmq ${ZMQ_PREFIX} ${ZMQ_PREFIX} +RUN pip install -v "pyzmq<26" --no-binary pyzmq EXPOSE 5555 5556 - WORKDIR ${INSTALLATION_PATH}/src/jukebox diff --git a/docker/Dockerfile.libzmq b/docker/Dockerfile.libzmq new file mode 100644 index 000000000..78cf52368 --- /dev/null +++ b/docker/Dockerfile.libzmq @@ -0,0 +1,25 @@ +FROM debian:bullseye-slim + +# Install necessary build dependencies +RUN apt-get update && apt-get install -y \ + build-essential wget tar + +# Define environment variables for libzmq +ENV ZMQ_VERSION 4.3.5 +ENV ZMQ_PREFIX /opt/libzmq + +# Download, compile, and install libzmq +RUN mkdir -p ${ZMQ_PREFIX}; \ + wget https://github.com/zeromq/libzmq/releases/download/v${ZMQ_VERSION}/zeromq-${ZMQ_VERSION}.tar.gz -O libzmq.tar.gz; \ + tar -xzf libzmq.tar.gz; \ + cd zeromq-${ZMQ_VERSION}; \ + ./configure --prefix=${ZMQ_PREFIX} --enable-drafts; \ + make -j$(nproc) && make install + +# Cleanup unnecessary files +RUN rm -rf /zeromq-${ZMQ_VERSION} libzmq.tar.gz + +# Create final image with only the libzmq build fragments +FROM scratch +ENV ZMQ_PREFIX /opt/libzmq +COPY --from=0 ${ZMQ_PREFIX} ${ZMQ_PREFIX} diff --git a/docker/mpd.Dockerfile b/docker/Dockerfile.mpd similarity index 100% rename from docker/mpd.Dockerfile rename to docker/Dockerfile.mpd diff --git a/docker/webapp.Dockerfile b/docker/Dockerfile.webapp similarity index 100% rename from docker/webapp.Dockerfile rename to docker/Dockerfile.webapp diff --git a/docker/config/docker.mpd.conf b/docker/config/docker.mpd.conf index 4ec64890e..ad7713d0d 100644 --- a/docker/config/docker.mpd.conf +++ b/docker/config/docker.mpd.conf @@ -11,7 +11,7 @@ # be disabled and audio files will only be accepted over ipc socket (using # file:// protocol) or streaming files over an accepted protocol. # -music_directory "/home/pi/RPi-Jukebox-RFID/shared/audiofolders" +music_directory "~/RPi-Jukebox-RFID/shared/audiofolders" # # This setting sets the MPD internal playlist directory. The purpose of this # directory is storage for playlists created by MPD. The server will use @@ -67,7 +67,7 @@ sticker_file "~/.config/mpd/sticker.sql" # initialization. This setting is disabled by default and MPD is run as the # current user. # -user "root" +# user "root" # # This setting specifies the group that MPD will run as. If not specified # primary group of user specified with "user" setting will be used (if set). @@ -225,6 +225,10 @@ decoder { # gapless "no" } +decoder { + plugin "wildmidi" + enabled "no" +} # ############################################################################### @@ -239,12 +243,11 @@ decoder { # audio_output { type "alsa" - name "My ALSA Device" -# device "pulse" # optional - mixer_type "software" # optional -# mixer_device "default" # optional -# mixer_control "Master" # optional -# mixer_index "0" # optional + name "Global ALSA->Pulse stream" +# mixer_type "hardware" + mixer_control "Master" + mixer_device "pulse" + device "pulse" } # # An example of an OSS output: @@ -311,9 +314,9 @@ audio_output { # Please see README.Debian if you want mpd to play through the pulseaudio # daemon started as part of your graphical desktop session! # -# audio_output { - # type "pulse" - # name "My Pulse Output" +#audio_output { +# type "pulse" +# name "My Pulse Output" # server "remote_server" # optional # sink "remote_server_sink" # optional # } diff --git a/docker/config/docker.pulse.mpd.conf b/docker/config/docker.pulse.mpd.conf deleted file mode 100644 index ad7713d0d..000000000 --- a/docker/config/docker.pulse.mpd.conf +++ /dev/null @@ -1,412 +0,0 @@ -# An example configuration file for MPD. -# Read the user manual for documentation: http://www.musicpd.org/doc/user/ -# or /usr/share/doc/mpd/html/user.html - - -# Files and directories ####################################################### -# -# This setting controls the top directory which MPD will search to discover the -# available audio files and add them to the daemon's online database. This -# setting defaults to the XDG directory, otherwise the music directory will be -# be disabled and audio files will only be accepted over ipc socket (using -# file:// protocol) or streaming files over an accepted protocol. -# -music_directory "~/RPi-Jukebox-RFID/shared/audiofolders" -# -# This setting sets the MPD internal playlist directory. The purpose of this -# directory is storage for playlists created by MPD. The server will use -# playlist files not created by the server but only if they are in the MPD -# format. This setting defaults to playlist saving being disabled. -# -# playlists are inside the Phoniebox path: -playlist_directory "~/.config/mpd/playlists" -# -# This setting sets the location of the MPD database. This file is used to -# load the database at server start up and store the database while the -# server is not up. This setting defaults to disabled which will allow -# MPD to accept files over ipc socket (using file:// protocol) or streaming -# files over an accepted protocol. -# -db_file "~/.config/mpd/database" -# -# These settings are the locations for the daemon log files for the daemon. -# These logs are great for troubleshooting, depending on your log_level -# settings. -# -# The special value "syslog" makes MPD use the local syslog daemon. This -# setting defaults to logging to syslog, or to journal if mpd was started as -# a systemd service. -# -log_file "~/.config/mpd/log" -# -# This setting sets the location of the file which stores the process ID -# for use of mpd --kill and some init scripts. This setting is disabled by -# default and the pid file will not be stored. -# -pid_file "~/.config/mpd/pid" -# -# This setting sets the location of the file which contains information about -# most variables to get MPD back into the same general shape it was in before -# it was brought down. This setting is disabled by default and the server -# state will be reset on server start up. -# -state_file "~/.config/mpd/state" -# -# The location of the sticker database. This is a database which -# manages dynamic information attached to songs. -# -sticker_file "~/.config/mpd/sticker.sql" -# -############################################################################### - - -# General music daemon options ################################################ -# -# This setting specifies the user that MPD will run as. MPD should never run as -# root and you may use this setting to make MPD change its user ID after -# initialization. This setting is disabled by default and MPD is run as the -# current user. -# -# user "root" -# -# This setting specifies the group that MPD will run as. If not specified -# primary group of user specified with "user" setting will be used (if set). -# This is useful if MPD needs to be a member of group such as "audio" to -# have permission to use sound card. -# -#group "nogroup" -# -# This setting sets the address for the daemon to listen on. Careful attention -# should be paid if this is assigned to anything other then the default, any. -# This setting can deny access to control of the daemon. Choose any if you want -# to have mpd listen on every address. Not effective if systemd socket -# activation is in use. -# -# For network -bind_to_address "any" -# -# And for Unix Socket -#bind_to_address "/run/mpd/socket" -# -# This setting is the TCP port that is desired for the daemon to get assigned -# to. -# -port "6600" -# -# This setting controls the type of information which is logged. Available -# setting arguments are "default", "secure" or "verbose". The "verbose" setting -# argument is recommended for troubleshooting, though can quickly stretch -# available resources on limited hardware storage. -# -log_level "default" -# -# Setting "restore_paused" to "yes" puts MPD into pause mode instead -# of starting playback after startup. -# -#restore_paused "no" -# -# This setting enables MPD to create playlists in a format usable by other -# music players. -# -#save_absolute_paths_in_playlists "no" -# -# This setting defines a list of tag types that will be extracted during the -# audio file discovery process. The complete list of possible values can be -# found in the user manual. -#metadata_to_use "artist,album,title,track,name,genre,date,composer,performer,disc" -# -# This example just enables the "comment" tag without disabling all -# the other supported tags: -#metadata_to_use "+comment" -# -# This setting enables automatic update of MPD's database when files in -# music_directory are changed. -# -auto_update "yes" -# -# Limit the depth of the directories being watched, 0 means only watch -# the music directory itself. There is no limit by default. -# -auto_update_depth "10" -# -############################################################################### - - -# Symbolic link behavior ###################################################### -# -# If this setting is set to "yes", MPD will discover audio files by following -# symbolic links outside of the configured music_directory. -# -#follow_outside_symlinks "yes" -# -# If this setting is set to "yes", MPD will discover audio files by following -# symbolic links inside of the configured music_directory. -# -#follow_inside_symlinks "yes" -# -############################################################################### - - -# Zeroconf / Avahi Service Discovery ########################################## -# -# If this setting is set to "yes", service information will be published with -# Zeroconf / Avahi. -# -#zeroconf_enabled "yes" -# -# The argument to this setting will be the Zeroconf / Avahi unique name for -# this MPD server on the network. %h will be replaced with the hostname. -# -#zeroconf_name "Music Player @ %h" -# -############################################################################### - - -# Permissions ################################################################# -# -# If this setting is set, MPD will require password authorization. The password -# setting can be specified multiple times for different password profiles. -# -#password "password@read,add,control,admin" -# -# This setting specifies the permissions a user has who has not yet logged in. -# -#default_permissions "read,add,control,admin" -# -############################################################################### - - -# Database ####################################################################### -# - -#database { -# plugin "proxy" -# host "other.mpd.host" -# port "6600" -#} - -# Input ####################################################################### -# - -input { - plugin "curl" -# proxy "proxy.isp.com:8080" -# proxy_user "user" -# proxy_password "password" -} - -# QOBUZ input plugin -input { - enabled "no" - plugin "qobuz" -# app_id "ID" -# app_secret "SECRET" -# username "USERNAME" -# password "PASSWORD" -# format_id "N" -} - -# TIDAL input plugin -input { - enabled "no" - plugin "tidal" -# token "TOKEN" -# username "USERNAME" -# password "PASSWORD" -# audioquality "Q" -} - -# Decoder ##################################################################### -# - -decoder { - plugin "hybrid_dsd" - enabled "no" -# gapless "no" -} - -decoder { - plugin "wildmidi" - enabled "no" -} -# -############################################################################### - -# Audio Output ################################################################ -# -# MPD supports various audio output types, as well as playing through multiple -# audio outputs at the same time, through multiple audio_output settings -# blocks. Setting this block is optional, though the server will only attempt -# autodetection for one sound card. -# -# An example of an ALSA output: -# -audio_output { - type "alsa" - name "Global ALSA->Pulse stream" -# mixer_type "hardware" - mixer_control "Master" - mixer_device "pulse" - device "pulse" -} -# -# An example of an OSS output: -# -#audio_output { -# type "oss" -# name "My OSS Device" -# device "/dev/dsp" # optional -# mixer_type "hardware" # optional -# mixer_device "/dev/mixer" # optional -# mixer_control "PCM" # optional -#} -# -# An example of a shout output (for streaming to Icecast): -# -#audio_output { -# type "shout" -# encoder "vorbis" # optional -# name "My Shout Stream" -# host "localhost" -# port "8000" -# mount "/mpd.ogg" -# password "hackme" -# quality "5.0" -# bitrate "128" -# format "44100:16:1" -# protocol "icecast2" # optional -# user "source" # optional -# description "My Stream Description" # optional -# url "http://example.com" # optional -# genre "jazz" # optional -# public "no" # optional -# timeout "2" # optional -# mixer_type "software" # optional -#} -# -# An example of a recorder output: -# -#audio_output { -# type "recorder" -# name "My recorder" -# encoder "vorbis" # optional, vorbis or lame -# path "/var/lib/mpd/recorder/mpd.ogg" -## quality "5.0" # do not define if bitrate is defined -# bitrate "128" # do not define if quality is defined -# format "44100:16:1" -#} -# -# An example of a httpd output (built-in HTTP streaming server): -# -#audio_output { -# type "httpd" -# name "My HTTP Stream" -# encoder "vorbis" # optional, vorbis or lame -# port "8000" -# bind_to_address "0.0.0.0" # optional, IPv4 or IPv6 -# quality "5.0" # do not define if bitrate is defined -# bitrate "128" # do not define if quality is defined -# format "44100:16:1" -# max_clients "0" # optional 0=no limit -#} -# -# An example of a pulseaudio output (streaming to a remote pulseaudio server) -# Please see README.Debian if you want mpd to play through the pulseaudio -# daemon started as part of your graphical desktop session! -# -#audio_output { -# type "pulse" -# name "My Pulse Output" -# server "remote_server" # optional -# sink "remote_server_sink" # optional -# } -# -# An example of a winmm output (Windows multimedia API). -# -#audio_output { -# type "winmm" -# name "My WinMM output" -# device "Digital Audio (S/PDIF) (High Definition Audio Device)" # optional -# or -# device "0" # optional -# mixer_type "hardware" # optional -#} -# -# An example of an openal output. -# -#audio_output { -# type "openal" -# name "My OpenAL output" -# device "Digital Audio (S/PDIF) (High Definition Audio Device)" # optional -#} -# -## Example "pipe" output: -# -#audio_output { -# type "pipe" -# name "my pipe" -# command "aplay -f cd 2>/dev/null" -## Or if you're want to use AudioCompress -# command "AudioCompress -m | aplay -f cd 2>/dev/null" -## Or to send raw PCM stream through PCM: -# command "nc example.org 8765" -# format "44100:16:2" -#} -# -## An example of a null output (for no audio output): -# -#audio_output { -# type "null" -# name "My Null Output" -# mixer_type "none" # optional -#} -# -############################################################################### - - -# Normalization automatic volume adjustments ################################## -# -# This setting specifies the type of ReplayGain to use. This setting can have -# the argument "off", "album", "track" or "auto". "auto" is a special mode that -# chooses between "track" and "album" depending on the current state of -# random playback. If random playback is enabled then "track" mode is used. -# See for more details about ReplayGain. -# This setting is off by default. -# -#replaygain "album" -# -# This setting sets the pre-amp used for files that have ReplayGain tags. By -# default this setting is disabled. -# -#replaygain_preamp "0" -# -# This setting sets the pre-amp used for files that do NOT have ReplayGain tags. -# By default this setting is disabled. -# -#replaygain_missing_preamp "0" -# -# This setting enables or disables ReplayGain limiting. -# MPD calculates actual amplification based on the ReplayGain tags -# and replaygain_preamp / replaygain_missing_preamp setting. -# If replaygain_limit is enabled MPD will never amplify audio signal -# above its original level. If replaygain_limit is disabled such amplification -# might occur. By default this setting is enabled. -# -#replaygain_limit "yes" -# -# This setting enables on-the-fly normalization volume adjustment. This will -# result in the volume of all playing audio to be adjusted so the output has -# equal "loudness". This setting is disabled by default. -# -volume_normalization "yes" -# -############################################################################### - -# Character Encoding ########################################################## -# -# If file or directory names do not display correctly for your locale then you -# may need to modify this setting. -# -filesystem_charset "UTF-8" -# -############################################################################### diff --git a/docker/docker-compose.linux.yml b/docker/docker-compose.linux.yml index 9609dc18d..6285484f8 100755 --- a/docker/docker-compose.linux.yml +++ b/docker/docker-compose.linux.yml @@ -12,7 +12,7 @@ services: volumes: - ../shared/audiofolders:/home/pi/RPi-Jukebox-RFID/shared/audiofolders - ../shared/playlists:/home/pi/.config/mpd/playlists - - ./config/docker.pulse.mpd.conf:/home/pi/.config/mpd/mpd.conf + - ./config/docker.mpd.conf:/home/pi/.config/mpd/mpd.conf - $XDG_RUNTIME_DIR/pulse/native:/tmp/pulse-sock jukebox: @@ -25,5 +25,5 @@ services: - PULSE_SERVER=unix:/tmp/pulse-sock volumes: - ../shared:/home/pi/RPi-Jukebox-RFID/shared - - ./config/docker.pulse.mpd.conf:/home/pi/.config/mpd/mpd.conf + - ./config/docker.mpd.conf:/home/pi/.config/mpd/mpd.conf - $XDG_RUNTIME_DIR/pulse/native:/tmp/pulse-sock diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 03191dbd5..38a112551 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -8,7 +8,7 @@ services: - USER=root - HOME=/root context: ../ - dockerfile: ./docker/mpd.Dockerfile + dockerfile: ./docker/Dockerfile.mpd container_name: mpd image: phoniebox/mpd environment: @@ -17,7 +17,7 @@ services: volumes: - ../shared/audiofolders:/root/RPi-Jukebox-RFID/shared/audiofolders - ../shared/playlists:/root/.config/mpd/playlists - - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf + - ./config/docker.mpd.conf:/root/.config/mpd/mpd.conf jukebox: build: @@ -26,7 +26,7 @@ services: - USER=root - HOME=/root context: ../ - dockerfile: ./docker/jukebox.Dockerfile + dockerfile: ./docker/Dockerfile.jukebox container_name: jukebox image: phoniebox/jukebox depends_on: @@ -43,13 +43,13 @@ services: - ../src/jukebox:/root/RPi-Jukebox-RFID/src/jukebox - ../src/webapp/public/cover-cache:/root/RPi-Jukebox-RFID/src/webapp/build/cover-cache - ../shared:/root/RPi-Jukebox-RFID/shared - - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf + - ./config/docker.mpd.conf:/root/.config/mpd/mpd.conf command: python run_jukebox.py webapp: build: context: ../ - dockerfile: ./docker/webapp.Dockerfile + dockerfile: ./docker/Dockerfile.webapp container_name: webapp image: phoniebox/webapp depends_on: diff --git a/documentation/README.md b/documentation/README.md index bb11dd6f4..cbfb6276a 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -42,7 +42,7 @@ project check out the [documentation of Version 2](https://github.com/MiczFlor/R Version 3 has reached a mature state and will soon be the default version. However, some features may still be missing. Please check the [Feature Status](./developers/status.md), if YOUR feature is already implemented. -> [!NOTE] +> [!NOTE] > If version 3 has all the features you need, we recommend using Version 3. If there is a feature missing, please open an issue. diff --git a/documentation/builders/README.md b/documentation/builders/README.md index 6d9e67bff..29733e92b 100644 --- a/documentation/builders/README.md +++ b/documentation/builders/README.md @@ -9,29 +9,34 @@ ## Features * Audio - * [Audio Output](./audio.md) - * [Bluetooth audio buttons](./bluetooth-audio-buttons.md) + * [Audio Output](./audio.md) + * [Bluetooth audio buttons](./bluetooth-audio-buttons.md) * [GPIO Recipes](./gpio.md) * [Card Database](./card-database.md) - * [RFID Cards synchronisation](./components/synchronisation/rfidcards.md) + * [RFID Cards synchronisation](./components/synchronisation/rfidcards.md) * [Auto Hotspot](./autohotspot.md) * File Management - * [Network share / Samba](./samba.md) + * [Network share / Samba](./samba.md) ## Hardware Components * [Power](./components/power/) - * [OnOff SHIM for safe power on/off](./components/power/onoff-shim.md) + * [OnOff SHIM for safe power on/off](./components/power/onoff-shim.md) + * [Battery Monitor based on a ADS1015](./components/power/batterymonitor.md) * [Soundcards](./components/soundcards/) - * [HiFiBerry Boards](./components/soundcards/hifiberry.md) + * [HiFiBerry Boards](./components/soundcards/hifiberry.md) * [RFID Readers](./../developers/rfid/README.md) +* [Event devices (USB and other buttons)](./event-devices.md) ## Web Application +* Application + * [Cover Art](./webapp/cover-art.md) * Music - * [Playlists, Livestreams and Podcasts](./webapp/playlists-livestreams-podcasts.md) + * [Playlists, Livestreams and Podcasts](./webapp/playlists-livestreams-podcasts.md) ## Advanced + * [Troubleshooting](./troubleshooting.md) * [Concepts](./concepts.md) * [System](./system.md) diff --git a/documentation/builders/audio.md b/documentation/builders/audio.md index 93c46dd54..765c6a08e 100644 --- a/documentation/builders/audio.md +++ b/documentation/builders/audio.md @@ -24,6 +24,7 @@ to setup the configuration for the Jukebox Core App. Run the following steps in a console: + ```bash # Check available PulseAudio sinks $ pactl list sinks short @@ -45,6 +46,7 @@ $ paplay /usr/share/sounds/alsa/Front_Center.wav # This must also work when using an ALSA device $ aplay /usr/share/sounds/alsa/Front_Center.wav ``` + You can also try different PulseAudio sinks without setting the default sink. In this case the volume is the last used volume level for this sink: @@ -86,6 +88,7 @@ Pairing successful .... [PowerLocus Buddy]# exit ``` + If `bluetoothctl` has trouble to execute due to permission issue, try `sudo bluetoothctl`. Wait for a few seconds and then with `$ pactl list sinks short`, check wether the Bluetooth device shows up as an output. diff --git a/documentation/builders/autohotspot.md b/documentation/builders/autohotspot.md index 5a62a37ca..8efde605a 100644 --- a/documentation/builders/autohotspot.md +++ b/documentation/builders/autohotspot.md @@ -7,11 +7,12 @@ The Auto-Hotspot function enables the Jukebox to switch its connection between a ## How to connect -When the Jukebox cannot connect to a known WiFi, it will automatically create a hotspot. +When the Jukebox cannot connect to a known WiFi, it will automatically create a hotspot. You can connect to this hotspot using the password set during installation. Afterwards, you can access the Web App or connect via SSH as before, using the IP from the configuration. The default configuration is + ``` text * SSID : Phoniebox_Hotspot_ * Password : PlayItLoud! @@ -23,8 +24,7 @@ The default configuration is Auto-Hotspot can be enabled or disabled using the Web App or RPC Commands. -> [!NOTE] -> Disabling the Auto-Hotspot will run the WiFi check again and maintain the last connection state until reboot. +Disabling the Auto-Hotspot will run the WiFi check again and maintain the last connection state until reboot. > [!IMPORTANT] > If you disable this feature, you will lose access to the Jukebox if you are not near a known WiFi after reboot! @@ -34,11 +34,13 @@ Auto-Hotspot can be enabled or disabled using the Web App or RPC Commands. ### AutoHotspot functionality is not working Check the `autohotspot.service` status + ``` bash sudo systemctl status autohotspot.service ``` and logs + ``` bash sudo journalctl -u autohotspot.service -n 50 ``` @@ -52,12 +54,13 @@ Check your WiFi configuration. ### You need to add a new WiFi network to the Raspberry Pi #### Using the command line + Connect to the hotspot and open a terminal. Use the [raspi-config](https://www.raspberrypi.com/documentation/computers/configuration.html#wireless-lan) tool to add the new WiFi. ## Resources * [Raspberry Connect - Auto WiFi Hotspot Switch](https://www.raspberryconnect.com/projects/65-raspberrypi-hotspot-accesspoints/158-raspberry-pi-auto-wifi-hotspot-switch-direct-connection) * [Raspberry Pi - Configuring networking](https://www.raspberrypi.com/documentation/computers/configuration.html#using-the-command-line) -* [dhcpcd / wpa_supplicant]() - * [hostapd](http://w1.fi/hostapd/) - * [dnsmasq](https://thekelleys.org.uk/dnsmasq/doc.html) +* dhcpcd / wpa_supplicant + * [hostapd](http://w1.fi/hostapd/) + * [dnsmasq](https://thekelleys.org.uk/dnsmasq/doc.html) diff --git a/documentation/builders/components/mqtt/mqtt-integration.md b/documentation/builders/components/mqtt/mqtt-integration.md new file mode 100644 index 000000000..3147e28a1 --- /dev/null +++ b/documentation/builders/components/mqtt/mqtt-integration.md @@ -0,0 +1,39 @@ +# MQTT Integration + +The MQTT integration allows you to control your Phoniebox via the MQTT protocol. This feature enables not only MQTT +control but also integration with home automation systems like Home Assistant. + +## Configuration + +Set the corresponding setting in `shared\settings\jukebox.yaml` to activate this feature. + +``` yaml +modules: + named: + ... + mqtt: mqtt +... +mqtt: + enable: true + # The prefix for the mqtt topic. /{base_topic}/{topic} + base_topic: phoniebox-dev + # Enable support for legacy commands. Only needed for compatiblity to previous phoniebox mqtt integration. + enable_legacy: false + # The client id used in communication with the MQTT broker and identification of the phoniebox + client_id: phoniebox_dev + # The username to authenticate against the broker + username: phoniebox-dev + # The password to authenticate against the broker + password: phoniebox-dev + # The host name or IP address of your mqtt broker + host: 127.0.0.1 + # The port number of the mqtt broker. The default is 1883 + port: 1883 +``` + +## Usage in Home Assistant + +Home Assistant does not have a native MQTT Media Player integration. To integrate Phoniebox into Home Assistant, you +can use the Universal Media Player configuration in combination with the Home Assistant MQTT service. + +There is also an HACS addon adding Phoniebox as Media Player [Hass Phoniebox](https://github.com/c0un7-z3r0/hass-phoniebox). diff --git a/documentation/builders/components/power/batterymonitor.md b/documentation/builders/components/power/batterymonitor.md new file mode 100644 index 000000000..151010caa --- /dev/null +++ b/documentation/builders/components/power/batterymonitor.md @@ -0,0 +1,82 @@ +# Battery Monitor + +> [!CAUTION] +> Lithium and other batteries are dangerous and must be treated with care. +> Rechargeable Lithium Ion batteries are potentially hazardous and can +> present a serious **FIRE HAZARD** if damaged, defective, or improperly used. +> Do not use this circuit for a lithium-ion battery without the expertise and +> training in handling and using batteries of this type. +> Use appropriate test equipment and safety protocols during development. +> There is no warranty, this may not work as expected! + +## Battery Monitor based on a ADS1015 + +The script in [src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/\_\_init\_\_.py](../../../../src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/__init__.py) is intended to read out the voltage of a single Cell LiIon Battery using a [CY-ADS1015 Board](https://www.adafruit.com/product/1083): + +```text + 3.3V + + + | + .----o----. + ___ | | SDA + .--------|___|---o----o---------o AIN0 o------ + | 2MΩ | | | | SCL + | .-. | | ADS1015 o------ + --- | | --- | | + Battery - 1.5MΩ| | ---100nF '----o----' + 2.9V-4.2V| '-' | | + | | | | + === === === === +``` + +> [!WARNING] +> +> * the circuit is constantly draining the battery! (leak current up to: 2.1µA) +> * the time between sample needs to be a minimum 1sec with this high impedance voltage divider don't use the continuous conversion method! + +## Battery Monitor based on an INA219 + +The script in [src/jukebox/components/battery_monitor/batt_mon_i2c_ina219/\_\_init\_\_.py](../../../../src/jukebox/components/battery_monitor/batt_mon_i2c_ina219/__init__.py) is intended to read out the voltage of a single cell or multiple LiIon Battery using a [INA219 Board](https://www.adafruit.com/product/904): + +```text + 3.3V + + + | + .----o----. + | | SDA + .-------------------------------o AIN o------ + | | INA219 | SCL + | .----------o AOUT o------ + --- | | | + Battery - Regulator + Raspi '----o----' + 2.9V-4.2V| | | + | | | + === === === +``` + +## Configuration example + +The battery monitoring is configured in the jukebox.yml file. + +The "battmon" module has to be added to the modules setting. + +```yaml +modules: + named: + # Do not change the order! + publishing: publishing + ... + battmon: battery_monitor.batt_mon_i2c_ina219 +``` + +The battmon module needs further configuration: + +```yaml +battmon: + scale_to_phy_num: 1 + scale_to_phy_denom: 0 + warning_action: + all_clear_action: +``` + +The setting "scale_to_phy_denom" does not influence the INA219. However, the scale can be adjusted to fit multiple LiIon cells. diff --git a/documentation/builders/components/power/onoff-shim.md b/documentation/builders/components/power/onoff-shim.md index b83ea6140..e9a2849c9 100644 --- a/documentation/builders/components/power/onoff-shim.md +++ b/documentation/builders/components/power/onoff-shim.md @@ -9,7 +9,7 @@ To install the software, open a terminal and type the following command to run t > [!NOTE] > The installation will ask you a few questions. You can safely answer with the default response. -``` +```bash curl https://get.pimoroni.com/onoffshim | bash ``` @@ -28,9 +28,8 @@ The OnOff SHIM comes with a 12-PIN header which needs soldering. If you want to | GPLCLK0 | 7 | 7 | GPIO4 | | GPIO17 | 11 | 11 | GPIO17 | -* More information can be found here: https://pinout.xyz/pinout/onoff_shim +* More information can be found here: ## Assembly options -![](https://cdn.review-images.pimoroni.com/upload-b6276a310ccfbeae93a2d13ec19ab83b-1617096824.jpg?width=640) - +![OnOffShim soldered on a Raspberry Pi](https://cdn.review-images.pimoroni.com/upload-b6276a310ccfbeae93a2d13ec19ab83b-1617096824.jpg?width=640) diff --git a/documentation/builders/components/soundcards/hifiberry.md b/documentation/builders/components/soundcards/hifiberry.md index 1f19fa96d..f663abb42 100644 --- a/documentation/builders/components/soundcards/hifiberry.md +++ b/documentation/builders/components/soundcards/hifiberry.md @@ -19,7 +19,6 @@ If you know you HifiBerry Board identifier, you can run the script as a 1-liner If you like to disable your HiFiberry Sound card and enable onboard sound, run the following command - ```bash ./setup_hifiberry.sh disable ``` diff --git a/documentation/builders/configuration.md b/documentation/builders/configuration.md index dd4c1fd53..c75f53683 100644 --- a/documentation/builders/configuration.md +++ b/documentation/builders/configuration.md @@ -27,9 +27,10 @@ $ ./run_jukebox.sh # Restart the service $ systemctl --user start jukebox-daemon ``` -To try different configurations, you can start the Jukebox with a custom config file. + +To try different configurations, you can start the Jukebox with a custom config file. This could be useful if you want your Jukebox to only allow a lower volume when started -at nighttime, signaling it's time to go to bed. :-) +at nighttime, signaling it's time to go to bed. :-) The path to the custom config file must be either absolute or relative to the folder `src/jukebox/`. ```bash diff --git a/documentation/builders/event-devices.md b/documentation/builders/event-devices.md new file mode 100644 index 000000000..95e871430 --- /dev/null +++ b/documentation/builders/event-devices.md @@ -0,0 +1,124 @@ +# Event devices + +## Background + +Event devices are generic input devices that are exposed in `/dev/input`. +This includes USB peripherals (Keyboards, Controllers, Joysticks or Mouse) as well as potentially bluetooth devices. + +A specific usecase for this could be, if a Zero Delay Arcade USB Encoder is used to wire arcade buttons instead of using GPIO pins. + +These device interface support various kinds of input events, such as press, movements and potentially also outputs (eg. rumble, led lights, ...). Currently only the usage of button presses as input is supported. + +This functionality was previously implemented under the name of [USB buttons](https://github.com/MiczFlor/RPi-Jukebox-RFID/blob/develop/components/controls/buttons_usb_encoder/README.md). + +The devices and their button mappings need to be mapped in the configuration file. + +## Configuration + +To configure event devices, first add the plugin as an entry to the module list of your main configuration file ``shared/settings/jukebox.yaml``: + +``` yaml +modules: + named: + event_devices: controls.event_devices +``` + +And add the following section with the plugin specific configuration: + +``` yaml +evdev: + enabled: true + config_file: ../../shared/settings/evdev.yaml +``` + +The actual configuration itself is stored in a separate file. In this case in ``../../shared/settings/evdev.yaml``. + +The configuration is structured akin to the configuration of the [GPIO devices](./gpio.md). + +In contrast to `gpio`, multiple devices (eg arcade controllser, keyboards, joysticks, mice, ...) are supported, each with their own `input_devices` (=buttons). `output_devices` or actions other than `on_press` are currently not yet supported. + +``` yaml +devices: # list of devices to listen for + {device nickname}: # config for a specific device + device_name: {device_name} # name of the device + exact: False/True # optional to require exact match. Otherwise it is sufficient that a part of the name matches + input_devices: # list of buttons to listen for for this device + {button nickname}: + type: Button + kwargs: + key_code: {key-code}: # evdev event id + actions: + on_press: # Currently only the on_press action is supported + {rpc_command_definition} # eg `alias: toggle` +``` + +The `{device nickname}` is only for your own orientation and can be choosen freely. +For each device you need to figure out the `{device_name}` and the `{event_id}` corresponding to key strokes, as indicated in the sections below. + +### Identifying the `{device_name}` + +The `{device_name}` can be identified using the following Python snippet: + +``` Python +import evdev +devices = [evdev.InputDevice(path) for path in evdev.list_devices()] +for device in devices: + print(device.path, device.name, device.phys) +``` + +The output could be in the style of: + +```text +/dev/input/event1 Dell Dell USB Keyboard usb-0000:00:12.1-2/input0 +/dev/input/event0 Dell USB Optical Mouse usb-0000:00:12.0-2/input0 +``` + +In this example, the `{device_name}` could be `DELL USB Optical Mouse`. +Note that if you use the option `exact: False`, it would be sufficient to add a substring such as `USB Keyboard`. + +### Identifying the `{key-code}` + +The key code for a button press can be determined using the following code snippet: + +``` Python +import evdev +device = evdev.InputDevice('/dev/input/event0') +device.capabilities(verbose=True)[('EV_KEY', evdev.ecodes.EV_KEY)] +``` + +With the `InputDevice` corresponding to the path from the output of the section `{device_name}` (eg. in the example `/dev/input/event0` +would correspond to `Dell Dell USB Keyboard`). + +If the naming is not clear, it is also possible to empirically check for the key code by listening for events: + +``` Python +from evdev import InputDevice, categorize, ecodes +dev = InputDevice('/dev/input/event1') +print(dev) +for event in dev.read_loop(): + if event.type == ecodes.EV_KEY: + print(categorize(event)) +``` + +The output could be of the form: + +```text +device /dev/input/event1, name "DragonRise Inc. Generic USB Joystick ", phys "usb-3f980000.usb-1.2/input0" +key event at 1672569673.124168, 297 (BTN_BASE4), down +key event at 1672569673.385170, 297 (BTN_BASE4), up +``` + +In this example output, the `{key-code}` would be `297` + +Alternatively, the device could also be setup without a mapping. +Afterwards, when pressing keys, the key codes can be found in the log files. Press various buttons on your device, +while watching the logs with `tail -f shared/logs/app.log`. +Look for entries like `No callback registered for button ...`. + +### Specifying the `{rpc_command_definition}` + +The RPC command follows the regular RPC command rules as defined in the [following documentation](./rpc-commands.md). + +## Full example config + +A complete configuration example for a USB Joystick controller can be found in the [examples](../../resources/default-settings/evdev.example.yaml). diff --git a/documentation/builders/gpio.md b/documentation/builders/gpio.md index 9d496c6b7..d0d82b206 100644 --- a/documentation/builders/gpio.md +++ b/documentation/builders/gpio.md @@ -112,7 +112,7 @@ A button to shutdown the Jukebox if it is pressed for more than 3 seconds. Note ```yml input_devices: - IncreaseVolume: + Shutdown: type: LongPressButton kwargs: pin: 3 diff --git a/documentation/builders/installation.md b/documentation/builders/installation.md index 7cd321b7a..2228ee47f 100644 --- a/documentation/builders/installation.md +++ b/documentation/builders/installation.md @@ -3,7 +3,7 @@ ## Install Raspberry Pi OS Lite > [!IMPORTANT] -> All Raspberry Pi models are supported. For sufficient performance, **we recommend Pi 2, 3 or Zero 2** (`ARMv7` models). Because Pi 1 or Zero 1 (`ARMv6` models) have limited resources, they are slower (during installation and start up procedure) and might require a bit more work! Pi 4 and 5 are an excess ;-) +> All Raspberry Pi models are supported. For sufficient performance, **we recommend Pi 2, 3 or Zero 2** (`ARMv7` models). Because Pi 1 or Zero 1 (`ARMv6` models) have limited resources, they are slower (during installation and start up procedure) and might require a bit more work! Pi 4 and 5 are an excess ;-) Before you can install the Phoniebox software, you need to prepare your Raspberry Pi. @@ -27,8 +27,8 @@ Before you can install the Phoniebox software, you need to prepare your Raspberr 8. Confirm the next warning about erasing the SD card with `Yes` 9. Wait for the imaging process to be finished (it'll take a few minutes) - ### Pre-boot preparation +
In case you forgot to customize the OS settings, follow these instructions after RPi OS has been written to the SD card. @@ -81,8 +81,9 @@ You will need a terminal, like PuTTY for Windows or the Terminal app for Mac to ### Pre-install preparation / workarounds #### Network management since Bookworm +
-With Bookworm, network management has changed. Now, "NetworkManager" is used instead of "dhcpcd". +With Bookworm, network management has changed. Now, "NetworkManager" is used instead of "dhcpcd". Both methods are supported during installation, but "NetworkManager" is recommended as it is simpler to set up and use. For Bullseye, this can also be activated, though it requires a manual process before running the installation. @@ -91,24 +92,28 @@ If the settings are changed, your network will reset, and WiFi will not be confi Therefore, make sure you use a wired connection or perform the following steps in a local terminal with a connected monitor and keyboard. Change network config + * run `sudo raspi-config` * select `6 - Advanced Options` * select `AA - Network Config` * select `NetworkManager` If you need Wifi, add the information now + * select `1 - System Options` * select `1 - Wireless LAN` * enter Wifi information +
#### Workaround for 64-bit Kernels (Pi 4 and newer) +
The installation process checks if a 32-bit OS is running, as 64-bit is currently not supported. This check also fails if the kernel is running in 64-bit mode. This is the default for Raspberry Pi models 4 and newer. -To be able to run the installation, you have to switch to the 32-bit mode by modifying the `config.txt` and add/change the line `arm_64bit=0`. +To be able to run the installation, you have to switch to the 32-bit mode by modifying the `config.txt` and add/change the line `arm_64bit=0`. Up to Bullseye, the `config.txt` file is located at `/boot/`. Since Bookworm, the location changed to `/boot/firmware/` ([see here](https://www.raspberrypi.com/documentation/computers/config_txt.html)). Reboot before you proceed. @@ -117,6 +122,7 @@ Reboot before you proceed. ## Install Phoniebox software Choose a version, run the corresponding install command in your SSH terminal and follow the instructions. + * [Stable Release](#stable-release) * [Pre-Release](#pre-release) * [Development](#development) @@ -127,6 +133,7 @@ After a successful installation, [configure your Phoniebox](configuration.md). > Depending on your hardware, this installation might last around 60 minutes (usually it's faster, 20-30 min). It updates OS packages, installs Phoniebox dependencies and applies settings. Be patient and don't let your computer go to sleep. It might disconnect your SSH connection causing the interruption of the installation process. Consider starting the installation in a terminal multiplexer like 'screen' or 'tmux' to avoid this. ### Stable Release + This will install the latest **stable release** from the *future3/main* branch. ```bash @@ -134,6 +141,7 @@ cd; bash <(wget -qO- https://raw.githubusercontent.com/MiczFlor/RPi-Jukebox-RFID ``` ### Pre-Release + This will install the latest **pre-release** from the *future3/develop* branch. ```bash @@ -141,6 +149,7 @@ cd; GIT_BRANCH='future3/develop' bash <(wget -qO- https://raw.githubusercontent. ``` ### Development + You can also install a specific branch and/or a fork repository. Update the variables to refer to your desired location. (The URL must not necessarily be updated, unless you have actually updated the file being downloaded.) > [!IMPORTANT] @@ -155,9 +164,9 @@ cd; GIT_USER='MiczFlor' GIT_BRANCH='future3/develop' bash <(wget -qO- https://ra > If you install another branch or from a fork repository, the Web App needs to be built locally. This is part of the installation process. See the the developers [Web App](../developers/webapp.md) documentation for further details. ### Logs + To follow the installation closely, use this command in another terminal. ```bash cd; tail -f INSTALL-.log ``` - diff --git a/documentation/builders/samba.md b/documentation/builders/samba.md index ac9a93bbc..8e486a181 100644 --- a/documentation/builders/samba.md +++ b/documentation/builders/samba.md @@ -4,15 +4,14 @@ To conveniently copy files to your Phoniebox via network `samba` can be configur ## Connect -To access the share open your OS network environment and select your Phoniebox device. +To access the share open your OS network environment and select your Phoniebox device. Alternatively directly access it via url with the file explorer (e.g. Windows `\\`, MacOS `smb://`). See also + * [MacOS](https://support.apple.com/lt-lt/guide/mac-help/mchlp1140/mac) ## User name / Password As login credentials use the same username you used to run the installation with. The password is `raspberry`. You can change the password anytime using the command `sudo smbpasswd -a ""`. - - diff --git a/documentation/builders/troubleshooting.md b/documentation/builders/troubleshooting.md index 5b4061aa8..a18272afb 100644 --- a/documentation/builders/troubleshooting.md +++ b/documentation/builders/troubleshooting.md @@ -47,10 +47,10 @@ The default logging config does 2 things: 1. It writes 2 log files: -```bash -shared/logs/app.log : Complete Debug Messages -shared/logs/errors.log : Only Errors and Warnings -``` + ```bash + shared/logs/app.log : Complete Debug Messages + shared/logs/errors.log : Only Errors and Warnings + ``` 2. Prints logging messages to the console. If run as a service, only error messages are emitted to console to avoid spamming the system log files. diff --git a/documentation/builders/update.md b/documentation/builders/update.md index cb919492a..6a9004625 100644 --- a/documentation/builders/update.md +++ b/documentation/builders/update.md @@ -12,6 +12,100 @@ Restore your old files after the new installation was successful and check if ne $ diff shared/settings/jukebox.yaml resources/default-settings/jukebox.default.yaml ``` +## Manually upgrade to the latest version + +> [!CAUTION] +> This documentation is only recommended for users running on `future3/develop` branch. For optimal system updates, it is strongly recommended to utilize the upgrade feature when transitioning to the next version (The Upgrade Feature will come in the future [#2304](https://github.com/MiczFlor/RPi-Jukebox-RFID/issues/2304)). Manual updates may necessitate specific migration steps and, if overlooked, could result in system failure. Please use these steps with caution. + +If you only want to update a few recent commits, this following explanation outlines the steps to do so + +Typically, 4 steps need to be considered + +1. Backup Local Changes (Optional) +1. Pull the latest version from Github +1. Replace the Web App with the most recent build +1. Optional: Update the config files + +### Fetch the most recent version from Github + +First, SSH into your Phoniebox. + +```bash +cd ~/RPi-Jukebox-RFID/ +``` + +Second, get the latest version from Github. Depending on your proficiency with Git, you can also checkout a specific branch or version. +Be aware, in case you have made changes to the software, stash them to keep them safe. + +1. Backup Local Changes (Optional): + - Stash your local changes: + + ```bash + git stash push -m "Backup before pull" + ``` + + - Create a Backup Branch (and potentially delete it in case it already exists): + + ```bash + git branch -D backup-before-pull + git branch backup-before-pull + ``` + +1. Pull Latest Changes: + + ```bash + git pull + ``` + +1. Update Web App: + 1. Backup the current webapp build + + ```bash + cd ~/RPi-Jukebox-RFID/src/webapp + rm -rf build-backup + mv build build-backup + ``` + + 1. Go to the [Github Release page](https://github.com/MiczFlor/RPi-Jukebox-RFID/releases) find the latest `Pre-release` release (typically Alpha). + 1. Under "Assets", find the latest Web App release called "webapp-build-latest.tar.gz" and copy the URL. + 1. On your Phoniebox, download the file and extract the archive. Afterwards, delete the archive + + ```bash + wget {URL} + tar -xzf webapp-build-latest.tar.gz + rm -rf webapp-build-latest.tar.gz + ``` + +1. Reboot the Phoniebox: + + ```bash + sudo reboot + ``` + +1. Verify the version of your Phoniebox in the settings tab. + +Revert to Backup If Needed: + +- Checkout the backup branch: + + ```bash + git checkout backup-before-pull + ``` + +- Reapply stashed changes (if any): + + ```bash + git stash pop + ``` + +- Revert Web App: + + ```bash + cd ~/RPi-Jukebox-RFID/src/webapp + rm -rf build + mv build-backup build + ``` + ## Migration Path from Version 2 There is no update path coming from Version 2.x of the Jukebox. diff --git a/documentation/builders/webapp/cover-art.md b/documentation/builders/webapp/cover-art.md new file mode 100644 index 000000000..09bae3a30 --- /dev/null +++ b/documentation/builders/webapp/cover-art.md @@ -0,0 +1,37 @@ +# Cover Art + +## Enable/Disable Cover Art + +The Web App automatically searches for cover art for albums and songs. If it finds cover art, it displays it; if not, it shows a placeholder image. However, you may prefer to disable cover art (e.g. in situations where device performance is low; screen space is limited; etc). There are two ways to do this: + +1. **Web App Settings**: Go to the "Settings" tab. Under the "General" section, find and toggle the "Show Cover Art" option. +1. **Configuration File**: Open the `jukebox.yaml` file. Navigate to `webapp` -> `show_covers`. Set this value to `true` to enable or `false` to disable cover art display. If this option does not exist, it assumes `true` as a default. + +## Providing Additional Cover Art + +Cover art can be provided in two ways: 1) embedded within the audio file itself, or 2) as a separate image file in the same directory as the audio file. The software searches for cover art in the order listed. + +To add cover art using the file system, place a file named `cover.jpg` in the same folder as your audio file or album. Accepted image file types are `jpg` and `png`. + +### Example + +Suppose none of your files currently include embedded cover art, the example below demonstrates how to enable cover art for an entire folder, applying the same cover art to all files within that folder. + +> [!IMPORTANT] +> You cannot assign different cover arts to different tracks within the same folder. + +#### Example Folder Structure + +```text +└── audiofolders + ├── Simone Sommerland + │ ├── 01 Aramsamsam.mp3 + │ ├── 02 Das Rote Pferd.mp3 + │ ├── 03 Hoch am Himmel.mp3 + │ └── cover.jpg <- Cover Art file as JPG + └── Bibi und Tina + ├── 01 Bibi und Tina Song.mp3 + ├── 02 Alles geht.mp3 + ├── 03 Solange dein Herz spricht.mp3 + └── cover.png <- Cover Art file as PNG +``` diff --git a/documentation/builders/webapp/playlists-livestreams-podcasts.md b/documentation/builders/webapp/playlists-livestreams-podcasts.md index 2b8be79a1..f45969e6e 100644 --- a/documentation/builders/webapp/playlists-livestreams-podcasts.md +++ b/documentation/builders/webapp/playlists-livestreams-podcasts.md @@ -3,11 +3,13 @@ By default, the Jukebox represents music based on its metadata like album name, artist or song name. The hierarchy and order of songs is determined by their original definition, e.g. order of songs within an album. If you prefer a specific list of songs to be played, you can use playlists (files ending with `*.m3u`). Jukebox also supports livestreams and podcasts (if connected to the internet) through playlists. ## Playlists + If you like the Jukebox to play songs in a pre-defined order, you can use .m3u playlists. A .m3u playlist is a plain text file that contains a list of file paths or URLs to multimedia files. Each entry in the playlist represents a single song, and they are listed in the order in which they should be played. ### Structure of a .m3u playlist + A .m3u playlist is a simple text document with each song file listed on a separate line. Each entry is optionally preceded by a comment line that starts with a '#' symbol. The actual file paths or URLs of the media files come after the comment. ### Creating a .m3u playlist @@ -18,7 +20,7 @@ A .m3u playlist is a simple text document with each song file listed on a separa 1. On the following lines, list the file paths or URLs of the media files you want to include in the playlist, one per line. They must refer to true files paths on your Jukebox. They can be relative or absolute paths. 1. Save the file with the .m3u extension, e.g. `my_playlist.m3u`. -``` +```text # Absolute /home//RPi-Jukebox-RFID/shared/audiofolders/Simone Sommerland/Die 30 besten Kindergartenlieder/08 - Pitsch, patsch, Pinguin.mp3 /home//RPi-Jukebox-RFID/shared/audiofolders/Simone Sommerland/Die 30 besten Spiel- Und Bewegungslieder/12 - Das rote Pferd.mp3 @@ -42,7 +44,7 @@ Based on the note above, we suggest to use m3u playlists like this, especially i #### Example folder structure -``` +```text └── audiofolders ├── wake-up-songs │ └── playlist.m3u @@ -74,9 +76,9 @@ In order to play radio livestreams on your Jukebox, you use playlists to registe You can now assign livestreams to cards [following the example](#assigning-a-m3u-playlist-to-a-card) of playlists. -#### Example folder structure and playlist names +#### Example folder structure and playlist names for livestreams -``` +```text └── audiofolders ├── wdr-kids │ └── wdr-kids-livestream.txt @@ -108,13 +110,13 @@ We will explain options 1 and 2 more closely. ### Using podcast.txt playlist in Jukebox 1. [Follow the steps above](#using-m3u-playlists-in-jukebox) to add a playlist to your Jukebox (make sure you have created individual folders). -1. When creating the playlist file, make sure it's called or at least ends with `podcasts.txt` instead of `.m3u`. (Examples: `awesome-podcast.txt`, `podcast.txt`). +1. When creating the playlist file, make sure it's called or at least ends with `podcast.txt` instead of `.m3u`. (Examples: `awesome-podcast.txt`, `podcast.txt`). 1. Add links to your individual podcast episodes just like you would with songs in .m3u playlists 1. As an alternative, you can provide a single RSS feed (XML). Jukebox will expand the file and refer to all episodes listed within this file. -#### Example folder structure and playlist names +#### Example folder structure and playlist names for podcasts -``` +```text └── audiofolders ├── die-maus │ └── die-maus-podcast.txt diff --git a/documentation/developers/README.md b/documentation/developers/README.md index 6addeaff1..412cc66a9 100644 --- a/documentation/developers/README.md +++ b/documentation/developers/README.md @@ -4,6 +4,7 @@ * [Development Environment](./development-environment.md) * [Python Development Notes](python.md) +* [Documentation (with Markdown)](documentation.md) ## Reference @@ -11,7 +12,7 @@ * [Web App](./webapp.md) * [RFID Readers](./rfid/README.md) * [Docstring API Docs (from py files)](./docstring/README.md) -* [Plugin Reference](./docstring/README.md#jukeboxplugs) +* [Plugin Reference](./docstring/README.md#jukebox.plugs) * [Feature Status](./status.md) * [Known Issues](./known-issues.md) diff --git a/documentation/developers/docker.md b/documentation/developers/docker.md index 827178aca..5f8b6f845 100644 --- a/documentation/developers/docker.md +++ b/documentation/developers/docker.md @@ -20,14 +20,14 @@ need to adapt some of those commands to your needs. 2. Pull the Jukebox repository: ```bash - $ git clone https://github.com/MiczFlor/RPi-Jukebox-RFID.git + git clone https://github.com/MiczFlor/RPi-Jukebox-RFID.git ``` 3. Create a jukebox.yaml file * Copy the `./resources/default-settings/jukebox.default.yaml` to `./shared/settings` and rename the file to `jukebox.yaml`. ```bash - $ cp ./resources/default-settings/jukebox.default.yaml ./shared/settings/jukebox.yaml + cp ./resources/default-settings/jukebox.default.yaml ./shared/settings/jukebox.yaml ``` * Override/Merge the values from the following [Override file](../../docker/config/jukebox.overrides.yaml) in your `jukebox.yaml`. @@ -39,121 +39,168 @@ need to adapt some of those commands to your needs. ## Run development environment -In contrary to how everything is set up on the Raspberry Pi, it\'s good +In contrary to how everything is set up on the Raspberry Pi, it's good practice to isolate different components in different Docker images. They can be run individually or in combination. To do that, we use `docker-compose`. ### Mac +
+ +See details + 1. [Install Docker & Compose (Mac)](https://docs.docker.com/docker-for-mac/install/) -2. Install pulseaudio +1. Install pulseaudio 1. Use Homebrew to install - ``` - $ brew install pulseaudio - ``` - 2. Enable pulseaudio network capabilities. In an editor, open `/opt/homebrew/Cellar/pulseaudio/16.1/etc/pulse/default.pa` (you might need to adapt this path to your own system settings). Uncomment the following line. - ``` - load-module module-native-protocol-tcp - ``` - 3. Restart the pulseaudio service - ``` - $ brew services restart pulseaudio - ``` - 4. If you have trouble with your audio, try these resources to troubleshoot: [[1]](https://gist.github.com/seongyongkim/b7d630a03e74c7ab1c6b53473b592712), [[2]](https://devops.datenkollektiv.de/running-a-docker-soundbox-on-mac.html), [[3]](https://stackoverflow.com/a/50939994/1062438) -> [!NOTE] -> In order for Pulseaudio to work properly with Docker on your Mac, you need to start Pulseaudio in a specific way. Otherwise MPD will throw an exception. See [Pulseaudio issues on Mac](#pulseaudio-issue-on-mac) for more info. + ```bash + brew install pulseaudio + ``` -``` bash -// Build Images -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.mac.yml build + 1. Enable pulseaudio network capabilities. In an editor, open `/opt/homebrew/Cellar/pulseaudio/16.1/etc/pulse/default.pa` (you might need to adapt this path to your own system settings). Uncomment the following line: -// Run Docker Environment -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.mac.yml up + ```text + load-module module-native-protocol-tcp + ``` -// Shuts down Docker containers and Docker network -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.mac.yml down -``` + 1. Restart the pulseaudio service + + ```bash + brew services restart pulseaudio + ``` + + 1. If you have trouble with your audio, try these resources to troubleshoot: [[1]](https://gist.github.com/seongyongkim/b7d630a03e74c7ab1c6b53473b592712), [[2]](https://devops.datenkollektiv.de/running-a-docker-soundbox-on-mac.html), [[3]](https://stackoverflow.com/a/50939994/1062438) + +1. Run `docker-compose` + + > [!NOTE] + > In order for Pulseaudio to work properly with Docker on your Mac, you need to start Pulseaudio in a specific way. Otherwise MPD will throw an exception. See [Pulseaudio issues on Mac](#pulseaudio-issue-on-mac) for more info. + + 1. Build libzmq for your host machine + + ```bash + docker build -f docker/Dockerfile.libzmq -t libzmq:local . + ``` + + 1. Build Images + + ```bash + docker-compose -f docker/docker-compose.yml -f docker/docker-compose.mac.yml build + ``` + + 1. Run Docker Environment -> Runs the entire Phoniebox environment + + ```bash + docker-compose -f docker/docker-compose.yml -f docker/docker-compose.mac.yml up + ``` + + * Shuts down Docker containers and Docker network + + ```bash + docker-compose -f docker/docker-compose.yml -f docker/docker-compose.mac.yml down + ``` + +
### Windows +
+ +See details + 1. Install [Docker & Compose (Windows)](https://docs.docker.com/docker-for-windows/install/) -2. Download [pulseaudio](https://www.freedesktop.org/wiki/Software/PulseAudio/Ports/Windows/Support/) +1. Download [pulseaudio](https://www.freedesktop.org/wiki/Software/PulseAudio/Ports/Windows/Support/) -3. Uncompress somewhere in your user folder +1. Uncompress somewhere in your user folder -4. Edit `$INSTALL_DIR/etc/pulse/default.pa` +1. Edit `$INSTALL_DIR/etc/pulse/default.pa` -5. Add the following line +1. Add the following line ``` bash load-module module-native-protocol-tcp auth-ip-acl=127.0.0.1 ``` -6. Edit `$INSTALL_DIR/etc/pulse//etc/pulse/daemon.conf`, find the +1. Edit `$INSTALL_DIR/etc/pulse/daemon.conf`, find the following line and change it to: ``` bash exit-idle-time = -1 ``` -7. Execute `$INSTALL_DIR/bin/pulseaudio.exe` +1. Execute `$INSTALL_DIR/bin/pulseaudio.exe` -8. Make sure Docker is running (e.g. start Docker Desktop) +1. Make sure Docker is running (e.g. start Docker Desktop) -9. Run `docker-compose` +1. Run `docker-compose` - ``` bash - // Build Images - $ docker-compose -f docker/docker-compose.yml build + 1. Build libzmq for your host machine - // Run Docker Environment - $ docker-compose -f docker/docker-compose.yml up + ```bash + docker build -f docker/Dockerfile.libzmq -t libzmq:local . + ``` - // Shuts down Docker containers and Docker network - $ docker-compose -f docker/docker-compose.yml down - ``` + 1. Build Images + + ```bash + docker-compose -f docker/docker-compose.yml build + ``` + + 1. Run Docker Environment -> Runs the entire Phoniebox environment + + ```bash + docker-compose -f docker/docker-compose.yml up + ``` + + * Shuts down Docker containers and Docker network + + ```bash + docker-compose -f docker/docker-compose.yml down + ``` + +
### Linux +
+ +See details + 1. Install Docker & Compose * [Docker](https://docs.docker.com/engine/install/debian/) * [Compose](https://docs.docker.com/compose/install/) -2. Make sure you don\'t use `sudo` to run your `docker-compose`. Check out +1. Make sure you don\'t use `sudo` to run your `docker-compose`. Check out Docker\'s [post-installation guide](https://docs.docker.com/engine/install/linux-postinstall/) for more information. -```bash -// Build Images -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.linux.yml build +1. Run `docker-compose` -// Run Docker Environment -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.linux.yml up + 1. Build libzmq for your host machine -// Shuts down Docker containers and Docker network -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.linux.yml down -``` + ```bash + docker build -f docker/Dockerfile.libzmq -t libzmq:local . + ``` -Note: if you have `mpd` running on your system, you need to stop it -using: + 1. Build Images -``` bash -$ sudo systemctl stop mpd.socket -$ sudo mpd --kill -``` + ```bash + docker-compose -f docker/docker-compose.yml -f docker/docker-compose.linux.yml build + ``` -Otherwise you might get the error message: + 1. Run Docker Environment -> Runs the entire Phoniebox environment -``` bash -$ docker-compose -f docker-compose.yml -f docker-compose.linux.yml up -Starting mpd ... -Starting mpd ... error -(...) -Error starting userland proxy: listen tcp4 0.0.0.0:6600: bind: address already in use -``` + ```bash + docker-compose -f docker/docker-compose.yml -f docker/docker-compose.linux.yml up + ``` -Read these threads for details: [thread 1](https://unix.stackexchange.com/questions/456909/socket-already-in-use-but-is-not-listed-mpd) and [thread 2](https://stackoverflow.com/questions/5106674/error-address-already-in-use-while-binding-socket-with-address-but-the-port-num/5106755#5106755) + * Shuts down Docker containers and Docker network + + ```bash + docker-compose -f docker/docker-compose.yml -f docker/docker-compose.linux.yml down + ``` + +
## Test & Develop @@ -169,7 +216,7 @@ restart your `jukebox` container. Update the below path with your specific host environment. ``` bash -$ docker-compose -f docker/docker-compose.yml -f docker/docker-compose.[ENVIRONMENT].yml restart jukebox +docker-compose -f docker/docker-compose.yml -f docker/docker-compose.[ENVIRONMENT].yml restart jukebox ``` ## Known issues @@ -183,13 +230,28 @@ would be of course useful to get rid of them, but currently we make a trade-off between a development environment and solving the specific details. +### Error when local libzmq Dockerfile has not been built: + +``` bash +------ + > [jukebox internal] load metadata for docker.io/library/libzmq:local: +------ +failed to solve: libzmq:local: pull access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failed +``` + +Build libzmq for your host machine + +``` bash +docker build -f docker/Dockerfile.libzmq -t libzmq:local . +``` + ### `mpd` container #### Pulseaudio issue on Mac If you notice the following exception while running MPD in Docker, it refers to a incorrect setup of your Mac host Pulseaudio. -``` +```text mpd | ALSA lib pulse.c:242:(pulse_connect) PulseAudio: Unable to connect: Connection refused mpd | exception: Failed to read mixer for 'Global ALSA->Pulse stream': failed to attach to pulse: Connection refused ``` @@ -197,15 +259,20 @@ mpd | exception: Failed to read mixer for 'Global ALSA->Pulse stream': fail To fix the issue, try the following. 1. Stop your Pulseaudio service - ``` + + ```bash brew service stop pulseaudio ``` -2. Start Pulseaudio with this command - ``` + +1. Start Pulseaudio with this command + + ```bash pulseaudio --load=module-native-protocol-tcp --exit-idle-time=-1 --daemon ``` -3. Check if daemon is working - ``` + +1. Check if daemon is working + + ```bash pulseaudio --check -v ``` @@ -213,9 +280,28 @@ Everything else should have been set up properly as a [prerequisite](#mac) * [Source](https://gist.github.com/seongyongkim/b7d630a03e74c7ab1c6b53473b592712) +#### `mpd` issues on Linux +If you have `mpd` running on your system, you need to stop it using: -#### Other error messages +``` bash +sudo systemctl stop mpd.socket +sudo mpd --kill +``` + +Otherwise you might get the error message: + +``` bash +docker-compose -f docker-compose.yml -f docker-compose.linux.yml up +Starting mpd ... +Starting mpd ... error +(...) +Error starting userland proxy: listen tcp4 0.0.0.0:6600: bind: address already in use +``` + +Read these threads for details: [thread 1](https://unix.stackexchange.com/questions/456909/socket-already-in-use-but-is-not-listed-mpd) and [thread 2](https://stackoverflow.com/questions/5106674/error-address-already-in-use-while-binding-socket-with-address-but-the-port-num/5106755#5106755) + +#### MPD issues When starting the `mpd` container, you will see the following errors. You can ignore them, MPD will run. @@ -238,7 +324,7 @@ mpd | alsa_mixer: snd_mixer_handle_events() failed: Input/output error mpd | exception: Failed to read mixer for 'My ALSA Device': snd_mixer_handle_events() failed: Input/output error ``` -### `jukebox` container +#### `jukebox` container Many features of the Phoniebox are based on the Raspberry Pi hardware. This hardware can\'t be mocked in a virtual Docker environment. As a @@ -265,12 +351,11 @@ jukebox | 319:server.py - jb.pub.server - host.timer.cputemp If you encounter the following error, refer to [Pulseaudio issues on Mac](#pulseaudio-issue-on-mac). -``` +``` bash jukebox | 21.12.2023 08:50:09 - 629:plugs.py - jb.plugin - MainThread - ERROR - Ignoring failed package load finalizer: 'volume.finalize()' jukebox | 21.12.2023 08:50:09 - 630:plugs.py - jb.plugin - MainThread - ERROR - Reason: NameError: name 'pulse_control' is not defined ``` - ## Appendix ### Individual Docker Image @@ -281,8 +366,8 @@ run `mpd` or `webapp`. The following command can be run on a Mac. ``` bash -$ docker build -f docker/jukebox.Dockerfile -t jukebox . -$ docker run -it --rm \ +docker build -f docker/Dockerfile.jukebox -t jukebox . +docker run -it --rm \ -v $(PWD)/src/jukebox:/home/pi/RPi-Jukebox-RFID/src/jukebox \ -v $(PWD)/shared/audiofolders:/home/pi/RPi-Jukebox-RFID/shared/audiofolders \ -v ~/.config/pulse:/root/.config/pulse \ @@ -291,6 +376,19 @@ $ docker run -it --rm \ --name jukebox jukebox ``` +## Testing ``evdev`` devices in Linux + +To test the [event device capabilities](../builders/event-devices.md) in docker, the device needs to be made available to the container. + +Mount the device into the container by configuring the appropriate device in a `devices` section of the `jukebox` service in the docker compose file. For example: + +```yaml + jukebox: + ... + devices: + - /dev/input/event3:/dev/input/event3 +``` + ### Resources #### Mac diff --git a/documentation/developers/docstring/README.md b/documentation/developers/docstring/README.md index aa7cefecc..fd3655524 100644 --- a/documentation/developers/docstring/README.md +++ b/documentation/developers/docstring/README.md @@ -3,61 +3,32 @@ ## Table of Contents * [run\_jukebox](#run_jukebox) -* [\_\_init\_\_](#__init__) -* [run\_register\_rfid\_reader](#run_register_rfid_reader) * [run\_rpc\_tool](#run_rpc_tool) * [get\_common\_beginning](#run_rpc_tool.get_common_beginning) * [runcmd](#run_rpc_tool.runcmd) -* [run\_configure\_audio](#run_configure_audio) +* [run\_register\_rfid\_reader](#run_register_rfid_reader) * [run\_publicity\_sniffer](#run_publicity_sniffer) -* [misc](#misc) - * [recursive\_chmod](#misc.recursive_chmod) - * [flatten](#misc.flatten) - * [getattr\_hierarchical](#misc.getattr_hierarchical) -* [misc.inputminus](#misc.inputminus) - * [input\_int](#misc.inputminus.input_int) - * [input\_yesno](#misc.inputminus.input_yesno) -* [misc.loggingext](#misc.loggingext) - * [ColorFilter](#misc.loggingext.ColorFilter) - * [\_\_init\_\_](#misc.loggingext.ColorFilter.__init__) - * [PubStream](#misc.loggingext.PubStream) - * [PubStreamHandler](#misc.loggingext.PubStreamHandler) -* [misc.simplecolors](#misc.simplecolors) - * [Colors](#misc.simplecolors.Colors) - * [resolve](#misc.simplecolors.resolve) - * [print](#misc.simplecolors.print) +* [\_\_init\_\_](#__init__) +* [run\_configure\_audio](#run_configure_audio) * [components](#components) -* [components.playermpd.playcontentcallback](#components.playermpd.playcontentcallback) - * [PlayContentCallbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks) - * [register](#components.playermpd.playcontentcallback.PlayContentCallbacks.register) - * [run\_callbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks.run_callbacks) -* [components.playermpd](#components.playermpd) - * [PlayerMPD](#components.playermpd.PlayerMPD) - * [mpd\_retry\_with\_mutex](#components.playermpd.PlayerMPD.mpd_retry_with_mutex) - * [pause](#components.playermpd.PlayerMPD.pause) - * [next](#components.playermpd.PlayerMPD.next) - * [rewind](#components.playermpd.PlayerMPD.rewind) - * [replay](#components.playermpd.PlayerMPD.replay) - * [toggle](#components.playermpd.PlayerMPD.toggle) - * [replay\_if\_stopped](#components.playermpd.PlayerMPD.replay_if_stopped) - * [play\_card](#components.playermpd.PlayerMPD.play_card) - * [get\_single\_coverart](#components.playermpd.PlayerMPD.get_single_coverart) - * [get\_folder\_content](#components.playermpd.PlayerMPD.get_folder_content) - * [play\_folder](#components.playermpd.PlayerMPD.play_folder) - * [play\_album](#components.playermpd.PlayerMPD.play_album) - * [get\_volume](#components.playermpd.PlayerMPD.get_volume) - * [set\_volume](#components.playermpd.PlayerMPD.set_volume) - * [play\_card\_callbacks](#components.playermpd.play_card_callbacks) -* [components.playermpd.coverart\_cache\_manager](#components.playermpd.coverart_cache_manager) -* [components.rpc\_command\_alias](#components.rpc_command_alias) -* [components.synchronisation.rfidcards](#components.synchronisation.rfidcards) - * [SyncRfidcards](#components.synchronisation.rfidcards.SyncRfidcards) - * [sync\_change\_on\_rfid\_scan](#components.synchronisation.rfidcards.SyncRfidcards.sync_change_on_rfid_scan) - * [sync\_all](#components.synchronisation.rfidcards.SyncRfidcards.sync_all) - * [sync\_card\_database](#components.synchronisation.rfidcards.SyncRfidcards.sync_card_database) - * [sync\_folder](#components.synchronisation.rfidcards.SyncRfidcards.sync_folder) -* [components.synchronisation](#components.synchronisation) -* [components.synchronisation.syncutils](#components.synchronisation.syncutils) +* [components.mqtt.utils](#components.mqtt.utils) + * [play\_folder\_recursive\_args](#components.mqtt.utils.play_folder_recursive_args) + * [parse\_repeat\_mode](#components.mqtt.utils.parse_repeat_mode) + * [get\_args](#components.mqtt.utils.get_args) + * [get\_rpc\_command](#components.mqtt.utils.get_rpc_command) + * [get\_kwargs](#components.mqtt.utils.get_kwargs) + * [get\_current\_time\_milli](#components.mqtt.utils.get_current_time_milli) + * [split\_topic](#components.mqtt.utils.split_topic) + * [map\_repeat\_mode](#components.mqtt.utils.map_repeat_mode) +* [components.mqtt.mqtt\_command\_alias](#components.mqtt.mqtt_command_alias) + * [get\_mute](#components.mqtt.mqtt_command_alias.get_mute) +* [components.mqtt.mqtt\_const](#components.mqtt.mqtt_const) +* [components.mqtt](#components.mqtt) + * [MQTT](#components.mqtt.MQTT) + * [run](#components.mqtt.MQTT.run) + * [stop](#components.mqtt.MQTT.stop) + * [on\_connect](#components.mqtt.on_connect) + * [initialize](#components.mqtt.initialize) * [components.volume](#components.volume) * [PulseMonitor](#components.volume.PulseMonitor) * [SoundCardConnectCallbacks](#components.volume.PulseMonitor.SoundCardConnectCallbacks) @@ -81,63 +52,20 @@ * [set\_soft\_max\_volume](#components.volume.PulseVolumeControl.set_soft_max_volume) * [get\_soft\_max\_volume](#components.volume.PulseVolumeControl.get_soft_max_volume) * [card\_list](#components.volume.PulseVolumeControl.card_list) -* [components.rfid](#components.rfid) -* [components.rfid.reader](#components.rfid.reader) - * [RfidCardDetectCallbacks](#components.rfid.reader.RfidCardDetectCallbacks) - * [register](#components.rfid.reader.RfidCardDetectCallbacks.register) - * [run\_callbacks](#components.rfid.reader.RfidCardDetectCallbacks.run_callbacks) - * [rfid\_card\_detect\_callbacks](#components.rfid.reader.rfid_card_detect_callbacks) - * [CardRemovalTimerClass](#components.rfid.reader.CardRemovalTimerClass) - * [\_\_init\_\_](#components.rfid.reader.CardRemovalTimerClass.__init__) -* [components.rfid.configure](#components.rfid.configure) - * [reader\_install\_dependencies](#components.rfid.configure.reader_install_dependencies) - * [reader\_load\_module](#components.rfid.configure.reader_load_module) - * [query\_user\_for\_reader](#components.rfid.configure.query_user_for_reader) - * [write\_config](#components.rfid.configure.write_config) -* [components.rfid.hardware.fake\_reader\_gui.fake\_reader\_gui](#components.rfid.hardware.fake_reader_gui.fake_reader_gui) -* [components.rfid.hardware.fake\_reader\_gui.description](#components.rfid.hardware.fake_reader_gui.description) -* [components.rfid.hardware.fake\_reader\_gui.gpioz\_gui\_addon](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon) - * [create\_inputs](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.create_inputs) - * [set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.set_state) - * [que\_set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.que_set_state) - * [fix\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.fix_state) - * [pbox\_set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.pbox_set_state) - * [que\_set\_pbox](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.que_set_pbox) - * [create\_outputs](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.create_outputs) -* [components.rfid.hardware.generic\_usb.description](#components.rfid.hardware.generic_usb.description) -* [components.rfid.hardware.generic\_usb.generic\_usb](#components.rfid.hardware.generic_usb.generic_usb) -* [components.rfid.hardware.rc522\_spi.description](#components.rfid.hardware.rc522_spi.description) -* [components.rfid.hardware.rc522\_spi.rc522\_spi](#components.rfid.hardware.rc522_spi.rc522_spi) -* [components.rfid.hardware.pn532\_i2c\_py532.description](#components.rfid.hardware.pn532_i2c_py532.description) -* [components.rfid.hardware.pn532\_i2c\_py532.pn532\_i2c\_py532](#components.rfid.hardware.pn532_i2c_py532.pn532_i2c_py532) -* [components.rfid.hardware.rdm6300\_serial.rdm6300\_serial](#components.rfid.hardware.rdm6300_serial.rdm6300_serial) - * [decode](#components.rfid.hardware.rdm6300_serial.rdm6300_serial.decode) -* [components.rfid.hardware.rdm6300\_serial.description](#components.rfid.hardware.rdm6300_serial.description) -* [components.rfid.hardware.template\_new\_reader.description](#components.rfid.hardware.template_new_reader.description) -* [components.rfid.hardware.template\_new\_reader.template\_new\_reader](#components.rfid.hardware.template_new_reader.template_new_reader) - * [query\_customization](#components.rfid.hardware.template_new_reader.template_new_reader.query_customization) - * [ReaderClass](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass) - * [\_\_init\_\_](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.__init__) - * [cleanup](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.cleanup) - * [stop](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.stop) - * [read\_card](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.read_card) -* [components.rfid.readerbase](#components.rfid.readerbase) - * [ReaderBaseClass](#components.rfid.readerbase.ReaderBaseClass) -* [components.rfid.cards](#components.rfid.cards) - * [list\_cards](#components.rfid.cards.list_cards) - * [delete\_card](#components.rfid.cards.delete_card) - * [register\_card](#components.rfid.cards.register_card) - * [register\_card\_custom](#components.rfid.cards.register_card_custom) - * [save\_card\_database](#components.rfid.cards.save_card_database) -* [components.rfid.cardutils](#components.rfid.cardutils) - * [decode\_card\_command](#components.rfid.cardutils.decode_card_command) - * [card\_command\_to\_str](#components.rfid.cardutils.card_command_to_str) - * [card\_to\_str](#components.rfid.cardutils.card_to_str) -* [components.publishing](#components.publishing) - * [republish](#components.publishing.republish) -* [components.player](#components.player) - * [MusicLibPath](#components.player.MusicLibPath) - * [get\_music\_library\_path](#components.player.get_music_library_path) +* [components.rpc\_command\_alias](#components.rpc_command_alias) +* [components.synchronisation](#components.synchronisation) +* [components.synchronisation.syncutils](#components.synchronisation.syncutils) +* [components.synchronisation.rfidcards](#components.synchronisation.rfidcards) + * [SyncRfidcards](#components.synchronisation.rfidcards.SyncRfidcards) + * [sync\_change\_on\_rfid\_scan](#components.synchronisation.rfidcards.SyncRfidcards.sync_change_on_rfid_scan) + * [sync\_all](#components.synchronisation.rfidcards.SyncRfidcards.sync_all) + * [sync\_card\_database](#components.synchronisation.rfidcards.SyncRfidcards.sync_card_database) + * [sync\_folder](#components.synchronisation.rfidcards.SyncRfidcards.sync_folder) +* [components.jingle.jinglemp3](#components.jingle.jinglemp3) + * [JingleMp3Play](#components.jingle.jinglemp3.JingleMp3Play) + * [play](#components.jingle.jinglemp3.JingleMp3Play.play) + * [JingleMp3PlayBuilder](#components.jingle.jinglemp3.JingleMp3PlayBuilder) + * [\_\_init\_\_](#components.jingle.jinglemp3.JingleMp3PlayBuilder.__init__) * [components.jingle](#components.jingle) * [JingleFactory](#components.jingle.JingleFactory) * [list](#components.jingle.JingleFactory.list) @@ -149,11 +77,6 @@ * [play](#components.jingle.alsawave.AlsaWave.play) * [AlsaWaveBuilder](#components.jingle.alsawave.AlsaWaveBuilder) * [\_\_init\_\_](#components.jingle.alsawave.AlsaWaveBuilder.__init__) -* [components.jingle.jinglemp3](#components.jingle.jinglemp3) - * [JingleMp3Play](#components.jingle.jinglemp3.JingleMp3Play) - * [play](#components.jingle.jinglemp3.JingleMp3Play.play) - * [JingleMp3PlayBuilder](#components.jingle.jinglemp3.JingleMp3PlayBuilder) - * [\_\_init\_\_](#components.jingle.jinglemp3.JingleMp3PlayBuilder.__init__) * [components.hostif.linux](#components.hostif.linux) * [shutdown](#components.hostif.linux.shutdown) * [reboot](#components.hostif.linux.reboot) @@ -167,60 +90,29 @@ * [get\_autohotspot\_status](#components.hostif.linux.get_autohotspot_status) * [stop\_autohotspot](#components.hostif.linux.stop_autohotspot) * [start\_autohotspot](#components.hostif.linux.start_autohotspot) -* [components.misc](#components.misc) - * [rpc\_cmd\_help](#components.misc.rpc_cmd_help) - * [get\_all\_loaded\_packages](#components.misc.get_all_loaded_packages) - * [get\_all\_failed\_packages](#components.misc.get_all_failed_packages) - * [get\_start\_time](#components.misc.get_start_time) - * [get\_log](#components.misc.get_log) - * [get\_log\_debug](#components.misc.get_log_debug) - * [get\_log\_error](#components.misc.get_log_error) - * [get\_git\_state](#components.misc.get_git_state) - * [empty\_rpc\_call](#components.misc.empty_rpc_call) -* [components.controls](#components.controls) -* [components.controls.bluetooth\_audio\_buttons](#components.controls.bluetooth_audio_buttons) -* [components.controls.common.evdev\_listener](#components.controls.common.evdev_listener) - * [find\_device](#components.controls.common.evdev_listener.find_device) - * [EvDevKeyListener](#components.controls.common.evdev_listener.EvDevKeyListener) - * [\_\_init\_\_](#components.controls.common.evdev_listener.EvDevKeyListener.__init__) - * [run](#components.controls.common.evdev_listener.EvDevKeyListener.run) - * [start](#components.controls.common.evdev_listener.EvDevKeyListener.start) -* [components.battery\_monitor](#components.battery_monitor) -* [components.battery\_monitor.BatteryMonitorBase](#components.battery_monitor.BatteryMonitorBase) - * [pt1\_frac](#components.battery_monitor.BatteryMonitorBase.pt1_frac) - * [BattmonBase](#components.battery_monitor.BatteryMonitorBase.BattmonBase) -* [components.battery\_monitor.batt\_mon\_simulator](#components.battery_monitor.batt_mon_simulator) - * [battmon\_simulator](#components.battery_monitor.batt_mon_simulator.battmon_simulator) -* [components.battery\_monitor.batt\_mon\_i2c\_ads1015](#components.battery_monitor.batt_mon_i2c_ads1015) - * [battmon\_ads1015](#components.battery_monitor.batt_mon_i2c_ads1015.battmon_ads1015) -* [components.gpio.gpioz.plugin](#components.gpio.gpioz.plugin) - * [output\_devices](#components.gpio.gpioz.plugin.output_devices) - * [input\_devices](#components.gpio.gpioz.plugin.input_devices) - * [factory](#components.gpio.gpioz.plugin.factory) - * [IS\_ENABLED](#components.gpio.gpioz.plugin.IS_ENABLED) - * [IS\_MOCKED](#components.gpio.gpioz.plugin.IS_MOCKED) - * [CONFIG\_FILE](#components.gpio.gpioz.plugin.CONFIG_FILE) - * [ServiceIsRunningCallbacks](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks) - * [register](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks.register) - * [run\_callbacks](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks.run_callbacks) - * [service\_is\_running\_callbacks](#components.gpio.gpioz.plugin.service_is_running_callbacks) - * [build\_output\_device](#components.gpio.gpioz.plugin.build_output_device) - * [build\_input\_device](#components.gpio.gpioz.plugin.build_input_device) - * [get\_output](#components.gpio.gpioz.plugin.get_output) - * [on](#components.gpio.gpioz.plugin.on) - * [off](#components.gpio.gpioz.plugin.off) - * [set\_value](#components.gpio.gpioz.plugin.set_value) - * [flash](#components.gpio.gpioz.plugin.flash) -* [components.gpio.gpioz.plugin.connectivity](#components.gpio.gpioz.plugin.connectivity) - * [BUZZ\_TONE](#components.gpio.gpioz.plugin.connectivity.BUZZ_TONE) - * [register\_rfid\_callback](#components.gpio.gpioz.plugin.connectivity.register_rfid_callback) - * [register\_status\_led\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_led_callback) - * [register\_status\_buzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_buzzer_callback) - * [register\_status\_tonalbuzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_tonalbuzzer_callback) - * [register\_audio\_sink\_change\_callback](#components.gpio.gpioz.plugin.connectivity.register_audio_sink_change_callback) - * [register\_volume\_led\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_led_callback) - * [register\_volume\_buzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_buzzer_callback) - * [register\_volume\_rgbled\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_rgbled_callback) +* [components.timers](#components.timers) +* [components.playermpd.coverart\_cache\_manager](#components.playermpd.coverart_cache_manager) +* [components.playermpd.playcontentcallback](#components.playermpd.playcontentcallback) + * [PlayContentCallbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks) + * [register](#components.playermpd.playcontentcallback.PlayContentCallbacks.register) + * [run\_callbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks.run_callbacks) +* [components.playermpd](#components.playermpd) + * [PlayerMPD](#components.playermpd.PlayerMPD) + * [mpd\_retry\_with\_mutex](#components.playermpd.PlayerMPD.mpd_retry_with_mutex) + * [pause](#components.playermpd.PlayerMPD.pause) + * [next](#components.playermpd.PlayerMPD.next) + * [rewind](#components.playermpd.PlayerMPD.rewind) + * [replay](#components.playermpd.PlayerMPD.replay) + * [toggle](#components.playermpd.PlayerMPD.toggle) + * [replay\_if\_stopped](#components.playermpd.PlayerMPD.replay_if_stopped) + * [play\_card](#components.playermpd.PlayerMPD.play_card) + * [flush\_coverart\_cache](#components.playermpd.PlayerMPD.flush_coverart_cache) + * [get\_folder\_content](#components.playermpd.PlayerMPD.get_folder_content) + * [play\_folder](#components.playermpd.PlayerMPD.play_folder) + * [play\_album](#components.playermpd.PlayerMPD.play_album) + * [get\_volume](#components.playermpd.PlayerMPD.get_volume) + * [set\_volume](#components.playermpd.PlayerMPD.set_volume) + * [play\_card\_callbacks](#components.playermpd.play_card_callbacks) * [components.gpio.gpioz.core.converter](#components.gpio.gpioz.core.converter) * [ColorProperty](#components.gpio.gpioz.core.converter.ColorProperty) * [VolumeToRGB](#components.gpio.gpioz.core.converter.VolumeToRGB) @@ -228,6 +120,18 @@ * [luminize](#components.gpio.gpioz.core.converter.VolumeToRGB.luminize) * [components.gpio.gpioz.core.mock](#components.gpio.gpioz.core.mock) * [patch\_mock\_outputs\_with\_callback](#components.gpio.gpioz.core.mock.patch_mock_outputs_with_callback) +* [components.gpio.gpioz.core.output\_devices](#components.gpio.gpioz.core.output_devices) + * [LED](#components.gpio.gpioz.core.output_devices.LED) + * [flash](#components.gpio.gpioz.core.output_devices.LED.flash) + * [Buzzer](#components.gpio.gpioz.core.output_devices.Buzzer) + * [flash](#components.gpio.gpioz.core.output_devices.Buzzer.flash) + * [PWMLED](#components.gpio.gpioz.core.output_devices.PWMLED) + * [flash](#components.gpio.gpioz.core.output_devices.PWMLED.flash) + * [RGBLED](#components.gpio.gpioz.core.output_devices.RGBLED) + * [flash](#components.gpio.gpioz.core.output_devices.RGBLED.flash) + * [TonalBuzzer](#components.gpio.gpioz.core.output_devices.TonalBuzzer) + * [flash](#components.gpio.gpioz.core.output_devices.TonalBuzzer.flash) + * [melody](#components.gpio.gpioz.core.output_devices.TonalBuzzer.melody) * [components.gpio.gpioz.core.input\_devices](#components.gpio.gpioz.core.input_devices) * [NameMixin](#components.gpio.gpioz.core.input_devices.NameMixin) * [set\_rpc\_actions](#components.gpio.gpioz.core.input_devices.NameMixin.set_rpc_actions) @@ -253,70 +157,139 @@ * [close](#components.gpio.gpioz.core.input_devices.TwinButton.close) * [value](#components.gpio.gpioz.core.input_devices.TwinButton.value) * [is\_active](#components.gpio.gpioz.core.input_devices.TwinButton.is_active) -* [components.gpio.gpioz.core.output\_devices](#components.gpio.gpioz.core.output_devices) - * [LED](#components.gpio.gpioz.core.output_devices.LED) - * [flash](#components.gpio.gpioz.core.output_devices.LED.flash) - * [Buzzer](#components.gpio.gpioz.core.output_devices.Buzzer) - * [flash](#components.gpio.gpioz.core.output_devices.Buzzer.flash) - * [PWMLED](#components.gpio.gpioz.core.output_devices.PWMLED) - * [flash](#components.gpio.gpioz.core.output_devices.PWMLED.flash) - * [RGBLED](#components.gpio.gpioz.core.output_devices.RGBLED) - * [flash](#components.gpio.gpioz.core.output_devices.RGBLED.flash) - * [TonalBuzzer](#components.gpio.gpioz.core.output_devices.TonalBuzzer) - * [flash](#components.gpio.gpioz.core.output_devices.TonalBuzzer.flash) - * [melody](#components.gpio.gpioz.core.output_devices.TonalBuzzer.melody) -* [components.timers](#components.timers) -* [jukebox](#jukebox) -* [jukebox.callingback](#jukebox.callingback) - * [CallbackHandler](#jukebox.callingback.CallbackHandler) - * [register](#jukebox.callingback.CallbackHandler.register) - * [run\_callbacks](#jukebox.callingback.CallbackHandler.run_callbacks) - * [has\_callbacks](#jukebox.callingback.CallbackHandler.has_callbacks) -* [jukebox.version](#jukebox.version) - * [version](#jukebox.version.version) - * [version\_info](#jukebox.version.version_info) -* [jukebox.cfghandler](#jukebox.cfghandler) - * [ConfigHandler](#jukebox.cfghandler.ConfigHandler) - * [loaded\_from](#jukebox.cfghandler.ConfigHandler.loaded_from) - * [get](#jukebox.cfghandler.ConfigHandler.get) - * [setdefault](#jukebox.cfghandler.ConfigHandler.setdefault) - * [getn](#jukebox.cfghandler.ConfigHandler.getn) - * [setn](#jukebox.cfghandler.ConfigHandler.setn) - * [setndefault](#jukebox.cfghandler.ConfigHandler.setndefault) - * [config\_dict](#jukebox.cfghandler.ConfigHandler.config_dict) - * [is\_modified](#jukebox.cfghandler.ConfigHandler.is_modified) - * [clear\_modified](#jukebox.cfghandler.ConfigHandler.clear_modified) - * [save](#jukebox.cfghandler.ConfigHandler.save) - * [load](#jukebox.cfghandler.ConfigHandler.load) - * [get\_handler](#jukebox.cfghandler.get_handler) - * [load\_yaml](#jukebox.cfghandler.load_yaml) - * [write\_yaml](#jukebox.cfghandler.write_yaml) -* [jukebox.playlistgenerator](#jukebox.playlistgenerator) - * [TYPE\_DECODE](#jukebox.playlistgenerator.TYPE_DECODE) - * [PlaylistCollector](#jukebox.playlistgenerator.PlaylistCollector) - * [\_\_init\_\_](#jukebox.playlistgenerator.PlaylistCollector.__init__) - * [set\_exclusion\_endings](#jukebox.playlistgenerator.PlaylistCollector.set_exclusion_endings) - * [get\_directory\_content](#jukebox.playlistgenerator.PlaylistCollector.get_directory_content) - * [parse](#jukebox.playlistgenerator.PlaylistCollector.parse) -* [jukebox.NvManager](#jukebox.NvManager) -* [jukebox.publishing](#jukebox.publishing) - * [get\_publisher](#jukebox.publishing.get_publisher) -* [jukebox.publishing.subscriber](#jukebox.publishing.subscriber) -* [jukebox.publishing.server](#jukebox.publishing.server) - * [PublishServer](#jukebox.publishing.server.PublishServer) - * [run](#jukebox.publishing.server.PublishServer.run) - * [handle\_message](#jukebox.publishing.server.PublishServer.handle_message) - * [handle\_subscription](#jukebox.publishing.server.PublishServer.handle_subscription) - * [Publisher](#jukebox.publishing.server.Publisher) - * [\_\_init\_\_](#jukebox.publishing.server.Publisher.__init__) - * [send](#jukebox.publishing.server.Publisher.send) - * [revoke](#jukebox.publishing.server.Publisher.revoke) - * [resend](#jukebox.publishing.server.Publisher.resend) - * [close\_server](#jukebox.publishing.server.Publisher.close_server) -* [jukebox.daemon](#jukebox.daemon) - * [log\_active\_threads](#jukebox.daemon.log_active_threads) - * [JukeBox](#jukebox.daemon.JukeBox) - * [signal\_handler](#jukebox.daemon.JukeBox.signal_handler) +* [components.gpio.gpioz.plugin.connectivity](#components.gpio.gpioz.plugin.connectivity) + * [BUZZ\_TONE](#components.gpio.gpioz.plugin.connectivity.BUZZ_TONE) + * [register\_rfid\_callback](#components.gpio.gpioz.plugin.connectivity.register_rfid_callback) + * [register\_status\_led\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_led_callback) + * [register\_status\_buzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_buzzer_callback) + * [register\_status\_tonalbuzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_status_tonalbuzzer_callback) + * [register\_audio\_sink\_change\_callback](#components.gpio.gpioz.plugin.connectivity.register_audio_sink_change_callback) + * [register\_volume\_led\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_led_callback) + * [register\_volume\_buzzer\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_buzzer_callback) + * [register\_volume\_rgbled\_callback](#components.gpio.gpioz.plugin.connectivity.register_volume_rgbled_callback) +* [components.gpio.gpioz.plugin](#components.gpio.gpioz.plugin) + * [output\_devices](#components.gpio.gpioz.plugin.output_devices) + * [input\_devices](#components.gpio.gpioz.plugin.input_devices) + * [factory](#components.gpio.gpioz.plugin.factory) + * [IS\_ENABLED](#components.gpio.gpioz.plugin.IS_ENABLED) + * [IS\_MOCKED](#components.gpio.gpioz.plugin.IS_MOCKED) + * [CONFIG\_FILE](#components.gpio.gpioz.plugin.CONFIG_FILE) + * [ServiceIsRunningCallbacks](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks) + * [register](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks.register) + * [run\_callbacks](#components.gpio.gpioz.plugin.ServiceIsRunningCallbacks.run_callbacks) + * [service\_is\_running\_callbacks](#components.gpio.gpioz.plugin.service_is_running_callbacks) + * [build\_output\_device](#components.gpio.gpioz.plugin.build_output_device) + * [build\_input\_device](#components.gpio.gpioz.plugin.build_input_device) + * [get\_output](#components.gpio.gpioz.plugin.get_output) + * [on](#components.gpio.gpioz.plugin.on) + * [off](#components.gpio.gpioz.plugin.off) + * [set\_value](#components.gpio.gpioz.plugin.set_value) + * [flash](#components.gpio.gpioz.plugin.flash) +* [components.rfid.configure](#components.rfid.configure) + * [reader\_install\_dependencies](#components.rfid.configure.reader_install_dependencies) + * [reader\_load\_module](#components.rfid.configure.reader_load_module) + * [query\_user\_for\_reader](#components.rfid.configure.query_user_for_reader) + * [write\_config](#components.rfid.configure.write_config) +* [components.rfid](#components.rfid) +* [components.rfid.cardutils](#components.rfid.cardutils) + * [decode\_card\_command](#components.rfid.cardutils.decode_card_command) + * [card\_command\_to\_str](#components.rfid.cardutils.card_command_to_str) + * [card\_to\_str](#components.rfid.cardutils.card_to_str) +* [components.rfid.cards](#components.rfid.cards) + * [list\_cards](#components.rfid.cards.list_cards) + * [delete\_card](#components.rfid.cards.delete_card) + * [register\_card](#components.rfid.cards.register_card) + * [register\_card\_custom](#components.rfid.cards.register_card_custom) + * [save\_card\_database](#components.rfid.cards.save_card_database) +* [components.rfid.readerbase](#components.rfid.readerbase) + * [ReaderBaseClass](#components.rfid.readerbase.ReaderBaseClass) +* [components.rfid.reader](#components.rfid.reader) + * [RfidCardDetectCallbacks](#components.rfid.reader.RfidCardDetectCallbacks) + * [register](#components.rfid.reader.RfidCardDetectCallbacks.register) + * [run\_callbacks](#components.rfid.reader.RfidCardDetectCallbacks.run_callbacks) + * [rfid\_card\_detect\_callbacks](#components.rfid.reader.rfid_card_detect_callbacks) + * [CardRemovalTimerClass](#components.rfid.reader.CardRemovalTimerClass) + * [\_\_init\_\_](#components.rfid.reader.CardRemovalTimerClass.__init__) +* [components.rfid.hardware.rdm6300\_serial.description](#components.rfid.hardware.rdm6300_serial.description) +* [components.rfid.hardware.rdm6300\_serial.rdm6300\_serial](#components.rfid.hardware.rdm6300_serial.rdm6300_serial) + * [decode](#components.rfid.hardware.rdm6300_serial.rdm6300_serial.decode) +* [components.rfid.hardware.template\_new\_reader.description](#components.rfid.hardware.template_new_reader.description) +* [components.rfid.hardware.template\_new\_reader.template\_new\_reader](#components.rfid.hardware.template_new_reader.template_new_reader) + * [query\_customization](#components.rfid.hardware.template_new_reader.template_new_reader.query_customization) + * [ReaderClass](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass) + * [\_\_init\_\_](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.__init__) + * [cleanup](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.cleanup) + * [stop](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.stop) + * [read\_card](#components.rfid.hardware.template_new_reader.template_new_reader.ReaderClass.read_card) +* [components.rfid.hardware.generic\_nfcpy.description](#components.rfid.hardware.generic_nfcpy.description) +* [components.rfid.hardware.generic\_nfcpy.generic\_nfcpy](#components.rfid.hardware.generic_nfcpy.generic_nfcpy) + * [ReaderClass](#components.rfid.hardware.generic_nfcpy.generic_nfcpy.ReaderClass) + * [cleanup](#components.rfid.hardware.generic_nfcpy.generic_nfcpy.ReaderClass.cleanup) + * [stop](#components.rfid.hardware.generic_nfcpy.generic_nfcpy.ReaderClass.stop) + * [read\_card](#components.rfid.hardware.generic_nfcpy.generic_nfcpy.ReaderClass.read_card) +* [components.rfid.hardware.generic\_usb.description](#components.rfid.hardware.generic_usb.description) +* [components.rfid.hardware.generic\_usb.generic\_usb](#components.rfid.hardware.generic_usb.generic_usb) +* [components.rfid.hardware.fake\_reader\_gui.fake\_reader\_gui](#components.rfid.hardware.fake_reader_gui.fake_reader_gui) +* [components.rfid.hardware.fake\_reader\_gui.description](#components.rfid.hardware.fake_reader_gui.description) +* [components.rfid.hardware.fake\_reader\_gui.gpioz\_gui\_addon](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon) + * [create\_inputs](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.create_inputs) + * [set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.set_state) + * [que\_set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.que_set_state) + * [fix\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.fix_state) + * [pbox\_set\_state](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.pbox_set_state) + * [que\_set\_pbox](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.que_set_pbox) + * [create\_outputs](#components.rfid.hardware.fake_reader_gui.gpioz_gui_addon.create_outputs) +* [components.rfid.hardware.pn532\_i2c\_py532.pn532\_i2c\_py532](#components.rfid.hardware.pn532_i2c_py532.pn532_i2c_py532) +* [components.rfid.hardware.pn532\_i2c\_py532.description](#components.rfid.hardware.pn532_i2c_py532.description) +* [components.rfid.hardware.rc522\_spi.rc522\_spi](#components.rfid.hardware.rc522_spi.rc522_spi) +* [components.rfid.hardware.rc522\_spi.description](#components.rfid.hardware.rc522_spi.description) +* [components.player](#components.player) + * [MusicLibPath](#components.player.MusicLibPath) + * [get\_music\_library\_path](#components.player.get_music_library_path) +* [components.battery\_monitor.batt\_mon\_i2c\_ina219](#components.battery_monitor.batt_mon_i2c_ina219) + * [battmon\_ina219](#components.battery_monitor.batt_mon_i2c_ina219.battmon_ina219) +* [components.battery\_monitor.batt\_mon\_i2c\_ads1015](#components.battery_monitor.batt_mon_i2c_ads1015) + * [battmon\_ads1015](#components.battery_monitor.batt_mon_i2c_ads1015.battmon_ads1015) +* [components.battery\_monitor.batt\_mon\_simulator](#components.battery_monitor.batt_mon_simulator) + * [battmon\_simulator](#components.battery_monitor.batt_mon_simulator.battmon_simulator) +* [components.battery\_monitor.BatteryMonitorBase](#components.battery_monitor.BatteryMonitorBase) + * [pt1\_frac](#components.battery_monitor.BatteryMonitorBase.pt1_frac) + * [BattmonBase](#components.battery_monitor.BatteryMonitorBase.BattmonBase) +* [components.battery\_monitor](#components.battery_monitor) +* [components.controls.bluetooth\_audio\_buttons](#components.controls.bluetooth_audio_buttons) +* [components.controls.event\_devices](#components.controls.event_devices) + * [IS\_ENABLED](#components.controls.event_devices.IS_ENABLED) + * [CONFIG\_FILE](#components.controls.event_devices.CONFIG_FILE) + * [activate](#components.controls.event_devices.activate) + * [initialize](#components.controls.event_devices.initialize) + * [parse\_device\_config](#components.controls.event_devices.parse_device_config) +* [components.controls.common.evdev\_listener](#components.controls.common.evdev_listener) + * [find\_device](#components.controls.common.evdev_listener.find_device) + * [EvDevKeyListener](#components.controls.common.evdev_listener.EvDevKeyListener) + * [\_\_init\_\_](#components.controls.common.evdev_listener.EvDevKeyListener.__init__) + * [run](#components.controls.common.evdev_listener.EvDevKeyListener.run) + * [start](#components.controls.common.evdev_listener.EvDevKeyListener.start) +* [components.controls](#components.controls) +* [components.misc](#components.misc) + * [rpc\_cmd\_help](#components.misc.rpc_cmd_help) + * [get\_all\_loaded\_packages](#components.misc.get_all_loaded_packages) + * [get\_all\_failed\_packages](#components.misc.get_all_failed_packages) + * [get\_start\_time](#components.misc.get_start_time) + * [get\_log](#components.misc.get_log) + * [get\_log\_debug](#components.misc.get_log_debug) + * [get\_log\_error](#components.misc.get_log_error) + * [get\_git\_state](#components.misc.get_git_state) + * [empty\_rpc\_call](#components.misc.empty_rpc_call) + * [get\_app\_settings](#components.misc.get_app_settings) + * [set\_app\_settings](#components.misc.set_app_settings) +* [components.publishing](#components.publishing) + * [republish](#components.publishing.republish) +* [jukebox](#jukebox) +* [jukebox.callingback](#jukebox.callingback) + * [CallbackHandler](#jukebox.callingback.CallbackHandler) + * [register](#jukebox.callingback.CallbackHandler.register) + * [run\_callbacks](#jukebox.callingback.CallbackHandler.run_callbacks) + * [has\_callbacks](#jukebox.callingback.CallbackHandler.has_callbacks) * [jukebox.plugs](#jukebox.plugs) * [PluginPackageClass](#jukebox.plugs.PluginPackageClass) * [register](#jukebox.plugs.register) @@ -345,7 +318,43 @@ * [generate\_help\_rst](#jukebox.plugs.generate_help_rst) * [get\_all\_loaded\_packages](#jukebox.plugs.get_all_loaded_packages) * [get\_all\_failed\_packages](#jukebox.plugs.get_all_failed_packages) +* [jukebox.cfghandler](#jukebox.cfghandler) + * [ConfigHandler](#jukebox.cfghandler.ConfigHandler) + * [loaded\_from](#jukebox.cfghandler.ConfigHandler.loaded_from) + * [get](#jukebox.cfghandler.ConfigHandler.get) + * [setdefault](#jukebox.cfghandler.ConfigHandler.setdefault) + * [getn](#jukebox.cfghandler.ConfigHandler.getn) + * [setn](#jukebox.cfghandler.ConfigHandler.setn) + * [setndefault](#jukebox.cfghandler.ConfigHandler.setndefault) + * [config\_dict](#jukebox.cfghandler.ConfigHandler.config_dict) + * [is\_modified](#jukebox.cfghandler.ConfigHandler.is_modified) + * [clear\_modified](#jukebox.cfghandler.ConfigHandler.clear_modified) + * [save](#jukebox.cfghandler.ConfigHandler.save) + * [load](#jukebox.cfghandler.ConfigHandler.load) + * [get\_handler](#jukebox.cfghandler.get_handler) + * [load\_yaml](#jukebox.cfghandler.load_yaml) + * [write\_yaml](#jukebox.cfghandler.write_yaml) * [jukebox.speaking\_text](#jukebox.speaking_text) +* [jukebox.utils](#jukebox.utils) + * [decode\_rpc\_call](#jukebox.utils.decode_rpc_call) + * [decode\_rpc\_command](#jukebox.utils.decode_rpc_command) + * [decode\_and\_call\_rpc\_command](#jukebox.utils.decode_and_call_rpc_command) + * [bind\_rpc\_command](#jukebox.utils.bind_rpc_command) + * [rpc\_call\_to\_str](#jukebox.utils.rpc_call_to_str) + * [get\_config\_action](#jukebox.utils.get_config_action) + * [generate\_cmd\_alias\_rst](#jukebox.utils.generate_cmd_alias_rst) + * [generate\_cmd\_alias\_reference](#jukebox.utils.generate_cmd_alias_reference) + * [get\_git\_state](#jukebox.utils.get_git_state) +* [jukebox.version](#jukebox.version) + * [version](#jukebox.version.version) + * [version\_info](#jukebox.version.version_info) +* [jukebox.playlistgenerator](#jukebox.playlistgenerator) + * [TYPE\_DECODE](#jukebox.playlistgenerator.TYPE_DECODE) + * [PlaylistCollector](#jukebox.playlistgenerator.PlaylistCollector) + * [\_\_init\_\_](#jukebox.playlistgenerator.PlaylistCollector.__init__) + * [set\_exclusion\_endings](#jukebox.playlistgenerator.PlaylistCollector.set_exclusion_endings) + * [get\_directory\_content](#jukebox.playlistgenerator.PlaylistCollector.get_directory_content) + * [parse](#jukebox.playlistgenerator.PlaylistCollector.parse) * [jukebox.multitimer](#jukebox.multitimer) * [MultiTimer](#jukebox.multitimer.MultiTimer) * [cancel](#jukebox.multitimer.MultiTimer.cancel) @@ -364,21 +373,47 @@ * [GenericMultiTimerClass](#jukebox.multitimer.GenericMultiTimerClass) * [\_\_init\_\_](#jukebox.multitimer.GenericMultiTimerClass.__init__) * [start](#jukebox.multitimer.GenericMultiTimerClass.start) -* [jukebox.utils](#jukebox.utils) - * [decode\_rpc\_call](#jukebox.utils.decode_rpc_call) - * [decode\_rpc\_command](#jukebox.utils.decode_rpc_command) - * [decode\_and\_call\_rpc\_command](#jukebox.utils.decode_and_call_rpc_command) - * [bind\_rpc\_command](#jukebox.utils.bind_rpc_command) - * [rpc\_call\_to\_str](#jukebox.utils.rpc_call_to_str) - * [generate\_cmd\_alias\_rst](#jukebox.utils.generate_cmd_alias_rst) - * [generate\_cmd\_alias\_reference](#jukebox.utils.generate_cmd_alias_reference) - * [get\_git\_state](#jukebox.utils.get_git_state) -* [jukebox.rpc](#jukebox.rpc) +* [jukebox.NvManager](#jukebox.NvManager) +* [jukebox.publishing.subscriber](#jukebox.publishing.subscriber) +* [jukebox.publishing](#jukebox.publishing) + * [get\_publisher](#jukebox.publishing.get_publisher) +* [jukebox.publishing.server](#jukebox.publishing.server) + * [PublishServer](#jukebox.publishing.server.PublishServer) + * [run](#jukebox.publishing.server.PublishServer.run) + * [handle\_message](#jukebox.publishing.server.PublishServer.handle_message) + * [handle\_subscription](#jukebox.publishing.server.PublishServer.handle_subscription) + * [Publisher](#jukebox.publishing.server.Publisher) + * [\_\_init\_\_](#jukebox.publishing.server.Publisher.__init__) + * [send](#jukebox.publishing.server.Publisher.send) + * [revoke](#jukebox.publishing.server.Publisher.revoke) + * [resend](#jukebox.publishing.server.Publisher.resend) + * [close\_server](#jukebox.publishing.server.Publisher.close_server) +* [jukebox.daemon](#jukebox.daemon) + * [log\_active\_threads](#jukebox.daemon.log_active_threads) + * [JukeBox](#jukebox.daemon.JukeBox) + * [signal\_handler](#jukebox.daemon.JukeBox.signal_handler) * [jukebox.rpc.client](#jukebox.rpc.client) +* [jukebox.rpc](#jukebox.rpc) * [jukebox.rpc.server](#jukebox.rpc.server) * [RpcServer](#jukebox.rpc.server.RpcServer) * [\_\_init\_\_](#jukebox.rpc.server.RpcServer.__init__) * [run](#jukebox.rpc.server.RpcServer.run) +* [misc](#misc) + * [recursive\_chmod](#misc.recursive_chmod) + * [flatten](#misc.flatten) + * [getattr\_hierarchical](#misc.getattr_hierarchical) +* [misc.simplecolors](#misc.simplecolors) + * [Colors](#misc.simplecolors.Colors) + * [resolve](#misc.simplecolors.resolve) + * [print](#misc.simplecolors.print) +* [misc.inputminus](#misc.inputminus) + * [input\_int](#misc.inputminus.input_int) + * [input\_yesno](#misc.inputminus.input_yesno) +* [misc.loggingext](#misc.loggingext) + * [ColorFilter](#misc.loggingext.ColorFilter) + * [\_\_init\_\_](#misc.loggingext.ColorFilter.__init__) + * [PubStream](#misc.loggingext.PubStream) + * [PubStreamHandler](#misc.loggingext.PubStreamHandler) @@ -396,24 +431,6 @@ as service. This gives direct logging info in the console and allows changing co See [Troubleshooting](../../builders/troubleshooting.md). - - -# \_\_init\_\_ - - - -# run\_register\_rfid\_reader - -Setup tool to configure the RFID Readers. - -Run this once to register and configure the RFID readers with the Jukebox. Can be re-run at any time to change -the settings. For more information see [RFID Readers](../rfid/README.md). - -> [!NOTE] -> This tool will always write a new configurations file. Thus, overwrite the old one (after checking with the user). -> Any manual modifications to the settings will have to be re-applied - - # run\_rpc\_tool @@ -457,16 +474,18 @@ Right now duplicates more or less main() :todo remove duplication of code - + -# run\_configure\_audio +# run\_register\_rfid\_reader -Setup tool to register the PulseAudio sinks as primary and secondary audio outputs. +Setup tool to configure the RFID Readers. -Will also setup equalizer and mono down mixer in the pulseaudio config file. +Run this once to register and configure the RFID readers with the Jukebox. Can be re-run at any time to change +the settings. For more information see [RFID Readers](../rfid/README.md). -Run this once after installation. Can be re-run at any time to change the settings. -For more information see [Audio Configuration](../../builders/audio.md#audio-configuration). +> [!NOTE] +> This tool will always write a new configurations file. Thus, overwrite the old one (after checking with the user). +> Any manual modifications to the settings will have to be re-applied @@ -479,625 +498,652 @@ Jukebox via the publishing interface. Received messages are printed in the cons Mainly used for debugging. - + -# misc +# \_\_init\_\_ - + -#### recursive\_chmod +# run\_configure\_audio -```python -def recursive_chmod(path, mode_files, mode_dirs) -``` +Setup tool to register the PulseAudio sinks as primary and secondary audio outputs. -Recursively change folder and file permissions +Will also setup equalizer and mono down mixer in the pulseaudio config file. -mode_files/mode dirs can be given in octal notation e.g. 0o777 -flags from the stats module. +Run this once after installation. Can be re-run at any time to change the settings. +For more information see [Audio Configuration](../../builders/audio.md#audio-configuration). -Reference: https://docs.python.org/3/library/os.html#os.chmod + - +# components -#### flatten + + +# components.mqtt.utils + + + +#### play\_folder\_recursive\_args ```python -def flatten(iterable) +def play_folder_recursive_args(payload: str) -> dict ``` -Flatten all levels of hierarchy in nested iterables +Create arguments for playing a folder recursively. - + -#### getattr\_hierarchical +#### parse\_repeat\_mode ```python -def getattr_hierarchical(obj: Any, name: str) -> Any +def parse_repeat_mode(payload: str) -> Optional[str] ``` -Like the builtin getattr, but descends though the hierarchy levels +Parse a repeat mode command based on the given payload. - + -# misc.inputminus +#### get\_args -Zero 3rd-party dependency module for user prompting +```python +def get_args(config: dict, payload: dict) -> Optional[dict] +``` -Yes, there are modules out there to do the same and they have more features. -However, this is low-complexity and has zero dependencies +Retrieve arguments based on the configuration and payload. - + -#### input\_int +#### get\_rpc\_command ```python -def input_int(prompt, - blank=None, - min=None, - max=None, - prompt_color=None, - prompt_hint=False) -> int +def get_rpc_command(config: dict) -> Optional[dict] ``` -Request an integer input from user +Retrieve the RPC command based on the configuration. -**Arguments**: -- `prompt`: The prompt to display -- `blank`: Value to return when user just hits enter. Leave at None, if blank is invalid -- `min`: Minimum valid integer value (None disables this check) -- `max`: Maximum valid integer value (None disables this check) -- `prompt_color`: Color of the prompt. Color will be reset at end of prompt -- `prompt_hint`: Append a 'hint' with [min...max, default=xx] to end of prompt + -**Returns**: +#### get\_kwargs -integer value read from user input +```python +def get_kwargs(config: dict, payload: dict) -> Optional[dict] +``` - +Retrieve keyword arguments based on the configuration and payload. -#### input\_yesno + + + +#### get\_current\_time\_milli ```python -def input_yesno(prompt, - blank=None, - prompt_color=None, - prompt_hint=False) -> bool +def get_current_time_milli() -> int ``` -Request a yes / no choice from user - -Accepts multiple input for true/false and is case insensitive +Get the current time in milliseconds. -**Arguments**: -- `prompt`: The prompt to display -- `blank`: Value to return when user just hits enter. Leave at None, if blank is invalid -- `prompt_color`: Color of the prompt. Color will be reset at end of prompt -- `prompt_hint`: Append a 'hint' with [y/n] to end of prompt. Default choice will be capitalized + -**Returns**: +#### split\_topic -boolean value read from user input +```python +def split_topic(topic: str) -> str +``` - +Split an MQTT topic and return a part of it. -# misc.loggingext -## Logger + -We use a hierarchical Logger structure based on pythons logging module. It can be finely configured with a yaml file. +#### map\_repeat\_mode -The top-level logger is called 'jb' (to make it short). In any module you may simple create a child-logger at any hierarchy -level below 'jb'. It will inherit settings from it's parent logger unless otherwise configured in the yaml file. -Hierarchy separator is the '.'. If the logger already exits, getLogger will return a reference to the same, else it will be -created on the spot. +```python +def map_repeat_mode(repeat_active: bool, single_active: bool) -> str +``` -Example: How to get logger and log away at your heart's content: +Map boolean flags to repeat mode constants. - >>> import logging - >>> logger = logging.getLogger('jb.awesome_module') - >>> logger.info('Started general awesomeness aura') -Example: YAML snippet, setting WARNING as default level everywhere and DEBUG for jb.awesome_module: + - loggers: - jb: - level: WARNING - handlers: [console, debug_file_handler, error_file_handler] - propagate: no - jb.awesome_module: - level: DEBUG +# components.mqtt.mqtt\_command\_alias +This file provides definitions for MQTT to RPC command aliases -> [!NOTE] -> The name (and hierarchy path) of the logger can be arbitrary and must not necessarily match the module name (still makes -> sense). -> There can be multiple loggers per module, e.g. for special classes, to further control the amount of log output +See [] +See [RPC Commands](../../builders/rpc-commands.md) - + -## ColorFilter Objects +#### get\_mute ```python -class ColorFilter(logging.Filter) +def get_mute(payload) -> bool ``` -This filter adds colors to the logger +Helper to toggle mute in legacy support. -It adds all colors from simplecolors by using the color name as new keyword, -i.e. use %(colorname)c or {colorname} in the formatter string -It also adds the keyword {levelnameColored} which is an auto-colored drop-in replacement -for the levelname depending on severity. + -Don't forget to {reset} the color settings at the end of the string. +# components.mqtt.mqtt\_const + - +# components.mqtt -#### \_\_init\_\_ + + +## MQTT Objects ```python -def __init__(enable=True, color_levelname=True) +class MQTT(threading.Thread) ``` -**Arguments**: +A thread for monitoring events and publishing interesting events via MQTT. -- `enable`: Enable the coloring -- `color_levelname`: Enable auto-coloring when using the levelname keyword - + -## PubStream Objects +#### run ```python -class PubStream() +def run() -> None ``` -Stream handler wrapper around the publisher for logging.StreamHandler +Main loop of the MQTT thread. -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! +#### stop +```python +def stop() +``` - +Stop the MQTT thread. -## PubStreamHandler Objects + + + +#### on\_connect ```python -class PubStreamHandler(logging.StreamHandler) +def on_connect(client, userdata, flags, rc) ``` -Wrapper for logging.StreamHandler with stream = PubStream +Start thread on successful MQTT connection. -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 + - +#### initialize -# misc.simplecolors +```python +@plugs.initialize +def initialize() +``` -Zero 3rd-party dependency module to add colors to unix terminal output +Setup connection and trigger the MQTT loop. -Yes, there are modules out there to do the same and they have more features. -However, this is low-complexity and has zero dependencies + - +# components.volume -## Colors Objects +PulseAudio Volume Control Plugin Package -```python -class Colors() -``` +## Features -Container class for all the colors as constants +* Volume Control +* Two outputs +* Watcher thread on volume / output change +## Publishes - +* volume.level +* volume.sink -#### resolve +## PulseAudio References -```python -def resolve(color_name: str) -``` + -Resolve a color name into the respective color constant +Check fallback device (on device de-connect): -**Arguments**: + $ pacmd list-sinks | grep -e 'name:' -e 'index' -- `color_name`: Name of the color -**Returns**: +## Integration -color constant +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). -#### print +## Misc -```python -def print(color: Colors, - *values, - sep=' ', - end='\n', - file=sys.stdout, - flush=False) -``` +PulseAudio may switch the sink automatically to a connecting bluetooth device depending on the loaded module +with name module-switch-on-connect. On Raspberry Pi OS Bullseye, this module is not part of the default configuration +in ``/usr/pulse/default.pa``. So, we don't need to worry about it. +If the module gets loaded it conflicts with the toggle on connect and the selected primary / secondary outputs +from the Jukebox. Remove it from the configuration! -Drop-in replacement for print with color choice and auto color reset for convenience + ### 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 -Use just as a regular print function, but with first parameter as color +## 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: -# components +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` -# components.playermpd.playcontentcallback - + -## PlayContentCallbacks Objects +## PulseMonitor Objects ```python -class PlayContentCallbacks(Generic[STATE], CallbackHandler) +class PulseMonitor(threading.Thread) ``` -Callbacks are executed in various play functions +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 - -#### register + + +## SoundCardConnectCallbacks Objects ```python -def register(func: Callable[[str, STATE], None]) +class SoundCardConnectCallbacks(CallbackHandler) ``` -Add a new callback function :attr:`func`. +Callbacks are executed when -Callback signature is +* new sound card gets connected -.. py:function:: func(folder: str, state: STATE) + + + +#### 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**: -- `folder`: relativ path to folder to play -- `state`: indicator of the state inside the calling +- `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(folder: str, state: STATE) +def run_callbacks(sink_name, alias, sink_index, error_state) ``` - + -# components.playermpd +#### toggle\_on\_connect -Package for interfacing with the MPD Music Player Daemon +```python +@property +def toggle_on_connect() +``` -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 +Returns :data:`True` if the sound card shall be changed when a new card connects/disconnects. Setting this -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 +property changes the behavior. -Internal status - - last played folder: Needed to detect second swipe +> [!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 -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 +#### toggle\_on\_connect -sudo -u mpd speaker-test -t wav -c 2 +```python +@toggle_on_connect.setter +def toggle_on_connect(state=True) +``` +Toggle Doc 2 - -## PlayerMPD Objects + + +#### stop ```python -class PlayerMPD() +def stop() ``` -Interface to MPD Music Player Daemon +Stop the pulse monitor thread - + -#### mpd\_retry\_with\_mutex +#### run ```python -def mpd_retry_with_mutex(mpd_cmd, *args) +def run() -> None ``` -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 +Starts the pulse monitor thread - + -#### pause +## PulseVolumeControl Objects ```python -@plugs.tag -def pause(state: int = 1) +class PulseVolumeControl() ``` -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 - +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 -#### next + with pulse_monitor as pulse ... -```python -@plugs.tag -def next() -``` -Play next track in current playlist +All private functions starting with `_function_name` assume that this is ensured by +the calling function. All user functions acquire proper context! - + -#### rewind +## OutputChangeCallbackHandler Objects ```python -@plugs.tag -def rewind() +class OutputChangeCallbackHandler(CallbackHandler) ``` -Re-start current playlist from first track +Callbacks are executed when -Note: Will not re-read folder config, but leave settings untouched +* audio sink is changed - + -#### replay +#### register ```python -@plugs.tag -def replay() +def register(func: Callable[[str, str, int, int], None]) ``` -Re-start playing the last-played folder +Add a new callback function :attr:`func`. -Will reset settings to folder config +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: -#### toggle +**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 -@plugs.tag -def toggle() +def run_callbacks(sink_name, alias, sink_index, error_state) ``` -Toggle pause state, i.e. do a pause / resume depending on current state - + -#### replay\_if\_stopped +## OutputVolumeCallbackHandler Objects ```python -@plugs.tag -def replay_if_stopped() +class OutputVolumeCallbackHandler(CallbackHandler) ``` -Re-start playing the last-played folder unless playlist is still playing +Callbacks are executed when -> [!NOTE] -> To me this seems much like the behaviour of play, -> but we keep it as it is specifically implemented in box 2.X +* audio volume level is changed - + -#### play\_card +#### register ```python -@plugs.tag -def play_card(folder: str, recursive: bool = False) +def register(func: Callable[[int, bool, bool], None]) ``` -Main entry point for trigger music playing from RFID reader. Decodes second swipe options before playing folder content +Add a new callback function :attr:`func`. -Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action -accordingly. +Callback signature is + +.. py:function:: func(volume: int, is_min: bool, is_max: bool) + :noindex: **Arguments**: -- `folder`: Folder path relative to music library path -- `recursive`: Add folder recursively +- `volume`: Volume level +- `is_min`: 1, if volume level is minimum, else 0 +- `is_max`: 1, if volume level is maximum, else 0 - + -#### get\_single\_coverart +#### run\_callbacks ```python -@plugs.tag -def get_single_coverart(song_url) +def run_callbacks(sink_name, alias, sink_index, error_state) ``` -Saves the album art image to a cache and returns the filename. - + -#### get\_folder\_content +#### toggle\_output ```python -@plugs.tag -def get_folder_content(folder: str) +@plugin.tag +def toggle_output() ``` -Get the folder content as content list with meta-information. Depth is always 1. - -Call repeatedly to descend in hierarchy - -**Arguments**: +Toggle the audio output sink -- `folder`: Folder path relative to music library path - + -#### play\_folder +#### get\_outputs ```python -@plugs.tag -def play_folder(folder: str, recursive: bool = False) -> None +@plugin.tag +def get_outputs() ``` -Playback a music folder. - -Folder content is added to the playlist as described by :mod:`jukebox.playlistgenerator`. -The playlist is cleared first. +Get current output and list of outputs -**Arguments**: -- `folder`: Folder path relative to music library path -- `recursive`: Add folder recursively + - +#### publish\_volume + +```python +@plugin.tag +def publish_volume() +``` + +Publish (volume, mute) -#### play\_album + + + +#### publish\_outputs ```python -@plugs.tag -def play_album(albumartist: str, album: str) +@plugin.tag +def publish_outputs() ``` -Playback a album found in MPD database. +Publish current output and list of outputs -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 +#### set\_volume - +```python +@plugin.tag +def set_volume(volume: int) +``` + +Set the volume (0-100) for the currently active output + + + #### get\_volume ```python +@plugin.tag def get_volume() ``` -Get the current volume +Get the volume -For volume control do not use directly, but use through the plugin 'volume', -as the user may have configured a volume control manager other than MPD + - +#### change\_volume -#### set\_volume +```python +@plugin.tag +def change_volume(step: int) +``` + +Increase/decrease the volume by step for the currently active output + + + + +#### get\_mute ```python -def set_volume(volume) +@plugin.tag +def get_mute() ``` -Set the volume +Return mute status for the currently active output -For volume control do not use directly, but use through the plugin 'volume', -as the user may have configured a volume control manager other than MPD + - +#### mute -#### play\_card\_callbacks +```python +@plugin.tag +def mute(mute=True) +``` -Callback handler instance for play_card events. +Set mute status for the currently active output -- is executed when play_card function is called -States: -- See :class:`PlayCardState` -See :class:`PlayContentCallbacks` + - +#### set\_output + +```python +@plugin.tag +def set_output(sink_index: int) +``` + +Set the active output (sink_index = 0: primary, 1: secondary) + + + + +#### set\_soft\_max\_volume + +```python +@plugin.tag +def set_soft_max_volume(max_volume: int) +``` + +Limit the maximum volume to max_volume for the currently active output + + + + +#### get\_soft\_max\_volume + +```python +@plugin.tag +def get_soft_max_volume() +``` + +Return the maximum volume limit for the currently active output + + + + +#### card\_list + +```python +def card_list() -> List[pulsectl.PulseCardInfo] +``` + +Return the list of present sound card -# components.playermpd.coverart\_cache\_manager @@ -1108,6 +1154,14 @@ This file provides definitions for RPC command aliases See [RPC Commands](../../builders/rpc-commands.md) + + +# components.synchronisation + + + +# components.synchronisation.syncutils + # components.synchronisation.rfidcards @@ -1200,4715 +1254,5042 @@ Sync the folder from the remote server, if existing - `folder`: Folder path relative to music library path - - -# components.synchronisation + - +# components.jingle.jinglemp3 -# components.synchronisation.syncutils +Generic MP3 jingle Service for jingle.JingleFactory - -# components.volume + -PulseAudio Volume Control Plugin Package +## JingleMp3Play Objects -## Features +```python +@plugin.register(auto_tag=True) +class JingleMp3Play() +``` -* Volume Control -* Two outputs -* Watcher thread on volume / output change +Jingle Service for playing MP3 files -## Publishes -* volume.level -* volume.sink + -## PulseAudio References +#### play - +```python +def play(filename) +``` -Check fallback device (on device de-connect): +Play the MP3 file - $ pacmd list-sinks | grep -e 'name:' -e 'index' + -## Integration +## JingleMp3PlayBuilder Objects -Pulse Audio runs as a user process. Processes who want to communicate / stream to it -must also run as a user process. +```python +class JingleMp3PlayBuilder() +``` -This means must also run as user process, as described in -[Music Player Daemon](../../builders/system.md#music-player-daemon-mpd). + -## Misc +#### \_\_init\_\_ -PulseAudio may switch the sink automatically to a connecting bluetooth device depending on the loaded module -with name module-switch-on-connect. On Raspberry Pi OS Bullseye, this module is not part of the default configuration -in ``/usr/pulse/default.pa``. So, we don't need to worry about it. -If the module gets loaded it conflicts with the toggle on connect and the selected primary / secondary outputs -from the Jukebox. Remove it from the configuration! +```python +def __init__() +``` - ### Use hot-plugged devices like Bluetooth or USB automatically (LP: `1702794`) - ### not available on PI? - .ifexists module-switch-on-connect.so - load-module module-switch-on-connect - .endif +Builder instantiates JingleMp3Play during init and not during first call because -## Why PulseAudio? +we want JingleMp3Play registers as plugin function in any case if this plugin is loaded +(and not only on first use!) -The audio configuration of the system is one of those topics, -which has a myriad of options and possibilities. Every system is different and PulseAudio unifies these and -makes our life easier. Besides, it is only option to support Bluetooth at the moment. -## Callbacks: + -The following callbacks are provided. Register callbacks with these adder functions (see their documentation for details): +# components.jingle -1. :func:`add_on_connect_callback` -2. :func:`add_on_output_change_callbacks` -3. :func:`add_on_volume_change_callback` +Jingle Playback Factory for extensible run-time support of various file types - + -## PulseMonitor Objects +## JingleFactory Objects ```python -class PulseMonitor(threading.Thread) +class JingleFactory() ``` -A thread for monitoring and interacting with the Pulse Lib via pulsectrl - -Whenever we want to access pulsectl, we need to exit the event listen loop. -This is handled by the context manager. It stops the event loop and returns -the pulsectl instance to be used (it does no return the monitor thread itself!) - -The context manager also locks the module to ensure proper thread sequencing, -as only a single thread may work with pulsectl at any time. Currently, an RLock is -used, even if it may not be necessary +Jingle Factory - + -## SoundCardConnectCallbacks Objects +#### list ```python -class SoundCardConnectCallbacks(CallbackHandler) +def list() ``` -Callbacks are executed when - -* new sound card gets connected +List the available volume services - + -#### register +#### play ```python -def register(func: Callable[[str, str], None]) +@plugin.register +def play(filename) ``` -Add a new callback function :attr:`func`. - -Callback signature is +Play the jingle using the configured jingle service -.. py:function:: func(card_driver: str, device_name: str) - :noindex: +> [!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! -**Arguments**: +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... -- `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 +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. - -#### run\_callbacks + + +#### play\_startup ```python -def run_callbacks(sink_name, alias, sink_index, error_state) +@plugin.register +def play_startup() ``` +Play the startup sound (using jingle.play) - + -#### toggle\_on\_connect +#### play\_shutdown ```python -@property -def toggle_on_connect() +@plugin.register +def play_shutdown() ``` -Returns :data:`True` if the sound card shall be changed when a new card connects/disconnects. Setting this +Play the shutdown sound (using jingle.play) -property changes the behavior. -> [!NOTE] -> A new card is always assumed to be the secondary device from the audio configuration. -> At the moment there is no check it actually is the configured device. This means any new -> device connection will initiate the toggle. This, however, is no real issue as the RPi's audio -> system will be relatively stable once setup + +# components.jingle.alsawave - +ALSA wave jingle Service for jingle.JingleFactory -#### toggle\_on\_connect + + + +## AlsaWave Objects ```python -@toggle_on_connect.setter -def toggle_on_connect(state=True) +@plugin.register +class AlsaWave() ``` -Toggle Doc 2 +Jingle Service for playing wave files directly from Python through ALSA - + -#### stop +#### play ```python -def stop() +@plugin.tag +def play(filename) ``` -Stop the pulse monitor thread +Play the wave file - + -#### run +## AlsaWaveBuilder Objects ```python -def run() -> None +class AlsaWaveBuilder() ``` -Starts the pulse monitor thread - - - + -## PulseVolumeControl Objects +#### \_\_init\_\_ ```python -class PulseVolumeControl() +def __init__() ``` -Volume control manager for PulseAudio - -When accessing the pulse library, it needs to be put into a special -state. Which is ensured by the context manager +Builder instantiates AlsaWave during init and not during first call because - with pulse_monitor as pulse ... +we want AlsaWave registers as plugin function in any case if this plugin is loaded +(and not only on first use!) -All private functions starting with `_function_name` assume that this is ensured by -the calling function. All user functions acquire proper context! + +# components.hostif.linux - + -## OutputChangeCallbackHandler Objects +#### shutdown ```python -class OutputChangeCallbackHandler(CallbackHandler) +@plugin.register +def shutdown() ``` -Callbacks are executed when - -* audio sink is changed +Shutdown the host machine - + -#### register +#### reboot ```python -def register(func: Callable[[str, str, int, int], None]) +@plugin.register +def reboot() ``` -Add a new callback function :attr:`func`. - -Parameters always give the valid audio sink. That means, if an error -occurred, all parameters are valid. - -Callback signature is - -.. py:function:: func(sink_name: str, alias: str, sink_index: int, error_state: int) - :noindex: - -**Arguments**: +Reboot the host machine -- `sink_name`: PulseAudio's sink name -- `alias`: The alias for :attr:`sink_name` -- `sink_index`: The index of the sink in the configuration list -- `error_state`: 1 if there was an attempt to change the output -but an error occurred. Above parameters always give the now valid sink! -If a sink change is successful, it is 0. - + -#### run\_callbacks +#### jukebox\_is\_service ```python -def run_callbacks(sink_name, alias, sink_index, error_state) +@plugin.register +def jukebox_is_service() ``` +Check if current Jukebox process is running as a service - + -## OutputVolumeCallbackHandler Objects +#### is\_any\_jukebox\_service\_active ```python -class OutputVolumeCallbackHandler(CallbackHandler) +@plugin.register +def is_any_jukebox_service_active() ``` -Callbacks are executed when +Check if a Jukebox service is running -* audio volume level is changed +> [!NOTE] +> Does not have the be the current app, that is running as a service! - + -#### register +#### restart\_service ```python -def register(func: Callable[[int, bool, bool], None]) +@plugin.register +def restart_service() ``` -Add a new callback function :attr:`func`. - -Callback signature is - -.. py:function:: func(volume: int, is_min: bool, is_max: bool) - :noindex: - -**Arguments**: +Restart Jukebox App if running as a service -- `volume`: Volume level -- `is_min`: 1, if volume level is minimum, else 0 -- `is_max`: 1, if volume level is maximum, else 0 - + -#### run\_callbacks +#### get\_disk\_usage ```python -def run_callbacks(sink_name, alias, sink_index, error_state) +@plugin.register() +def get_disk_usage(path='/') ``` +Return the disk usage in Megabytes as dictionary for RPC export - + -#### toggle\_output +#### get\_cpu\_temperature ```python -@plugin.tag -def toggle_output() +@plugin.register +def get_cpu_temperature() ``` -Toggle the audio output sink +Get the CPU temperature with single decimal point + +No error handling: this is expected to take place up-level! - + -#### get\_outputs +#### get\_ip\_address ```python -@plugin.tag -def get_outputs() +@plugin.register +def get_ip_address() ``` -Get current output and list of outputs +Get the IP address - + -#### publish\_volume +#### wlan\_disable\_power\_down ```python -@plugin.tag -def publish_volume() +@plugin.register() +def wlan_disable_power_down(card=None) ``` -Publish (volume, mute) +Turn off power management of wlan. Keep RPi reachable via WLAN +This must be done after every reboot +card=None takes card from configuration file - -#### publish\_outputs + + +#### get\_autohotspot\_status ```python -@plugin.tag -def publish_outputs() +@plugin.register +def get_autohotspot_status() ``` -Publish current output and list of outputs +Get the status of the auto hotspot feature - + -#### set\_volume +#### stop\_autohotspot ```python -@plugin.tag -def set_volume(volume: int) +@plugin.register() +def stop_autohotspot() ``` -Set the volume (0-100) for the currently active output +Stop auto hotspot functionality +Stopping and disabling the timer and running the service one last time manually - -#### get\_volume + + +#### start\_autohotspot ```python -@plugin.tag -def get_volume() +@plugin.register() +def start_autohotspot() ``` -Get the volume +Start auto hotspot functionality +Enabling and starting the timer (timer will start the service) - -#### change\_volume + -```python -@plugin.tag -def change_volume(step: int) -``` +# components.timers -Increase/decrease the volume by step for the currently active output + +# components.playermpd.coverart\_cache\_manager - + -#### get\_mute +# components.playermpd.playcontentcallback + + + +## PlayContentCallbacks Objects ```python -@plugin.tag -def get_mute() +class PlayContentCallbacks(Generic[STATE], CallbackHandler) ``` -Return mute status for the currently active output +Callbacks are executed in various play functions - + -#### mute +#### register ```python -@plugin.tag -def mute(mute=True) +def register(func: Callable[[str, STATE], None]) ``` -Set mute status for the currently active output +Add a new callback function :attr:`func`. +Callback signature is - +.. py:function:: func(folder: str, state: STATE) + :noindex: -#### set\_output +**Arguments**: + +- `folder`: relativ path to folder to play +- `state`: indicator of the state inside the calling + + + +#### run\_callbacks ```python -@plugin.tag -def set_output(sink_index: int) +def run_callbacks(folder: str, state: STATE) ``` -Set the active output (sink_index = 0: primary, 1: secondary) - + -#### set\_soft\_max\_volume +# components.playermpd -```python -@plugin.tag -def set_soft_max_volume(max_volume: int) -``` +Package for interfacing with the MPD Music Player Daemon -Limit the maximum volume to max_volume for the currently active output +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 -#### get\_soft\_max\_volume -```python -@plugin.tag -def get_soft_max_volume() -``` +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'}}} -Return the maximum volume limit for the currently active output +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 - -#### card\_list + + +## PlayerMPD Objects ```python -def card_list() -> List[pulsectl.PulseCardInfo] +class PlayerMPD() ``` -Return the list of present sound card +Interface to MPD Music Player Daemon - + -# components.rfid +#### mpd\_retry\_with\_mutex - +```python +def mpd_retry_with_mutex(mpd_cmd, *args) +``` -# components.rfid.reader +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 -## RfidCardDetectCallbacks Objects +I think this should be refactored to a decorator + + + + +#### pause ```python -class RfidCardDetectCallbacks(CallbackHandler) +@plugs.tag +def pause(state: int = 1) ``` -Callbacks are executed if rfid card is detected +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 - -#### register + + +#### next ```python -def register(func: Callable[[str, RfidCardDetectState], None]) +@plugs.tag +def next() ``` -Add a new callback function :attr:`func`. +Play next track in current playlist -Callback signature is -.. py:function:: func(card_id: str, state: int) - :noindex: + -**Arguments**: +#### rewind -- `card_id`: Card ID -- `state`: See `RfidCardDetectState` +```python +@plugs.tag +def rewind() +``` - +Re-start current playlist from first track -#### run\_callbacks +Note: Will not re-read folder config, but leave settings untouched + + + + +#### replay ```python -def run_callbacks(card_id: str, state: RfidCardDetectState) +@plugs.tag +def replay() ``` +Re-start playing the last-played folder +Will reset settings to folder config - -#### rfid\_card\_detect\_callbacks + -Callback handler instance for rfid_card_detect_callbacks events. +#### toggle -See [`RfidCardDetectCallbacks`](#components.rfid.reader.RfidCardDetectCallbacks) +```python +@plugs.tag +def toggle() +``` +Toggle pause state, i.e. do a pause / resume depending on current state - -## CardRemovalTimerClass Objects + + +#### replay\_if\_stopped ```python -class CardRemovalTimerClass(threading.Thread) +@plugs.tag +def replay_if_stopped() ``` -A timer watchdog thread that calls timeout_action on time-out +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 - + -#### \_\_init\_\_ +#### play\_card ```python -def __init__(on_timeout_callback, logger: logging.Logger = None) +@plugs.tag +def play_card(folder: str, recursive: bool = False) ``` -**Arguments**: +Main entry point for trigger music playing from RFID reader. Decodes second swipe options before playing folder content -- `on_timeout_callback`: The function to execute on time-out +Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action +accordingly. - +**Arguments**: -# components.rfid.configure +- `folder`: Folder path relative to music library path +- `recursive`: Add folder recursively - + -#### reader\_install\_dependencies +#### flush\_coverart\_cache ```python -def reader_install_dependencies(reader_path: str, - dependency_install: str) -> None +@plugs.tag +def flush_coverart_cache() ``` -Install dependencies for the selected reader module - -**Arguments**: +Deletes the Cover Art Cache -- `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 +#### get\_folder\_content ```python -def reader_load_module(reader_name) +@plugs.tag +def get_folder_content(folder: str) ``` -Load the module for the reader_name +Get the folder content as content list with meta-information. Depth is always 1. -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 +Call repeatedly to descend in hierarchy **Arguments**: -- `reader_name`: Name of the reader to load the module for - -**Returns**: - -module +- `folder`: Folder path relative to music library path - + -#### query\_user\_for\_reader +#### play\_folder ```python -def query_user_for_reader(dependency_install='query') -> dict +@plugs.tag +def play_folder(folder: str, recursive: bool = False) -> None ``` -Ask the user to select a RFID reader and prompt for the reader's configuration +Playback a music folder. -This function performs the following steps, to find and present all available readers to the user +Folder content is added to the playlist as described by :mod:`jukebox.playlistgenerator`. +The playlist is cleared first. -- 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 +**Arguments**: -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 +- `folder`: Folder path relative to music library path +- `recursive`: Add folder recursively - + -#### write\_config +#### play\_album ```python -def write_config(config_file: str, - config_dict: dict, - force_overwrite=False) -> None +@plugs.tag +def play_album(albumartist: str, album: str) ``` -Write configuration to config_file - -**Arguments**: +Playback a album found in MPD database. -- `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 +All album songs are added to the playlist +The playlist is cleared first. - +**Arguments**: -# components.rfid.hardware.fake\_reader\_gui.fake\_reader\_gui +- `albumartist`: Artist of the Album provided by MPD database +- `album`: Album name provided by MPD database - + -# components.rfid.hardware.fake\_reader\_gui.description +#### get\_volume - +```python +def get_volume() +``` -# components.rfid.hardware.fake\_reader\_gui.gpioz\_gui\_addon +Get the current volume -Add GPIO input devices and output devices to the RFID Mock Reader GUI +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 - + -#### create\_inputs +#### set\_volume ```python -def create_inputs(frame, default_btn_width, default_padx, default_pady) +def set_volume(volume) ``` -Add all input devies to the GUI - -**Arguments**: - -- `frame`: The TK frame (e.g. LabelFrame) in the main GUI to add the buttons to +Set the volume -**Returns**: +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 -List of all added GUI buttons - + -#### set\_state +#### play\_card\_callbacks -```python -def set_state(value, box_state_var) -``` +Callback handler instance for play_card events. -Change the value of a checkbox state variable +- is executed when play_card function is called +States: +- See :class:`PlayCardState` +See :class:`PlayContentCallbacks` - + -#### que\_set\_state +# components.gpio.gpioz.core.converter -```python -def que_set_state(value, box_state_var) -``` +Provides converter functions/classes for various Jukebox parameters to -Queue the action to change a checkbox state variable to the TK GUI main thread +values that can be assigned to GPIO output devices - + -#### fix\_state +## ColorProperty Objects ```python -def fix_state(box_state_var) +class ColorProperty() ``` -Prevent a checkbox state variable to change on checkbox mouse press +Color descriptor ensuring valid weight ranges - + -#### pbox\_set\_state +## VolumeToRGB Objects ```python -def pbox_set_state(value, pbox_state_var, label_var) +class VolumeToRGB() ``` -Update progress bar state and related state label +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 -#### que\_set\_pbox + 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 que_set_pbox(value, pbox_state_var, label_var) +def __call__(volume) -> Tuple[float, float, float] ``` -Queue the action to change the progress bar state to the TK GUI main thread +Perform conversion for single volume level +**Returns**: - +Tuple(red, green, blue) -#### create\_outputs + + +#### luminize ```python -def create_outputs(frame, default_btn_width, default_padx, default_pady) +def luminize(r, g, b) ``` -Add all output devices to the GUI +Apply the color weight factors to the input color values -**Arguments**: -- `frame`: The TK frame (e.g. LabelFrame) in the main GUI to add the representations to + -**Returns**: +# components.gpio.gpioz.core.mock -List of all added GUI objects +Changes to the GPIOZero devices for using with the Mock RFID Reader - -# components.rfid.hardware.generic\_usb.description + - +#### patch\_mock\_outputs\_with\_callback -# components.rfid.hardware.generic\_usb.generic\_usb +```python +def patch_mock_outputs_with_callback() +``` - +Monkey Patch LED + Buzzer to get a callback when state changes -# components.rfid.hardware.rc522\_spi.description +This targets to represent the state in the TK GUI. +Other output devices cannot be represented in the GUI and are silently ignored. - +> [!NOTE] +> Only for developing purposes! -# components.rfid.hardware.rc522\_spi.rc522\_spi - + -# components.rfid.hardware.pn532\_i2c\_py532.description +# components.gpio.gpioz.core.output\_devices - +Provides all supported output devices for the GPIOZ plugin. -# components.rfid.hardware.pn532\_i2c\_py532.pn532\_i2c\_py532 +For each device all constructor parameters can be set via the configuration file. Only exceptions +are the :attr:`name` and :attr:`pin_factory` which are set by internal mechanisms. - +The devices a are a relatively thin wrapper around the GPIOZero devices with the same name. +We add a name property to be used for error log message and similar and a :func:`flash` function +to all devices. This function provides a unified API to all devices. This means it can be called for every device +with parameters for this device and optional parameters from another device. Unused/unsupported parameters +are silently ignored. This is done to reduce the amount of coding required for connectivity functions. -# components.rfid.hardware.rdm6300\_serial.rdm6300\_serial +For examples how to use the devices from the configuration files, see +[GPIO: Output Devices](../../builders/gpio.md#output-devices). - -#### decode + + +## LED Objects ```python -def decode(raw_card_id: bytearray, number_format: int) -> str +class LED(NameMixin, gpiozero.LED) ``` -Decode the RDM6300 data format into actual card ID +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 -# components.rfid.hardware.rdm6300\_serial.description + - +#### flash -# components.rfid.hardware.template\_new\_reader.description +```python +def flash(on_time=1, off_time=1, n=1, *, background=True, **ignored_kwargs) +``` -Provide a short title for this reader. +Exactly like :func:`blink` but restores the original state after flashing the device -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 +**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 -# components.rfid.hardware.template\_new\_reader.template\_new\_reader +```python +class Buzzer(NameMixin, gpiozero.Buzzer) +``` - + -#### query\_customization +#### flash ```python -def query_customization() -> dict +def flash(on_time=1, off_time=1, n=1, *, background=True, **ignored_kwargs) ``` -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 {} +Flash the device and restore the previous value afterwards - + -## ReaderClass Objects +## PWMLED Objects ```python -class ReaderClass(ReaderBaseClass) +class PWMLED(NameMixin, gpiozero.PWMLED) ``` -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\_\_ +#### flash ```python -def __init__(reader_cfg_key) +def flash(on_time=1, + off_time=1, + n=1, + *, + fade_in_time=0, + fade_out_time=0, + background=True, + **ignored_kwargs) ``` -In the constructor, you will get the `reader_cfg_key` with which you can access the configuration data - -As you are dealing directly with potentially user-manipulated config information, it is -advisable to do some sanity checks and give useful error messages. Even if you cannot recover gracefully, -a good error message helps :-) +Flash the LED and restore the previous value afterwards - + -#### cleanup +## RGBLED Objects ```python -def cleanup() +class RGBLED(NameMixin, gpiozero.RGBLED) ``` -The cleanup function: free and release all resources used by this card reader (if any). - -Put all your cleanup code here, e.g. if you are using the serial bus or GPIO pins. -Will be called implicitly via the __exit__ function -This function must exist! If there is nothing to do, just leave the pass statement in place below - - - + -#### stop +#### flash ```python -def stop() +def flash(on_time=1, + off_time=1, + *, + fade_in_time=0, + fade_out_time=0, + on_color=(1, 1, 1), + off_color=(0, 0, 0), + n=None, + background=True, + **igorned_kwargs) ``` -This function is called to tell the reader to exist it's reading function. - -This function is called before cleanup is called. - -> [!NOTE] -> This is usually called from a different thread than the reader's thread! And this is the reason for the -> two-step exit strategy. This function works across threads to indicate to the reader that is should stop attempt -> to read a card. Once called, the function read_card will not be called again. When the reader thread exits -> cleanup is called from the reader thread itself. +Flash the LED with :attr:`on_color` and restore the previous value afterwards - + -#### read\_card +## TonalBuzzer Objects ```python -def read_card() -> str +class TonalBuzzer(NameMixin, gpiozero.TonalBuzzer) ``` -Blocking or non-blocking function that waits for a new card to appear and return the card's UID as string - -This is were your main code goes :-) -This function must return a string with the card id -In case of error, it may return None or an empty string + -The function should break and return with an empty string, once stop() is called +#### flash +```python +def flash(on_time=1, + off_time=1, + n=1, + *, + tone=None, + background=True, + **ignored_kwargs) +``` - +Play the tone :data:`tone` for :attr:`n` times -# components.rfid.readerbase - + -## ReaderBaseClass Objects +#### melody ```python -class ReaderBaseClass(ABC) +def melody(on_time=0.2, + off_time=0.05, + *, + tone: Optional[List[Tone]] = None, + background=True) ``` -Abstract Base Class for all Reader Classes to ensure common API - -Look at template_new_reader.py for documentation how to integrate a new RFID reader +Play a melody from the list of tones in :attr:`tone` - + -# components.rfid.cards +# components.gpio.gpioz.core.input\_devices -Handling the RFID card database +Provides all supported input devices for the GPIOZ plugin. -A few considerations: -- Changing the Card DB influences to current state - - rfid.reader: Does not care, as it always freshly looks into the DB when a new card is triggered - - fake_reader_gui: Initializes the Drop-down menu once on start --> Will get out of date! +Input devices are based on GPIOZero devices. So for certain configuration parameters, you should +their documentation. -Do we need a notifier? Or a callback for modules to get notified? -Do we want to publish the information about a card DB update? -TODO: Add callback for on_database_change +All callback handlers are replaced by GPIOZ callback handlers. These are usually configured +by using the :func:`set_rpc_actions` each input device exhibits. -TODO: check card id type (if int, convert to str) -TODO: check if args is really a list (convert if not?) +For examples how to use the devices from the configuration files, see +[GPIO: Input Devices](../../builders/gpio.md#input-devices). - + -#### list\_cards +## NameMixin Objects ```python -@plugs.register -def list_cards() +class NameMixin(ABC) ``` -Provide a summarized, decoded list of all card actions - -This is intended as basis for a formatter function - -Format: 'id': {decoded_function_call, ignore_same_id_delay, ignore_card_removal_action, description, from_alias} +Provides name property and RPC decode function - + -#### delete\_card +#### set\_rpc\_actions ```python -@plugs.register -def delete_card(card_id: str, auto_save: bool = True) +@abstractmethod +def set_rpc_actions(action_config) -> None ``` +Set all input device callbacks from :attr:`action_config` + **Arguments**: -- `auto_save`: -- `card_id`: +- `action_config`: Dictionary with one +[RPC Commands](../../builders/rpc-commands.md) definition entry for every device callback - + -#### register\_card +## EventProperty Objects ```python -@plugs.register -def register_card(card_id: str, - cmd_alias: str, - args: Optional[List] = None, - kwargs: Optional[Dict] = None, - ignore_card_removal_action: Optional[bool] = None, - ignore_same_id_delay: Optional[bool] = None, - overwrite: bool = False, - auto_save: bool = True) +class EventProperty() ``` -Register a new card based on quick-selection - -If you are going to call this through the RPC it will get a little verbose - -**Example:** Registering a new card with ID *0009* for increment volume with a custom argument to inc_volume -(*here: 15*) and custom *ignore_same_id_delay value*:: - - plugin.call_ignore_errors('cards', 'register_card', - args=['0009', 'inc_volume'], - kwargs={'args': [15], 'ignore_same_id_delay': True, 'overwrite': True}) +Event callback property - + -#### register\_card\_custom +## ButtonBase Objects ```python -@plugs.register -def register_card_custom() +class ButtonBase(ABC) ``` -Register a new card with full RPC call specification (Not implemented yet) +Common stuff for single button devices - + -#### save\_card\_database +#### value ```python -@plugs.register -def save_card_database(filename=None, *, only_if_changed=True) +@property +def value() ``` -Store the current card database. If filename is None, it is saved back to the file it was loaded from - - - - -# components.rfid.cardutils - -Common card decoding functions - -TODO: Thread safety when accessing the card DB! +Returns 1 if the button is currently pressed, and 0 if it is not. - + -#### decode\_card\_command +#### pin ```python -def decode_card_command(cfg_rpc_cmd: Mapping, logger: logging.Logger = log) +@property +def pin() ``` -Extension of utils.decode_action with card-specific parameters +Returns the underlying pin class from GPIOZero. - + -#### card\_command\_to\_str +#### pull\_up ```python -def card_command_to_str(cfg_rpc_cmd: Mapping, long=False) -> List[str] +@property +def pull_up() ``` -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 +If :data:`True`, the device uses an internal pull-up resistor to set the GPIO pin “high” by default. - + -#### card\_to\_str +#### close ```python -def card_to_str(card_id: str, long=False) -> List[str] +def close() ``` -Returns a list of strings from card entry command in the format of :func:`card_command_to_str` - - - - -# components.publishing +Close the device and release the pin -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. +## Button Objects +```python +class Button(NameMixin, ButtonBase) +``` - +A basic Button that runs a single actions on button press -#### republish +**Arguments**: -```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 - - +- `pull_up` (`bool`): If :data:`True`, the device uses an internal pull-up resistor to set the GPIO pin “high” by default. +If :data:`False` the internal pull-down resistor is used. If :data:`None`, the pin will be floating and an external +resistor must be used and the :attr:`active_state` must be set. +- `active_state` (`bool or None`): If :data:`True`, when the hardware pin state is ``HIGH``, the software +pin is ``HIGH``. If :data:`False`, the input polarity is reversed: when +the hardware pin state is ``HIGH``, the software pin state is ``LOW``. +Use this parameter to set the active state of the underlying pin when +configuring it as not pulled (when *pull_up* is :data:`None`). When +*pull_up* is :data:`True` or :data:`False`, the active state is +automatically set to the proper value. +- `bounce_time` (`float or None`): Specifies the length of time (in seconds) that the component will +ignore changes in state after an initial change. This defaults to +:data:`None` which indicates that no bounce compensation will be +performed. +- `hold_repeat` (`bool`): If :data:`True` repeat the :attr:`on_press` every :attr:`hold_time` seconds. Else action +is run only once independent of the length of time the button is pressed for. +- `hold_time` (`float`): Time in seconds to wait between invocations of :attr:`on_press`. +- `pin_factory`: The GPIOZero pin factory. This parameter cannot be set through the configuration file +- `name` (`str`): The name of the button for use in error messages. This parameter cannot be set explicitly +through the configuration file -# components.player +.. copied from GPIOZero's documentation: active_state, bounce_time +.. Copyright Ben Nuttall / SPDX-License-Identifier: BSD-3-Clause - + -## MusicLibPath Objects +#### on\_press ```python -class MusicLibPath() +@property +def on_press() ``` -Extract the music directory from the mpd.conf file +The function to run when the device has been pressed - + -#### get\_music\_library\_path +## LongPressButton Objects ```python -def get_music_library_path() +class LongPressButton(NameMixin, ButtonBase) ``` -Get the music library path - - - - -# components.jingle +A Button that runs a single actions only when the button is pressed long enough -Jingle Playback Factory for extensible run-time support of various file types +**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`. - + -## JingleFactory Objects +#### on\_press ```python -class JingleFactory() +@on_press.setter +def on_press(func) ``` -Jingle Factory +The function to run when the device has been pressed for longer than :attr:`hold_time` - + -#### list +## ShortLongPressButton Objects ```python -def list() +class ShortLongPressButton(NameMixin, ButtonBase) ``` -List the available volume services +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**: -#### play +- `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) -```python -@plugin.register -def play(filename) -``` + -Play the jingle using the configured jingle service +## RotaryEncoder Objects -> [!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! +```python +class RotaryEncoder(NameMixin) +``` -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... +A rotary encoder to run one of two actions depending on the rotation direction. -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. +**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) - + -#### play\_startup +#### pin\_a ```python -@plugin.register -def play_startup() +@property +def pin_a() ``` -Play the startup sound (using jingle.play) +Returns the underlying pin A - + -#### play\_shutdown +#### pin\_b ```python -@plugin.register -def play_shutdown() +@property +def pin_b() ``` -Play the shutdown sound (using jingle.play) +Returns the underlying pin B - + -# components.jingle.alsawave +#### on\_rotate\_clockwise -ALSA wave jingle Service for jingle.JingleFactory +```python +@property +def on_rotate_clockwise() +``` +The function to run when the encoder is rotated clockwise - -## AlsaWave Objects + + +#### on\_rotate\_counter\_clockwise ```python -@plugin.register -class AlsaWave() +@property +def on_rotate_counter_clockwise() ``` -Jingle Service for playing wave files directly from Python through ALSA +The function to run when the encoder is rotated counter clockwise - + -#### play +#### close ```python -@plugin.tag -def play(filename) +def close() ``` -Play the wave file +Close the device and release the pin - + -## AlsaWaveBuilder Objects +## TwinButton Objects ```python -class AlsaWaveBuilder() +class TwinButton(NameMixin) ``` - +A two-button device which can run up to six different actions, a.k.a the six function beast. -#### \_\_init\_\_ +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! -```python -def __init__() -``` +It is not necessary to configure all actions. -Builder instantiates AlsaWave during init and not during first call because +**Arguments**: -we want AlsaWave registers as plugin function in any case if this plugin is loaded -(and not only on first use!) +- `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 -# components.jingle.jinglemp3 +```python +class StateVar(Enum) +``` -Generic MP3 jingle Service for jingle.JingleFactory +State encoding of the Mealy FSM - + -## JingleMp3Play Objects +#### close ```python -@plugin.register(auto_tag=True) -class JingleMp3Play() +def close() ``` -Jingle Service for playing MP3 files +Close the device and release the pins - + -#### play +#### value ```python -def play(filename) +@property +def value() ``` -Play the MP3 file +2 bit integer indicating if and which button is currently pressed. Button A is the LSB. - + -## JingleMp3PlayBuilder Objects +#### is\_active ```python -class JingleMp3PlayBuilder() +@property +def is_active() ``` - -#### \_\_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.gpio.gpioz.plugin.connectivity +Provide connector functions to hook up to some kind of Jukebox functionality and change the output device's state - +accordingly. -# components.hostif.linux +Connector functions can often be used for various output devices. Some connector functions are specific to +an output device type. - -#### shutdown + -```python -@plugin.register -def shutdown() -``` +#### BUZZ\_TONE -Shutdown the host machine +The tone to be used as buzz tone when the buzzer is an active buzzer - + -#### reboot +#### register\_rfid\_callback ```python -@plugin.register -def reboot() +def register_rfid_callback(device) ``` -Reboot the host machine +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` -#### jukebox\_is\_service + + + +#### register\_status\_led\_callback ```python -@plugin.register -def jukebox_is_service() +def register_status_led_callback(device) ``` -Check if current Jukebox process is running as a service +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` - + -#### is\_any\_jukebox\_service\_active +#### register\_status\_buzzer\_callback ```python -@plugin.register -def is_any_jukebox_service_active() +def register_status_buzzer_callback(device) ``` -Check if a Jukebox service is running +Buzz once when Jukebox App has started, twice when closing down -> [!NOTE] -> Does not have the be the current app, that is running as a service! +Compatible devices: +* :class:`components.gpio.gpioz.core.output_devices.Buzzer` +* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` - -#### restart\_service + + +#### register\_status\_tonalbuzzer\_callback ```python -@plugin.register -def restart_service() +def register_status_tonalbuzzer_callback(device) ``` -Restart Jukebox App if running as a service +Buzz a multi-note melody when Jukebox App has started and when closing down +Compatible devices: - +* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` -#### get\_disk\_usage + + + +#### register\_audio\_sink\_change\_callback ```python -@plugin.register() -def get_disk_usage(path='/') +def register_audio_sink_change_callback(device) ``` -Return the disk usage in Megabytes as dictionary for RPC export +Turn LED on if secondary audio output is selected. If audio output change +fails, blink thrice - +Compatible devices: -#### get\_cpu\_temperature +* :class:`components.gpio.gpioz.core.output_devices.LED` +* :class:`components.gpio.gpioz.core.output_devices.PWMLED` +* :class:`components.gpio.gpioz.core.output_devices.RGBLED` -```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! +#### register\_volume\_led\_callback +```python +def register_volume_led_callback(device) +``` - +Have a PWMLED change it's brightness according to current volume. LED flashes when minimum or maximum volume -#### get\_ip\_address +is reached. Minimum value is still a very dimly turned on LED (i.e. LED is never off). -```python -@plugin.register -def get_ip_address() -``` +Compatible devices: -Get the IP address +* :class:`components.gpio.gpioz.core.output_devices.PWMLED` - + -#### wlan\_disable\_power\_down +#### register\_volume\_buzzer\_callback ```python -@plugin.register() -def wlan_disable_power_down(card=None) +def register_volume_buzzer_callback(device) ``` -Turn off power management of wlan. Keep RPi reachable via WLAN +Sound a buzzer once when minimum or maximum value is reached -This must be done after every reboot -card=None takes card from configuration file +Compatible devices: +* :class:`components.gpio.gpioz.core.output_devices.Buzzer` +* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` - -#### get\_autohotspot\_status + + +#### register\_volume\_rgbled\_callback ```python -@plugin.register -def get_autohotspot_status() +def register_volume_rgbled_callback(device) ``` -Get the status of the auto hotspot feature +Have a :class:`RGBLED` change it's color according to current volume. LED flashes when minimum or maximum volume +is reached. - +Compatible devices: -#### stop\_autohotspot +* :class:`components.gpio.gpioz.core.output_devices.RGBLED` -```python -@plugin.register() -def stop_autohotspot() -``` -Stop auto hotspot functionality + -Basically disabling the cronjob and running the script one last time manually +# 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. -#### start\_autohotspot -```python -@plugin.register() -def start_autohotspot() -``` + -start auto hotspot functionality +#### output\_devices -Basically enabling the cronjob and running the script one time manually +List of all created output devices - + -# components.misc +#### input\_devices -Miscellaneous function package +List of all created input devices - + -#### rpc\_cmd\_help +#### factory -```python -@plugin.register -def rpc_cmd_help() -``` +The global pin factory used in this module -Return all commands for RPC +Using different pin factories for different devices is not supported - + -#### get\_all\_loaded\_packages +#### IS\_ENABLED -```python -@plugin.register -def get_all_loaded_packages() -``` +Indicates that the GPIOZ module is enabled and loaded w/o errors -Get all successfully loaded plugins + - +#### IS\_MOCKED -#### get\_all\_failed\_packages +Indicates that the pin factory is a mock factory -```python -@plugin.register -def get_all_failed_packages() -``` -Get all plugins with error during load or initialization + +#### CONFIG\_FILE - +The path of the config file the GPIOZ configuration was loaded from -#### get\_start\_time + + + +## ServiceIsRunningCallbacks Objects ```python -@plugin.register -def get_start_time() +class ServiceIsRunningCallbacks(CallbackHandler) ``` -Time when JukeBox has been started +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: -#### get\_log +* 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 get_log(handler_name: str) +def register(func: Callable[[int], None]) ``` -Get the log file from the loggers (debug_file_handler, error_file_handler) +Add a new callback function :attr:`func`. +Callback signature is - +.. py:function:: func(status: int) + :noindex: -#### get\_log\_debug +**Arguments**: + +- `status`: 1 if app started, 0 if app shuts down + + + +#### run\_callbacks ```python -@plugin.register -def get_log_debug() +def run_callbacks(status: int) ``` -Get the log file (from the debug_file_handler) - + -#### get\_log\_error +#### service\_is\_running\_callbacks -```python -@plugin.register -def get_log_error() -``` +Callback handler instance for service_is_running_callbacks events. -Get the log file (from the error_file_handler) +See :class:`ServiceIsRunningCallbacks` - + -#### get\_git\_state +#### build\_output\_device ```python -@plugin.register -def get_git_state() +def build_output_device(name: str, config: Dict) ``` -Return git state information for the current branch +Construct and register a new output device +In principal all supported GPIOZero output devices can be used. +For all devices a custom functions need to be written to control the state of the outputs - -#### empty\_rpc\_call + + +#### build\_input\_device ```python -@plugin.register -def empty_rpc_call(msg: str = '') +def build_input_device(name: str, config) ``` -This function does nothing. - -The RPC command alias 'none' is mapped to this function. - -This is also used when configuration errors lead to non existing RPC command alias definitions. -When the alias definition is void, we still want to return a valid function to simplify error handling -up the module call stack. - -**Arguments**: +Construct and connect a new input device -- `msg`: If present, this message is send to the logger with severity warning +Supported input devices are those from gpio.gpioz.core.input_devices - -# components.controls + - +#### get\_output -# components.controls.bluetooth\_audio\_buttons +```python +def get_output(name: str) +``` -Plugin to attempt to automatically listen to it's buttons (play, next, ...) +Get the output device instance based on the configured name -when a bluetooth sound device (headphone, speakers) connects +**Arguments**: -This effectively does: +- `name`: The alias name output device instance -* 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 + +#### on - +```python +@plugin.register +def on(name: str) +``` -# components.controls.common.evdev\_listener +Turn an output device on -Generalized listener for ``dev/input`` devices +**Arguments**: +- `name`: The alias name output device instance - + -#### find\_device +#### off ```python -def find_device(device_name: str, - exact_name: bool = True, - mandatory_keys: Optional[Set[int]] = None) -> str +@plugin.register +def off(name: str) ``` -Find an input device with device_name and mandatory keys. +Turn an output device off **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` +- `name`: The alias name output device instance -**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 +#### set\_value -**Returns**: +```python +@plugin.register +def set_value(name: str, value: Any) +``` -The path to the device +Set the output device to :attr:`value` - +**Arguments**: -## EvDevKeyListener Objects +- `name`: The alias name output device instance +- `value`: Value to set the device to + + + +#### flash ```python -class EvDevKeyListener(threading.Thread) +@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)) ``` -Opens and event input device from ``/dev/inputs``, and runs callbacks upon the button presses. +Flash (blink or beep) an output device -Input devices could be .e.g. Keyboard, Bluetooth audio buttons, USB buttons +This is a generic function for all types of output devices. Parameters not applicable to an +specific output device are silently ignored -Runs as a separate thread. When device disconnects or disappears, thread exists. A new thread must be started -when device re-connects. +**Arguments**: -Assign callbacks to :attr:`EvDevKeyListener.button_callbacks` +- `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.rfid.configure -#### \_\_init\_\_ + + +#### reader\_install\_dependencies ```python -def __init__(device_name_request: str, exact_name: bool, thread_name: str) +def reader_install_dependencies(reader_path: str, + dependency_install: str) -> None ``` +Install dependencies for the selected reader module + **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 +- `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 - + -#### run +#### reader\_load\_module ```python -def run() +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**: -#### start +- `reader_name`: Name of the reader to load the module for -```python -def start() -> None -``` +**Returns**: -Start the tread and start listening +module + - +#### query\_user\_for\_reader -# components.battery\_monitor +```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 -# components.battery\_monitor.BatteryMonitorBase +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 -## pt1\_frac Objects +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. -```python -class pt1_frac() -``` +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 -fixed point first order filter, fractional format: 2^16,2^16 +**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 -## BattmonBase Objects + + +#### write\_config ```python -class BattmonBase() +def write_config(config_file: str, + config_dict: dict, + force_overwrite=False) -> None ``` -Battery Monitor base class - +Write configuration to config_file - +**Arguments**: -# components.battery\_monitor.batt\_mon\_simulator +- `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 - + -## battmon\_simulator Objects +# components.rfid -```python -class battmon_simulator(BatteryMonitorBase.BattmonBase) -``` + -Battery Monitor Simulator +# components.rfid.cardutils +Common card decoding functions - +TODO: Thread safety when accessing the card DB! -# components.battery\_monitor.batt\_mon\_i2c\_ads1015 - + -## battmon\_ads1015 Objects +#### decode\_card\_command ```python -class battmon_ads1015(BatteryMonitorBase.BattmonBase) +def decode_card_command(cfg_rpc_cmd: Mapping, logger: logging.Logger = log) ``` -Battery Monitor based on a ADS1015 +Extension of utils.decode_action with card-specific parameters -> [!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 +#### card\_command\_to\_str -The GPIOZ plugin interface build all input and output devices from the configuration file and connects +```python +def card_command_to_str(cfg_rpc_cmd: Mapping, long=False) -> List[str] +``` -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. +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 - -#### output\_devices + -List of all created output devices +#### 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` -#### input\_devices -List of all created input devices + +# components.rfid.cards - +Handling the RFID card database -#### factory +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! -The global pin factory used in this module +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 -Using different pin factories for different devices is not supported +TODO: check card id type (if int, convert to str) +TODO: check if args is really a list (convert if not?) - + -#### IS\_ENABLED +#### list\_cards -Indicates that the GPIOZ module is enabled and loaded w/o errors +```python +@plugs.register +def list_cards() +``` +Provide a summarized, decoded list of all card actions - +This is intended as basis for a formatter function -#### IS\_MOCKED +Format: 'id': {decoded_function_call, ignore_same_id_delay, ignore_card_removal_action, description, from_alias} -Indicates that the pin factory is a mock factory + - +#### delete\_card -#### CONFIG\_FILE +```python +@plugs.register +def delete_card(card_id: str, auto_save: bool = True) +``` -The path of the config file the GPIOZ configuration was loaded from +**Arguments**: +- `auto_save`: +- `card_id`: - + -## ServiceIsRunningCallbacks Objects +#### register\_card ```python -class ServiceIsRunningCallbacks(CallbackHandler) +@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) ``` -Callbacks are executed when +Register a new card based on quick-selection -* Jukebox app started -* Jukebox shuts down +If you are going to call this through the RPC it will get a little verbose -This is intended to e.g. signal an LED to change state. -This is integrated into this module because: +**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*:: -* 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 + plugin.call_ignore_errors('cards', 'register_card', + args=['0009', 'inc_volume'], + kwargs={'args': [15], 'ignore_same_id_delay': True, 'overwrite': True}) - + -#### register +#### register\_card\_custom ```python -def register(func: Callable[[int], None]) +@plugs.register +def register_card_custom() ``` -Add a new callback function :attr:`func`. - -Callback signature is - -.. py:function:: func(status: int) - :noindex: - -**Arguments**: +Register a new card with full RPC call specification (Not implemented yet) -- `status`: 1 if app started, 0 if app shuts down - + -#### run\_callbacks +#### save\_card\_database ```python -def run_callbacks(status: int) +@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 - - -#### service\_is\_running\_callbacks - -Callback handler instance for service_is_running_callbacks events. - -See :class:`ServiceIsRunningCallbacks` + +# components.rfid.readerbase - + -#### build\_output\_device +## ReaderBaseClass Objects ```python -def build_output_device(name: str, config: Dict) +class ReaderBaseClass(ABC) ``` -Construct and register a new output device +Abstract Base Class for all Reader Classes to ensure common API -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 +Look at template_new_reader.py for documentation how to integrate a new RFID reader - + -#### build\_input\_device +# components.rfid.reader + + + +## RfidCardDetectCallbacks Objects ```python -def build_input_device(name: str, config) +class RfidCardDetectCallbacks(CallbackHandler) ``` -Construct and connect a new input device - -Supported input devices are those from gpio.gpioz.core.input_devices +Callbacks are executed if rfid card is detected - + -#### get\_output +#### register ```python -def get_output(name: str) +def register(func: Callable[[str, RfidCardDetectState], None]) ``` -Get the output device instance based on the configured name +Add a new callback function :attr:`func`. + +Callback signature is + +.. py:function:: func(card_id: str, state: int) + :noindex: **Arguments**: -- `name`: The alias name output device instance +- `card_id`: Card ID +- `state`: See `RfidCardDetectState` - + -#### on +#### run\_callbacks ```python -@plugin.register -def on(name: str) +def run_callbacks(card_id: str, state: RfidCardDetectState) ``` -Turn an output device on - -**Arguments**: - -- `name`: The alias name output device instance - -#### off + -```python -@plugin.register -def off(name: str) -``` +#### rfid\_card\_detect\_callbacks -Turn an output device off +Callback handler instance for rfid_card_detect_callbacks events. -**Arguments**: +See [`RfidCardDetectCallbacks`](#components.rfid.reader.RfidCardDetectCallbacks) -- `name`: The alias name output device instance - + -#### set\_value +## CardRemovalTimerClass Objects ```python -@plugin.register -def set_value(name: str, value: Any) +class CardRemovalTimerClass(threading.Thread) ``` -Set the output device to :attr:`value` - -**Arguments**: +A timer watchdog thread that calls timeout_action on time-out -- `name`: The alias name output device instance -- `value`: Value to set the device to - + -#### flash +#### \_\_init\_\_ ```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)) +def __init__(on_timeout_callback, logger: logging.Logger = None) ``` -Flash (blink or beep) an output device +**Arguments**: -This is a generic function for all types of output devices. Parameters not applicable to an -specific output device are silently ignored +- `on_timeout_callback`: The function to execute on time-out -**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.rfid.hardware.rdm6300\_serial.description - + -# components.gpio.gpioz.plugin.connectivity +# components.rfid.hardware.rdm6300\_serial.rdm6300\_serial -Provide connector functions to hook up to some kind of Jukebox functionality and change the output device's state + -accordingly. +#### decode -Connector functions can often be used for various output devices. Some connector functions are specific to -an output device type. +```python +def decode(raw_card_id: bytearray, number_format: int) -> str +``` +Decode the RDM6300 data format into actual card ID - -#### BUZZ\_TONE - -The tone to be used as buzz tone when the buzzer is an active buzzer - - - + -#### register\_rfid\_callback +# components.rfid.hardware.template\_new\_reader.description -```python -def register_rfid_callback(device) -``` +Provide a short title for this reader. -Flash the output device once on successful RFID card detection and thrice if card ID is unknown +This is what that user will see when asked for selecting his RFID reader +So, be precise but readable. Precise means 40 characters or less -Compatible devices: -* :class:`components.gpio.gpioz.core.output_devices.LED` -* :class:`components.gpio.gpioz.core.output_devices.PWMLED` -* :class:`components.gpio.gpioz.core.output_devices.RGBLED` -* :class:`components.gpio.gpioz.core.output_devices.Buzzer` -* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` + +# components.rfid.hardware.template\_new\_reader.template\_new\_reader - + -#### register\_status\_led\_callback +#### query\_customization ```python -def register_status_led_callback(device) +def query_customization() -> dict ``` -Turn LED on when Jukebox App has started - -Compatible devices: +Query the user for reader parameter customization -* :class:`components.gpio.gpioz.core.output_devices.LED` -* :class:`components.gpio.gpioz.core.output_devices.PWMLED` -* :class:`components.gpio.gpioz.core.output_devices.RGBLED` +This function will be called during the configuration/setup phase when the user selects this reader module. +It must return all configuration parameters that are necessary to later use the Reader class. +You can ask the user for selections and choices. And/or provide default values. +If your reader requires absolutely no configuration return {} - + -#### register\_status\_buzzer\_callback +## ReaderClass Objects ```python -def register_status_buzzer_callback(device) +class ReaderClass(ReaderBaseClass) ``` -Buzz once when Jukebox App has started, twice when closing down +The actual reader class that is used to read RFID cards. -Compatible devices: +It will be instantiated once and then read_card() is called in an endless loop. -* :class:`components.gpio.gpioz.core.output_devices.Buzzer` -* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` +It will be used in a manner + with Reader(reader_cfg_key) as reader: + for card_id in reader: + ... +which ensures proper resource de-allocation. For this to work derive this class from ReaderBaseClass. +All the required interfaces are implemented there. + +Put your code into these functions (see below for more information) + - `__init__` + - read_card + - cleanup + - stop - + -#### register\_status\_tonalbuzzer\_callback +#### \_\_init\_\_ ```python -def register_status_tonalbuzzer_callback(device) +def __init__(reader_cfg_key) ``` -Buzz a multi-note melody when Jukebox App has started and when closing down - -Compatible devices: +In the constructor, you will get the `reader_cfg_key` with which you can access the configuration data -* :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer` +As you are dealing directly with potentially user-manipulated config information, it is +advisable to do some sanity checks and give useful error messages. Even if you cannot recover gracefully, +a good error message helps :-) - + -#### register\_audio\_sink\_change\_callback +#### cleanup ```python -def register_audio_sink_change_callback(device) +def cleanup() ``` -Turn LED on if secondary audio output is selected. If audio output change - -fails, blink thrice - -Compatible devices: +The cleanup function: free and release all resources used by this card reader (if any). -* :class:`components.gpio.gpioz.core.output_devices.LED` -* :class:`components.gpio.gpioz.core.output_devices.PWMLED` -* :class:`components.gpio.gpioz.core.output_devices.RGBLED` +Put all your cleanup code here, e.g. if you are using the serial bus or GPIO pins. +Will be called implicitly via the __exit__ function +This function must exist! If there is nothing to do, just leave the pass statement in place below - + -#### register\_volume\_led\_callback +#### stop ```python -def register_volume_led_callback(device) +def stop() ``` -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). +This function is called to tell the reader to exist it's reading function. -Compatible devices: +This function is called before cleanup is called. -* :class:`components.gpio.gpioz.core.output_devices.PWMLED` +> [!NOTE] +> This is usually called from a different thread than the reader's thread! And this is the reason for the +> two-step exit strategy. This function works across threads to indicate to the reader that is should stop attempt +> to read a card. Once called, the function read_card will not be called again. When the reader thread exits +> cleanup is called from the reader thread itself. - + -#### register\_volume\_buzzer\_callback +#### read\_card ```python -def register_volume_buzzer_callback(device) +def read_card() -> str ``` -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` +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 -#### 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 +# components.rfid.hardware.generic\_nfcpy.description -is reached. +List of supported devices https://nfcpy.readthedocs.io/en/latest/overview.html -Compatible devices: -* :class:`components.gpio.gpioz.core.output_devices.RGBLED` + +# components.rfid.hardware.generic\_nfcpy.generic\_nfcpy - + -# components.gpio.gpioz.core.converter +## ReaderClass Objects -Provides converter functions/classes for various Jukebox parameters to +```python +class ReaderClass(ReaderBaseClass) +``` -values that can be assigned to GPIO output devices +The reader class for nfcpy supported NFC card readers. - + -## ColorProperty Objects +#### cleanup ```python -class ColorProperty() +def cleanup() ``` -Color descriptor ensuring valid weight ranges +The cleanup function: free and release all resources used by this card reader (if any). - + -## VolumeToRGB Objects +#### stop ```python -class VolumeToRGB() +def stop() ``` -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) +This function is called to tell the reader to exit its reading function. -The three components of an RGB LEDs do not have the same luminosity. -Weight factors are used to get a balanced color output - + -#### \_\_call\_\_ +#### read\_card ```python -def __call__(volume) -> Tuple[float, float, float] +def read_card() -> str ``` -Perform conversion for single volume level +Blocking or non-blocking function that waits for a new card to appear and return the card's UID as string -**Returns**: -Tuple(red, green, blue) + - +# components.rfid.hardware.generic\_usb.description -#### luminize + -```python -def luminize(r, g, b) -``` +# components.rfid.hardware.generic\_usb.generic\_usb -Apply the color weight factors to the input color values + +# components.rfid.hardware.fake\_reader\_gui.fake\_reader\_gui - + -# components.gpio.gpioz.core.mock +# components.rfid.hardware.fake\_reader\_gui.description -Changes to the GPIOZero devices for using with the Mock RFID Reader + +# components.rfid.hardware.fake\_reader\_gui.gpioz\_gui\_addon - +Add GPIO input devices and output devices to the RFID Mock Reader GUI -#### patch\_mock\_outputs\_with\_callback + + + +#### create\_inputs ```python -def patch_mock_outputs_with_callback() +def create_inputs(frame, default_btn_width, default_padx, default_pady) ``` -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. +Add all input devies to the GUI -> [!NOTE] -> Only for developing purposes! +**Arguments**: +- `frame`: The TK frame (e.g. LabelFrame) in the main GUI to add the buttons to - +**Returns**: -# components.gpio.gpioz.core.input\_devices +List of all added GUI buttons -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. +#### set\_state -All callback handlers are replaced by GPIOZ callback handlers. These are usually configured -by using the :func:`set_rpc_actions` each input device exhibits. +```python +def set_state(value, box_state_var) +``` -For examples how to use the devices from the configuration files, see -[GPIO: Input Devices](../../builders/gpio.md#input-devices). +Change the value of a checkbox state variable - + -## NameMixin Objects +#### que\_set\_state ```python -class NameMixin(ABC) +def que_set_state(value, box_state_var) ``` -Provides name property and RPC decode function +Queue the action to change a checkbox state variable to the TK GUI main thread - + -#### set\_rpc\_actions +#### fix\_state ```python -@abstractmethod -def set_rpc_actions(action_config) -> None +def fix_state(box_state_var) ``` -Set all input device callbacks from :attr:`action_config` +Prevent a checkbox state variable to change on checkbox mouse press -**Arguments**: -- `action_config`: Dictionary with one -[RPC Commands](../../builders/rpc-commands.md) definition entry for every device callback - - + -## EventProperty Objects +#### pbox\_set\_state ```python -class EventProperty() +def pbox_set_state(value, pbox_state_var, label_var) ``` -Event callback property +Update progress bar state and related state label - + -## ButtonBase Objects +#### que\_set\_pbox ```python -class ButtonBase(ABC) +def que_set_pbox(value, pbox_state_var, label_var) ``` -Common stuff for single button devices +Queue the action to change the progress bar state to the TK GUI main thread - + -#### value +#### create\_outputs ```python -@property -def value() +def create_outputs(frame, default_btn_width, default_padx, default_pady) ``` -Returns 1 if the button is currently pressed, and 0 if it is not. +Add all output devices to the GUI +**Arguments**: - +- `frame`: The TK frame (e.g. LabelFrame) in the main GUI to add the representations to -#### pin +**Returns**: -```python -@property -def pin() -``` +List of all added GUI objects -Returns the underlying pin class from GPIOZero. + +# components.rfid.hardware.pn532\_i2c\_py532.pn532\_i2c\_py532 - + -#### pull\_up +# components.rfid.hardware.pn532\_i2c\_py532.description + + + +# components.rfid.hardware.rc522\_spi.rc522\_spi + + + +# components.rfid.hardware.rc522\_spi.description + + + +# components.player + + + +## MusicLibPath Objects ```python -@property -def pull_up() +class MusicLibPath() ``` -If :data:`True`, the device uses an internal pull-up resistor to set the GPIO pin “high” by default. +Extract the music directory from the mpd.conf file - + -#### close +#### get\_music\_library\_path ```python -def close() +def get_music_library_path() ``` -Close the device and release the pin +Get the music library path - + -## Button Objects +# components.battery\_monitor.batt\_mon\_i2c\_ina219 + + + +## battmon\_ina219 Objects ```python -class Button(NameMixin, ButtonBase) +class battmon_ina219(BatteryMonitorBase.BattmonBase) ``` -A basic Button that runs a single actions on button press +Battery Monitor based on a INA219 -**Arguments**: +See [Battery Monitor documentation](../../builders/components/power/batterymonitor.md) -- `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 + - +# components.battery\_monitor.batt\_mon\_i2c\_ads1015 -#### on\_press + + +## battmon\_ads1015 Objects ```python -@property -def on_press() +class battmon_ads1015(BatteryMonitorBase.BattmonBase) ``` -The function to run when the device has been pressed +Battery Monitor based on a ADS1015 +See [Battery Monitor documentation](../../builders/components/power/batterymonitor.md) - -## LongPressButton Objects + + +# components.battery\_monitor.batt\_mon\_simulator + + + +## battmon\_simulator Objects ```python -class LongPressButton(NameMixin, ButtonBase) +class battmon_simulator(BatteryMonitorBase.BattmonBase) ``` -A Button that runs a single actions only when the button is pressed long enough +Battery Monitor Simulator -**Arguments**: -- `pull_up`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) -- `active_state`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) -- `bounce_time`: See [`Button`](#components.gpio.gpioz.core.input_devices.Button) -- `hold_repeat`: If :data:`True` repeat the :attr:`on_press` every :attr:`hold_time` seconds. Else only action -is run only once independent of the length of time the button is pressed for. -- `hold_time`: The minimum time, the button must be pressed be running :attr:`on_press` for the first time. -Also the time in seconds to wait between invocations of :attr:`on_press`. + - +# components.battery\_monitor.BatteryMonitorBase -#### on\_press + + +## pt1\_frac Objects ```python -@on_press.setter -def on_press(func) +class pt1_frac() ``` -The function to run when the device has been pressed for longer than :attr:`hold_time` +fixed point first order filter, fractional format: 2^16,2^16 - + -## ShortLongPressButton Objects +## BattmonBase Objects ```python -class ShortLongPressButton(NameMixin, ButtonBase) +class BattmonBase() ``` -A single button that runs two different actions depending if the button is pressed for a short or long time. +Battery Monitor base class -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) +# components.battery\_monitor - + -## RotaryEncoder Objects +# components.controls.bluetooth\_audio\_buttons -```python -class RotaryEncoder(NameMixin) -``` +Plugin to attempt to automatically listen to it's buttons (play, next, ...) -A rotary encoder to run one of two actions depending on the rotation direction. +when a bluetooth sound device (headphone, speakers) connects -**Arguments**: +This effectively does: -- `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) +* 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 - -#### pin\_a + -```python -@property -def pin_a() -``` +# components.controls.event\_devices -Returns the underlying pin A +Plugin to register event_devices (ie USB controllers, keyboards etc) in a +generic manner. - +This effectively does: -#### pin\_b + * parse the configured event devices from the evdev.yaml + * setup listen threads -```python -@property -def pin_b() -``` -Returns the underlying pin B + + +#### IS\_ENABLED +Indicates that the module is enabled and loaded w/o errors - -#### on\_rotate\_clockwise + -```python -@property -def on_rotate_clockwise() -``` +#### CONFIG\_FILE -The function to run when the encoder is rotated clockwise +The path of the config file the event device configuration was loaded from - + -#### on\_rotate\_counter\_clockwise +#### activate ```python -@property -def on_rotate_counter_clockwise() +@plugin.register +def activate(device_name: str, + button_callbacks: dict[int, Callable], + exact: bool = True, + mandatory_keys: set[int] | None = None) ``` -The function to run when the encoder is rotated counter clockwise +Activate an event device listener + +**Arguments**: +- `device_name` (`str`): device name +- `button_callbacks` (`dict[int, Callable]`): mapping of event +code to RPC +- `exact` (`bool, optional`): Should the device_name match exactly +(default, false) or be a substring of the name? +- `mandatory_keys` (`set[int] | None, optional`): Mandatory event ids the +device needs to support. Defaults to None +to require all ids from the button_callbacks - + -#### close +#### initialize ```python -def close() +@plugin.initialize +def initialize() ``` -Close the device and release the pin +Initialize event device button listener from config +Initializes event buttons from the main configuration file. +Please see the documentation `builders/event-devices.md` for a specification of the format. - -## TwinButton Objects + + +#### parse\_device\_config ```python -class TwinButton(NameMixin) +def parse_device_config(config: dict) -> Tuple[str, bool, dict[int, Callable]] ``` -A two-button device which can run up to six different actions, a.k.a the six function beast. +Parse the device configuration from the config file -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! +**Arguments**: -It is not necessary to configure all actions. +- `config` (`dict`): The configuration of the device -**Arguments**: +**Returns**: -- `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) +`Tuple[str, bool, dict[int, Callable]]`: The parsed device configuration - - -## StateVar Objects + -```python -class StateVar(Enum) -``` +# components.controls.common.evdev\_listener -State encoding of the Mealy FSM +Generalized listener for ``dev/input`` devices - + -#### close +#### find\_device ```python -def close() +def find_device(device_name: str, + exact_name: bool = True, + mandatory_keys: Optional[Set[int]] = None) -> str ``` -Close the device and release the pins +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` -#### value +**Raises**: -```python -@property -def value() -``` +- `FileNotFoundError`: if no device is found. +- `AttributeError`: if device does not have the mandatory key +If multiple devices match, the first match is returned -2 bit integer indicating if and which button is currently pressed. Button A is the LSB. +**Returns**: +The path to the device - + -#### is\_active +## EvDevKeyListener Objects ```python -@property -def is_active() +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 - - -# 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. +Runs as a separate thread. When device disconnects or disappears, thread exists. A new thread must be started +when device re-connects. -For examples how to use the devices from the configuration files, see -[GPIO: Output Devices](../../builders/gpio.md#output-devices). +Assign callbacks to :attr:`EvDevKeyListener.button_callbacks` - + -## LED Objects +#### \_\_init\_\_ ```python -class LED(NameMixin, gpiozero.LED) +def __init__(device_name_request: str, exact_name: bool, thread_name: str) ``` -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 +- `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 - + -#### flash +#### run ```python -def flash(on_time=1, off_time=1, n=1, *, background=True, **ignored_kwargs) +def run() ``` -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 +#### start ```python -class Buzzer(NameMixin, gpiozero.Buzzer) +def start() -> None ``` - +Start the tread and start listening -#### 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 +# components.controls + - +# components.misc -## PWMLED Objects +Miscellaneous function package -```python -class PWMLED(NameMixin, gpiozero.PWMLED) -``` - + -#### flash +#### rpc\_cmd\_help ```python -def flash(on_time=1, - off_time=1, - n=1, - *, - fade_in_time=0, - fade_out_time=0, - background=True, - **ignored_kwargs) +@plugin.register +def rpc_cmd_help() ``` -Flash the LED and restore the previous value afterwards +Return all commands for RPC - + -## RGBLED Objects +#### get\_all\_loaded\_packages ```python -class RGBLED(NameMixin, gpiozero.RGBLED) +@plugin.register +def get_all_loaded_packages() ``` - +Get all successfully loaded plugins -#### flash + + + +#### get\_all\_failed\_packages ```python -def flash(on_time=1, - off_time=1, - *, - fade_in_time=0, - fade_out_time=0, - on_color=(1, 1, 1), - off_color=(0, 0, 0), - n=None, - background=True, - **igorned_kwargs) +@plugin.register +def get_all_failed_packages() ``` -Flash the LED with :attr:`on_color` and restore the previous value afterwards +Get all plugins with error during load or initialization - + -## TonalBuzzer Objects +#### get\_start\_time ```python -class TonalBuzzer(NameMixin, gpiozero.TonalBuzzer) +@plugin.register +def get_start_time() ``` - +Time when JukeBox has been started -#### flash + + + +#### get\_log ```python -def flash(on_time=1, - off_time=1, - n=1, - *, - tone=None, - background=True, - **ignored_kwargs) +def get_log(handler_name: str) ``` -Play the tone :data:`tone` for :attr:`n` times +Get the log file from the loggers (debug_file_handler, error_file_handler) - + -#### melody +#### get\_log\_debug ```python -def melody(on_time=0.2, - off_time=0.05, - *, - tone: Optional[List[Tone]] = None, - background=True) +@plugin.register +def get_log_debug() ``` -Play a melody from the list of tones in :attr:`tone` +Get the log file (from the debug_file_handler) - + -# components.timers +#### get\_log\_error - +```python +@plugin.register +def get_log_error() +``` -# jukebox +Get the log file (from the error_file_handler) - -# jukebox.callingback + -Provides a generic callback handler +#### get\_git\_state +```python +@plugin.register +def get_git_state() +``` - +Return git state information for the current branch -## CallbackHandler Objects + + + +#### empty\_rpc\_call ```python -class CallbackHandler() +@plugin.register +def empty_rpc_call(msg: str = '') ``` -Generic Callback Handler to collect callbacks functions through :func:`register` and execute them +This function does nothing. -with :func:`run_callbacks` +The RPC command alias 'none' is mapped to this function. -A lock is used to sequence registering of new functions and running callbacks. +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**: -- `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 +- `msg`: If present, this message is send to the logger with severity warning - + -#### register +#### get\_app\_settings ```python -def register(func: Optional[Callable[..., None]]) +@plugin.register +def get_app_settings() ``` -Register a new function to be executed when the callback event happens - -**Arguments**: +Return settings for web app stored in jukebox.yaml -- `func`: The function to register. If set to :data:`None`, this register request is silently ignored. - + -#### run\_callbacks +#### set\_app\_settings ```python -def run_callbacks(*args, **kwargs) +@plugin.register +def set_app_settings(settings={}) ``` -Run all registered callbacks. - -*ALL* exceptions from callback functions will be caught and logged only. -Exceptions are not raised upwards! - +Set configuration settings for the web app. - -#### has\_callbacks + -```python -@property -def has_callbacks() -``` +# 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. -# jukebox.version - + -#### version +#### republish ```python -def version() +@plugin.register +def republish(topic=None) ``` -Return the Jukebox version as a string - +Re-publish the topic tree 'topic' to all subscribers - +**Arguments**: -#### version\_info +- `topic`: Topic tree to republish. None = resend all -```python -def version_info() -``` + -Return the Jukebox version as a tuple of three numbers +# jukebox -If this is a development version, an identifier string will be appended after the third integer. + +# jukebox.callingback - +Provides a generic callback handler -# 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:: +## CallbackHandler Objects - cfg = get_handler('global') - load_yaml(cfg, 'filename.yaml') +```python +class CallbackHandler() +``` -In all other modules (in potentially different threads) the same handler is obtained and used by:: +Generic Callback Handler to collect callbacks functions through :func:`register` and execute them - cfg = get_handler('global') +with :func:`run_callbacks` -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*) +A lock is used to sequence registering of new functions and running callbacks. -The function :func:`get_handler` is the main entry point to obtain a new or existing handler. +**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 - + -## ConfigHandler Objects +#### register ```python -class ConfigHandler() +def register(func: Optional[Callable[..., None]]) ``` -The configuration handler class +Register a new function to be executed when the callback event happens -Don't instantiate directly. Always use :func:`get_handler`! +**Arguments**: -**Threads:** +- `func`: The function to register. If set to :data:`None`, this register request is silently ignored. -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') +#### run\_callbacks -For a single function call, this is done implicitly. In this case, there is no need -to explicitly acquire the lock. +```python +def run_callbacks(*args, **kwargs) +``` -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. +Run all registered callbacks. -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. +*ALL* exceptions from callback functions will be caught and logged only. +Exceptions are not raised upwards! - + -#### loaded\_from +#### has\_callbacks ```python @property -def loaded_from() -> Optional[str] +def has_callbacks() ``` -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 +# 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. -#### setdefault +The python package name may be different from the name the package is registered under in plugs. This allows to load different +python packages for a specific feature based on a configuration file. Note: Python package are still loaded as regular +python packages and can be accessed by normal means -```python -def setdefault(key, *, value) -``` +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. -Enforce keyword on default to avoid accidental misuse when actually setndefault is wanted +**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: -#### getn + @plugs.register(name='better_name') + def func2(param): + pass -```python -def getn(*keys, default=None) -``` +**Example:** Register a function during run-time under it's own name: -Get the value at arbitrary hierarchy depth. Return ``default`` if key not present + def func3(param): + pass + plugs.register(func3) -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. +**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): -#### setn + @plugs.register(auto_tag=True) + class MyClass1: + pass -```python -def setn(*keys, value, hierarchy_type=None) -> None -``` +**Example:** Register a class instance, from which only report is a callable method through the plugs interface: -Set the ``key: value`` pair at arbitrary hierarchy depth + class MyClass2: + @plugs.tag + def report(self): + pass + myinst2 = MyClass2() + plugin.register(myinst2, name='myinst2') -All non-existing hierarchy levels are created. +Naming convention: -**Arguments**: +* 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 -- `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 +## PluginPackageClass Objects ```python -def setndefault(*keys, value, hierarchy_type=None) +class PluginPackageClass() ``` -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**: +A local data class for holding all information about a loaded plugin package -The actual value or or the default value if key does not exit - + -#### config\_dict +#### register ```python -def config_dict(data) +@overload +def register(plugin: Callable) -> Callable ``` -Initialize configuration data from dict-like data structure - -**Arguments**: +1-level decorator around a function -- `data`: configuration data - + -#### is\_modified +#### register ```python -def is_modified() -> bool +@overload +def register(plugin: Type) -> Any ``` -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 +Signature: 1-level decorator around a class - + -#### clear\_modified +#### register ```python -def clear_modified() -> None +@overload +def register(*, name: str, package: Optional[str] = None) -> Callable ``` -Sets the current state as new baseline, clearing the is_modified state +Signature: 2-level decorator around a function - + -#### save +#### register ```python -def save(only_if_changed: bool = False) -> None +@overload +def register(*, auto_tag: bool = False, package: Optional[str] = None) -> Type ``` -Save config back to the file it was loaded from +Signature: 2-level decorator around a class -If you want to save to a different file, use :func:`write_yaml`. + - - -#### load +#### register ```python -def load(filename: str) -> None +@overload +def register(plugin: Callable[..., Any] = None, + *, + name: Optional[str] = None, + package: Optional[str] = None, + replace: bool = False) -> Callable ``` -Load YAML config file into memory +Signature: Run-time registration of function / class instance / bound method - + -#### get\_handler +#### register ```python -def get_handler(name: str) -> ConfigHandler +def register(plugin: Optional[Callable] = None, + *, + name: Optional[str] = None, + package: Optional[str] = None, + replace: bool = False, + auto_tag: bool = False) -> Callable ``` -Get a configuration data handler with the specified name, creating it +A generic decorator / run-time function to register plugin module callables -if it doesn't yet exit. If created, it is always created empty. +The functions comes in five distinct signatures for 5 use cases: -This is the main entry point for obtaining an configuration handler +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 -**Arguments**: +For more documentation see the functions +* :func:`_register_obj` +* :func:`_register_class` -- `name`: Name of the config handler +See the examples in Module :mod:`plugs` how to use this decorator / function -**Returns**: +**Arguments**: -`ConfigHandler`: The configuration data handler for *name* +- `plugin`: +- `name`: +- `package`: +- `replace`: +- `auto_tag`: - + -#### load\_yaml +#### tag ```python -def load_yaml(cfg: ConfigHandler, filename: str) -> None +def tag(func: Callable) -> Callable ``` -Load a yaml file into a ConfigHandler +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**: -- `cfg`: ConfigHandler instance -- `filename`: filename to yaml file +- `func`: function to decorate **Returns**: -None +the function - + -#### write\_yaml +#### initialize ```python -def write_yaml(cfg: ConfigHandler, - filename: str, - only_if_changed: bool = False, - *args, - **kwargs) -> None +def initialize(func: Callable) -> Callable ``` -Writes ConfigHandler data to yaml file / sys.stdout +Decorator for functions that shall be called by the plugs package directly after the module is loaded **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(...) +- `func`: Function to decorate **Returns**: -None +The function itself - + -# jukebox.playlistgenerator +#### finalize -Playlists are build from directory content in the following way: +```python +def finalize(func: Callable) -> Callable +``` -a directory is parsed and files are added to the playlist in the following way +Decorator for functions that shall be called by the plugs package directly after ALL modules are loaded -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 +**Arguments**: -An directory may contain a mixed set of files and multiple ``*.txt`` files, e.g. +- `func`: Function to decorate - 01-livestream.txt - 02-livestream.txt - music.mp3 - podcast.txt +**Returns**: -All files are treated as music files and are added to the playlist, except those: +The function itself - * 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). +#### atexit -In ``*.txt`` and ``*.m3u`` files, all lines starting with ``#`` are ignored. +```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. -#### TYPE\_DECODE +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 -Types if file entires in parsed directory +**Arguments**: +- `func`: Function to decorate - +**Returns**: -## PlaylistCollector Objects +The function itself -```python -class PlaylistCollector() -``` + -Build a playlist from directory(s) +#### load -This class is intended to be used with an absolute path to the music library:: +```python +def load(package: str, + load_as: Optional[str] = None, + prefix: Optional[str] = None) +``` - plc = PlaylistCollector('/home/chris/music') - plc.parse('Traumfaenger') - print(f"res = {plc}") +Loads a python package as plugin package -But it can also be used with relative paths from current working directory:: +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` - plc = PlaylistCollector('.') - plc.parse('../../../../music/Traumfaenger') - print(f"res = {plc}") +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. -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`. +**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 - + -#### \_\_init\_\_ +#### load\_all\_named ```python -def __init__(music_library_base_path='/') +def load_all_named(packages_named: Mapping[str, str], + prefix: Optional[str] = None, + ignore_errors=False) ``` -Initialize the playlist generator with music_library_base_path +Load all packages in packages_named with mapped names **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 +- `packages_named`: Dict[load_as, package] - + -#### set\_exclusion\_endings +#### load\_all\_unnamed ```python -@classmethod -def set_exclusion_endings(cls, endings: List[str]) +def load_all_unnamed(packages_unnamed: Iterable[str], + prefix: Optional[str] = None, + ignore_errors=False) ``` -Set the class-wide file ending exclusion list - -See :attr:`PlaylistCollector._exclude_endings` +Load all packages in packages_unnamed with default names - + -#### get\_directory\_content +#### load\_all\_finalize ```python -def get_directory_content(path='.') +def load_all_finalize(ignore_errors=False) ``` -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`` +Calls all functions registered with @finalize from all loaded modules in the order they were loaded -**Returns**: +This must be executed after the last plugin package is loaded -[ { type: 'directory', name: 'Simone', path: '/some/path/to/Simone' }, {...} ] -where type is one of :attr:`TYPE_DECODE` - + -#### parse +#### close\_down ```python -def parse(path='.', recursive=False) +def close_down(**kwargs) -> Any ``` -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 - - +Calls all functions registered with @atexit from all loaded modules in reverse order of module load order -# jukebox.NvManager +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 -# jukebox.publishing - + -#### get\_publisher +#### call ```python -def get_publisher() +def call(package: str, + plugin: str, + method: Optional[str] = None, + *, + args=(), + kwargs=None, + as_thread: bool = False, + thread_name: Optional[str] = None) -> Any ``` -Return the publisher instance for this thread +Call a function/method from the loaded plugins -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 +If a plugin is a function or a callable instance of a class, this is equivalent to - class MyClass: - def __init__(self): - pass +``package.plugin(*args, **kwargs)`` - def say_hello(name): - publishing.get_publisher().send('hello', f'Hi {name}, howya?') +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`` -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. +``package.plugin.method(*args, **kwargs)`` -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. +Calls are serialized by a thread lock. The thread lock is shared with call_ignore_errors. -**Remember**: Don’t share ZeroMQ sockets between threads. +> [!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 -# jukebox.publishing.subscriber +**Returns**: - +The return value from the called function, or, if started as thread the thread object -# jukebox.publishing.server + -## Publishing Server +#### call\_ignore\_errors -The common publishing server for the entire Jukebox using ZeroMQ +```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 +``` -### Structure +Call a function/method from the loaded plugins ignoring all raised Exceptions. - +-----------------------+ - | 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 +Errors get logged. -#### Connection (1): Internal connection +See :func:`call` for parameter documentation. -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 +#### exists - 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 +```python +def exists(package: str, + plugin: Optional[str] = None, + method: Optional[str] = None) -> bool +``` - 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 +Check if an object is registered within the plugs package -#### 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 +#### get - Part 1: Topic (in topic tree format) - E.g. player.status.elapsed +```python +def get(package: str, + plugin: Optional[str] = None, + method: Optional[str] = None) -> Any +``` - 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) +Get a plugs-package registered object -### Why? Why? +The return object depends on the number of parameters -Check out the [ZeroMQ Documentation](https://zguide.zeromq.org/docs/chapter5) -for why you need a proxy in a good design. +* 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* -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, ..." +#### loaded\_as -**But we have:** +```python +def loaded_as(module_name: str) -> str +``` -* 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! +Return the plugin name a python module is loaded as -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** +#### delete -* 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?) +```python +def delete(package: str, plugin: Optional[str] = None, ignore_errors=False) +``` -**Start-up sequence:** +Delete a plugin object from the registered plugs callables -* Publisher plugin is first plugin to be loaded -* Due to Publisher - PublisherServer structure no further sequencing required +> [!NOTE] +> This does not 'unload' the python module. It merely makes it un-callable via plugs! -### 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) +#### dump\_plugins -Plugins publishing state information should publish initial state at @plugin.finalize +```python +def dump_plugins(stream) +``` -> [!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()`. +Write a human readable summary of all plugin callables to stream -**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. +#### summarize -**Further ZeroMQ References:** +```python +def summarize() +``` -* [Working with Messages](https://zguide.zeromq.org/docs/chapter2/`Working`-with-Messages) -* [Multiple Threads](https://zguide.zeromq.org/docs/chapter2/`Multithreading`-with-ZeroMQ) +Create a reference summary of all plugin callables in dictionary format - + -## PublishServer Objects +#### generate\_help\_rst ```python -class PublishServer(threading.Thread) +def generate_help_rst(stream) ``` -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) +Write a reference of all plugin callables in Restructured Text format - + -#### run +#### get\_all\_loaded\_packages ```python -def run() +def get_all_loaded_packages() -> Dict[str, str] ``` -Thread's activity +Report a short summary of all loaded packages +**Returns**: - +Dictionary of the form `{loaded_as: loaded_from, ...}` -#### handle\_message + + +#### get\_all\_failed\_packages ```python -def handle_message(msg) +def get_all_failed_packages() -> Dict[str, str] ``` -Handle incoming messages +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**: -#### handle\_subscription +Dictionary of the form `{loaded_as: loaded_from, ...}` -```python -def handle_subscription(msg) -``` + -Handle new subscribers +# 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:: -## Publisher Objects + cfg = get_handler('global') + load_yaml(cfg, 'filename.yaml') -```python -class Publisher() -``` +In all other modules (in potentially different threads) the same handler is obtained and used by:: -The publisher that provides the functional interface to the application + cfg = get_handler('global') -> [!NOTE] -> * An instance must not be shared across threads! -> * One instance per thread is enough +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. - -#### \_\_init\_\_ + + +## ConfigHandler Objects ```python -def __init__(check_thread_owner=True) +class ConfigHandler() ``` -**Arguments**: +The configuration handler class -- `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! +Don't instantiate directly. Always use :func:`get_handler`! - +**Threads:** -#### send +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:: -```python -def send(topic: str, payload) -``` + with cfg: + cfg['key'] = 66 + cfg.setndefault('hello', value='world') -Send out a message for topic +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. -#### revoke + + + +#### loaded\_from ```python -def revoke(topic: str) +@property +def loaded_from() -> Optional[str] ``` -Revoke a single topic element (not a topic tree!) +Property to store filename from which the config was loaded - + -#### resend +#### get ```python -def resend(topic: Optional[str] = None) +def get(key, *, default=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! +Enforce keyword on default to avoid accidental misuse when actually getn is wanted - + -#### close\_server +#### setdefault ```python -def close_server() +def setdefault(key, *, value) ``` -Instructs the PublishServer to close itself down - - - +Enforce keyword on default to avoid accidental misuse when actually setndefault is wanted -# jukebox.daemon - + -#### log\_active\_threads +#### getn ```python -@atexit.register -def log_active_threads() +def getn(*keys, default=None) ``` -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) +Get the value at arbitrary hierarchy depth. Return ``default`` if key not present -This function is registered before all the plugins and their dependencies are loaded +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. - + -## JukeBox Objects +#### setn ```python -class JukeBox() +def setn(*keys, value, hierarchy_type=None) -> None ``` - - -#### signal\_handler - -```python -def signal_handler(esignal, frame) -``` +Set the ``key: value`` pair at arbitrary hierarchy depth -Signal handler for orderly shutdown +All non-existing hierarchy levels are created. -On first Ctrl-C (or SIGTERM) orderly shutdown procedure is embarked upon. It gets allocated a time-out! -On third Ctrl-C (or SIGTERM), this is interrupted and there will be a hard exit! +**Arguments**: +- `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 - + -# jukebox.plugs +#### setndefault -A plugin package with some special functionality +```python +def setndefault(*keys, value, hierarchy_type=None) +``` -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. +Set the ``key: value`` pair at arbitrary hierarchy depth unless the key already exists -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 +All non-existing hierarchy levels are created. -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. +**Arguments**: -**Example:** Decorate a function for auto-registering under it's own name: +- `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 - import jukebox.plugs as plugs - @plugs.register - def func1(param): - pass +**Returns**: -**Example:** Decorate a function for auto-registering under a new name: +The actual value or or the default value if key does not exit - @plugs.register(name='better_name') - def func2(param): - pass + -**Example:** Register a function during run-time under it's own name: +#### config\_dict - def func3(param): - pass - plugs.register(func3) +```python +def config_dict(data) +``` -**Example:** Register a function during run-time under a new name: +Initialize configuration data from dict-like data structure - def func4(param): - pass - plugs.register(func4, name='other_name', package='other_package') +**Arguments**: -**Example:** Decorate a class for auto registering during initialization, -including all methods (see _register_class for more info): +- `data`: configuration data - @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: +#### is\_modified - class MyClass2: - @plugs.tag - def report(self): - pass - myinst2 = MyClass2() - plugin.register(myinst2, name='myinst2') +```python +def is_modified() -> bool +``` -Naming convention: +Check if the data has changed since the last load/store -* 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 +> [!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 - + -## PluginPackageClass Objects +#### clear\_modified ```python -class PluginPackageClass() +def clear_modified() -> None ``` -A local data class for holding all information about a loaded plugin package +Sets the current state as new baseline, clearing the is_modified state - + -#### register +#### save ```python -@overload -def register(plugin: Callable) -> Callable +def save(only_if_changed: bool = False) -> None ``` -1-level decorator around a function +Save config back to the file it was loaded from +If you want to save to a different file, use :func:`write_yaml`. - -#### register + + +#### load ```python -@overload -def register(plugin: Type) -> Any +def load(filename: str) -> None ``` -Signature: 1-level decorator around a class +Load YAML config file into memory - + -#### register +#### get\_handler ```python -@overload -def register(*, name: str, package: Optional[str] = None) -> Callable +def get_handler(name: str) -> ConfigHandler ``` -Signature: 2-level decorator around a function +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 -#### register +**Arguments**: -```python -@overload -def register(*, auto_tag: bool = False, package: Optional[str] = None) -> Type -``` +- `name`: Name of the config handler -Signature: 2-level decorator around a class +**Returns**: +`ConfigHandler`: The configuration data handler for *name* - + -#### register +#### load\_yaml ```python -@overload -def register(plugin: Callable[..., Any] = None, - *, - name: Optional[str] = None, - package: Optional[str] = None, - replace: bool = False) -> Callable +def load_yaml(cfg: ConfigHandler, filename: str) -> None ``` -Signature: Run-time registration of function / class instance / bound method +Load a yaml file into a ConfigHandler +**Arguments**: - - -#### register +- `cfg`: ConfigHandler instance +- `filename`: filename to yaml file -```python -def register(plugin: Optional[Callable] = None, - *, - name: Optional[str] = None, - package: Optional[str] = None, - replace: bool = False, - auto_tag: bool = False) -> Callable -``` +**Returns**: -A generic decorator / run-time function to register plugin module callables +None -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 +#### write\_yaml -For more documentation see the functions -* :func:`_register_obj` -* :func:`_register_class` +```python +def write_yaml(cfg: ConfigHandler, + filename: str, + only_if_changed: bool = False, + *args, + **kwargs) -> None +``` -See the examples in Module :mod:`plugs` how to use this decorator / function +Writes ConfigHandler data to yaml file / sys.stdout **Arguments**: -- `plugin`: -- `name`: -- `package`: -- `replace`: -- `auto_tag`: +- `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**: -#### tag +None -```python -def tag(func: Callable) -> Callable -``` + -Method decorator for tagging a method as callable through the plugs interface +# jukebox.speaking\_text -Note that the instantiated class must still be registered as plugin object -(either with the class decorator or dynamically) +Text to Speech. Plugin to speak any given text via speaker -**Arguments**: -- `func`: function to decorate + -**Returns**: +# jukebox.utils -the function +Common utility functions - -#### initialize + + +#### decode\_rpc\_call ```python -def initialize(func: Callable) -> Callable +def decode_rpc_call(cfg_rpc_call: Dict) -> Optional[Dict] ``` -Decorator for functions that shall be called by the plugs package directly after the module is loaded +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**: -- `func`: Function to decorate +- `cfg_rpc_call`: RPC command as configuration entry **Returns**: -The function itself +A fully populated deep copy of cfg_rpc_call - + -#### finalize +#### decode\_rpc\_command ```python -def finalize(func: Callable) -> Callable +def decode_rpc_command(cfg_rpc_cmd: Dict, + logger: logging.Logger = log) -> Optional[Dict] ``` -Decorator for functions that shall be called by the plugs package directly after ALL modules are loaded +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**: -- `func`: Function to decorate +- `cfg_rpc_cmd`: RPC command as configuration entry +- `logger`: The logger to use **Returns**: -The function itself +A decoded, fully populated deep copy of cfg_rpc_cmd - + -#### atexit +#### decode\_and\_call\_rpc\_command ```python -def atexit(func: Callable[[int], Any]) -> Callable[[int], Any] +def decode_and_call_rpc_command(rpc_cmd: Dict, logger: logging.Logger = log) ``` -Decorator for functions that shall be called by the plugs package directly after at exit of program. +Convenience function combining decode_rpc_command and plugs.call_ignore_errors -> [!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 + + +#### 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**: -- `func`: Function to decorate +- `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**: -The function itself +Callable function w/o parameters which directly runs the RPC command +using plugs.call_ignore_errors - + -#### load +#### rpc\_call\_to\_str ```python -def load(package: str, - load_as: Optional[str] = None, - prefix: Optional[str] = None) +def rpc_call_to_str(cfg_rpc_call: Dict, with_args=True) -> str ``` -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. +Return a readable string of an RPC call config **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 +- `cfg_rpc_call`: RPC call configuration entry +- `with_args`: Return string shall include the arguments of the function - + -#### load\_all\_named +#### get\_config\_action ```python -def load_all_named(packages_named: Mapping[str, str], - prefix: Optional[str] = None, - ignore_errors=False) +def get_config_action(cfg, section, option, default, valid_actions_dict, + logger) ``` -Load all packages in packages_named with mapped names +Looks up the given {section}.{option} config option and returns -**Arguments**: +the associated entry from valid_actions_dict, if valid. Falls back to the given +default otherwise. -- `packages_named`: Dict[load_as, package] - + -#### load\_all\_unnamed +#### generate\_cmd\_alias\_rst ```python -def load_all_unnamed(packages_unnamed: Iterable[str], - prefix: Optional[str] = None, - ignore_errors=False) +def generate_cmd_alias_rst(stream) ``` -Load all packages in packages_unnamed with default names +Write a reference of all rpc command aliases in Restructured Text format - + -#### load\_all\_finalize +#### generate\_cmd\_alias\_reference ```python -def load_all_finalize(ignore_errors=False) +def generate_cmd_alias_reference(stream) ``` -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 +Write a reference of all rpc command aliases in text format - + -#### close\_down +#### get\_git\_state ```python -def close_down(**kwargs) -> Any +def get_git_state() ``` -Calls all functions registered with @atexit from all loaded modules in reverse order of module load order +Return git state information for the current branch -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 + +# jukebox.version - + -#### call +#### version ```python -def call(package: str, - plugin: str, - method: Optional[str] = None, - *, - args=(), - kwargs=None, - as_thread: bool = False, - thread_name: Optional[str] = None) -> Any +def version() ``` -Call a function/method from the loaded plugins +Return the Jukebox version as a string -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`` +#### version\_info -``package.plugin.method(*args, **kwargs)`` +```python +def version_info() +``` -Calls are serialized by a thread lock. The thread lock is shared with call_ignore_errors. +Return the Jukebox version as a tuple of three numbers -> [!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` +If this is a development version, an identifier string will be appended after the third integer. -**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**: +# jukebox.playlistgenerator -The return value from the called function, or, if started as thread the thread object +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 -#### call\_ignore\_errors +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 -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 +class PlaylistCollector() ``` -Call a function/method from the loaded plugins ignoring all raised Exceptions. +Build a playlist from directory(s) -Errors get logged. +This class is intended to be used with an absolute path to the music library:: -See :func:`call` for parameter documentation. + 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}") -#### exists +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 exists(package: str, - plugin: Optional[str] = None, - method: Optional[str] = None) -> bool +def __init__(music_library_base_path='/') ``` -Check if an object is registered within the plugs package +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 -#### get + + +#### set\_exclusion\_endings ```python -def get(package: str, - plugin: Optional[str] = None, - method: Optional[str] = None) -> Any +@classmethod +def set_exclusion_endings(cls, endings: List[str]) ``` -Get a plugs-package registered object +Set the class-wide file ending exclusion list -The return object depends on the number of parameters +See :attr:`PlaylistCollector._exclude_endings` -* 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* + - +#### get\_directory\_content -#### loaded\_as +```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 loaded_as(module_name: str) -> str +def parse(path='.', recursive=False) ``` -Return the plugin name a python module is loaded as +Parse the folder ``path`` and create a playlist from its content +**Arguments**: - +- `path`: Path to folder **relative** to ``music_library_base_path`` +- `recursive`: Parse folder recursivley, or stay in top-level folder -#### delete + + +# jukebox.multitimer + +Multitimer Module + + + + +## MultiTimer Objects ```python -def delete(package: str, plugin: Optional[str] = None, ignore_errors=False) +class MultiTimer(threading.Thread) ``` -Delete a plugin object from the registered plugs callables +Call a function after a specified number of seconds, repeat that iteration times -> [!NOTE] -> This does not 'unload' the python module. It merely makes it un-callable via plugs! +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 -#### dump\_plugins +Note: Inspired by threading.Timer and generally using the same API + + + + +#### cancel ```python -def dump_plugins(stream) +def cancel() ``` -Write a human readable summary of all plugin callables to stream +Stop the timer if it hasn't finished all iterations yet. - + -#### summarize +## GenericTimerClass Objects ```python -def summarize() +class GenericTimerClass() ``` -Create a reference summary of all plugin callables in dictionary format +Interface for plugin / RPC accessibility for a single event timer - + -#### generate\_help\_rst +#### \_\_init\_\_ ```python -def generate_help_rst(stream) +def __init__(name, wait_seconds: float, function, args=None, kwargs=None) ``` -Write a reference of all plugin callables in Restructured Text format +**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 - + -#### get\_all\_loaded\_packages +#### start ```python -def get_all_loaded_packages() -> Dict[str, str] +@plugin.tag +def start(wait_seconds=None) ``` -Report a short summary of all loaded packages +Start the timer (with default or new parameters) -**Returns**: -Dictionary of the form `{loaded_as: loaded_from, ...}` + - +#### cancel -#### get\_all\_failed\_packages +```python +@plugin.tag +def cancel() +``` + +Cancel the timer + + + + +#### toggle ```python -def get_all_failed_packages() -> Dict[str, str] +@plugin.tag +def toggle() ``` -Report those packages that did not load error free +Toggle the activation of the timer -> [!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, ...}` +#### trigger - +```python +@plugin.tag +def trigger() +``` -# jukebox.speaking\_text +Trigger the next target execution before the time is up -Text to Speech. Plugin to speak any given text via speaker + - +#### is\_alive -# jukebox.multitimer +```python +@plugin.tag +def is_alive() +``` -Multitimer Module +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.NvManager + + + +# jukebox.publishing.subscriber + + + +# jukebox.publishing + + + +#### get\_publisher + +```python +def get_publisher() +``` + +Return the publisher instance for this thread + +Per thread, only one publisher instance is required to connect to the inproc socket. +A new instance is created if it does not already exist. + +If there is a remote-chance that your function publishing something may be called form +different threads, always make a fresh call to ``get_publisher()`` to get the correct instance for the current thread. + +Example:: + + import jukebox.publishing as publishing + + class MyClass: + def __init__(self): + pass + + def say_hello(name): + publishing.get_publisher().send('hello', f'Hi {name}, howya?') + +To stress what **NOT** to do: don't get a publisher instance in the constructor and save it to ``self._pub``. +If you do and ``say_hello`` gets called from different threads, the publisher of the thread which instantiated the class +will be used. + +If you need your very own private Publisher Instance, you'll need to instantiate it yourself. +But: the use cases are very rare for that. I cannot think of one at the moment. + +**Remember**: Don’t share ZeroMQ sockets between threads. + + + + +# jukebox.publishing.server + +## Publishing Server + +The common publishing server for the entire Jukebox using ZeroMQ + +### Structure + + +-----------------------+ + | functional interface | Publisher + | | - functional interface for single Thread + | PUB | - sends data to publisher (and thus across threads) + +-----------------------+ + | (1) + v + +-----------------------+ + | SUB (bind) | PublishServer + | | - Last Value (LV) Cache + | XPUB (bind) | - Subscriber notification and LV resend + +-----------------------+ - independent thread + | (2) + v + +#### Connection (1): Internal connection + +Internal connection only - do not use (no, not even inside this App for you own plugins - always bind to the PublishServer) + + Protocol: Multi-part message + + Part 1: Topic (in topic tree format) + E.g. player.status.elapsed + + Part 2: Payload or Message in json serialization + If empty (i.e. ``b''``), it means delete the topic sub-tree from cache. And instruct subscribers to do the same + + Part 3: Command + Usually empty, i.e. ``b''``. If not empty the message is treated as command for the PublishServer + and the message is not forwarded to the outside. This third part of the message is never forwarded + +#### Connection (2): External connection + +Upon connection of a new subscriber, the entire current state is resend from cache to ALL subscribers! +Subscribers must subscribe to topics. Topics are treated as topic trees! Subscribing to a root tree will +also get you all the branch topics. To get everything, subscribe to ``b''`` + + Protocol: Multi-part message + + Part 1: Topic (in topic tree format) + E.g. player.status.elapsed + + Part 2: Payload or Message in json serialization + If empty (i.e. b''), it means the subscriber must delete this key locally (not valid anymore) + +### Why? Why? + +Check out the [ZeroMQ Documentation](https://zguide.zeromq.org/docs/chapter5) +for why you need a proxy in a good design. + +For use case, we made a few simplifications + +### Design Rationales + +* "If you need [millions of messages per second](https://zguide.zeromq.org/docs/chapter5/`Pros`-and-Cons-of-Pub-Sub) + sent to thousands of points, + you'll appreciate pub-sub a lot more than if you need a few messages a second sent to a handful of recipients." +* "lower-volume network with a few dozen subscribers and a limited number of topics, we can use TCP and then + the [XSUB and XPUB](https://zguide.zeromq.org/docs/chapter5/`Last`-Value-Caching)" +* "Let's imagine [our feed has an average of 100,000 100-byte messages a + second](https://zguide.zeromq.org/docs/chapter5/`High`-Speed-Subscribers-Black-Box-Pattern) [...]. + While 100K messages a second is easy for a ZeroMQ application, ..." + +**But we have:** + +* few dozen subscribers --> Check! +* limited number of topics --> Check! +* max ~10 messages per second --> Check! +* small common state information --> Check! +* only the server updates the state --> Check! + +This means, we can use less complex patters than used for these high-speed, high code count, high data rate networks :-) + +* XPUB / XSUB to detect new subscriber +* Cache the entire state in the publisher +* Re-send the entire state on-demand (and then even to every subscriber) +* Using the same channel: sends state to every subscriber + +**Reliability considerations** + +* Late joining client (or drop-off and re-join): get full state update +* Server crash etc: No special handling necessary, we are simple + and don't need recovery in this case. Server will publish initial state + after re-start +* Subscriber too slow: Subscribers problem (TODO: Do we need to do anything about it?) + +**Start-up sequence:** + +* Publisher plugin is first plugin to be loaded +* Due to Publisher - PublisherServer structure no further sequencing required + +### Plugin interactions and usage + +RPC can trigger through function call in components/publishing plugin that + +* entire state is re-published (from the cache) +* a specific topic tree is re-published (from the cache) + +Plugins publishing state information should publish initial state at @plugin.finalize + +> [!IMPORTANT] +> Do not direclty instantiate the Publisher in your plugin module. Only one Publisher is +> required per thread. But the publisher instance **must** be thread-local! +> Always go through :func:`publishing.get_publisher()`. + +**Sockets** + +Three sockets are opened: + +1. TCP (on a configurable port) +2. Websocket (on a configurable port) +3. Inproc: On ``inproc://PublisherToProxy`` all topics are published app-internally. This can be used for plugin modules + that want to know about the current state on event based updates. + +**Further ZeroMQ References:** + +* [Working with Messages](https://zguide.zeromq.org/docs/chapter2/`Working`-with-Messages) +* [Multiple Threads](https://zguide.zeromq.org/docs/chapter2/`Multithreading`-with-ZeroMQ) + + + + +## PublishServer Objects + +```python +class PublishServer(threading.Thread) +``` + +The publish proxy server that collects and caches messages from all internal publishers and + +forwards them to the outside world + +Handles new subscriptions by sending out the entire cached state to **all** subscribers + +The code is structures using a [Reactor Pattern](https://zguide.zeromq.org/docs/chapter5/`Using`-a-Reactor) + + + -## MultiTimer Objects +#### run ```python -class MultiTimer(threading.Thread) +def run() ``` -Call a function after a specified number of seconds, repeat that iteration times +Thread's activity -May be cancelled during any of the wait times. -Function is called with keyword parameter 'iteration' (which decreases down to 0 for the last iteration) -If iterations is negative, an endlessly repeating timer is created (which needs to be cancelled with cancel()) + -Initiates start and publishing by calling self.publish_callback +#### handle\_message -Note: Inspired by threading.Timer and generally using the same API +```python +def handle_message(msg) +``` +Handle incoming messages - -#### cancel + + +#### handle\_subscription ```python -def cancel() +def handle_subscription(msg) ``` -Stop the timer if it hasn't finished all iterations yet. +Handle new subscribers - + -## GenericTimerClass Objects +## Publisher Objects ```python -class GenericTimerClass() +class Publisher() ``` -Interface for plugin / RPC accessibility for a single event timer +The publisher that provides the functional interface to the application + +> [!NOTE] +> * An instance must not be shared across threads! +> * One instance per thread is enough - + #### \_\_init\_\_ ```python -def __init__(name, wait_seconds: float, function, args=None, kwargs=None) +def __init__(check_thread_owner=True) ``` **Arguments**: -- `wait_seconds`: The time in seconds to wait before calling function -- `function`: The function to call with args and kwargs. -- `args`: Parameters for function call -- `kwargs`: Parameters for function call +- `check_thread_owner`: Check if send() is always called from the correct thread. This is debug feature +and is intended to expose the situation before it leads to real trouble. Leave it on! - + -#### start +#### send ```python -@plugin.tag -def start(wait_seconds=None) +def send(topic: str, payload) ``` -Start the timer (with default or new parameters) +Send out a message for topic - + -#### cancel +#### revoke ```python -@plugin.tag -def cancel() +def revoke(topic: str) ``` -Cancel the timer +Revoke a single topic element (not a topic tree!) - + -#### toggle +#### resend ```python -@plugin.tag -def toggle() +def resend(topic: Optional[str] = None) ``` -Toggle the activation of the timer +Instructs the PublishServer to resend current status to all subscribers +Not necessary to call after incremental updates or new subscriptions - that will happen automatically! - -#### trigger + + +#### close\_server ```python -@plugin.tag -def trigger() +def close_server() ``` -Trigger the next target execution before the time is up +Instructs the PublishServer to close itself down - + -#### is\_alive +# jukebox.daemon + + + +#### log\_active\_threads ```python -@plugin.tag -def is_alive() +@atexit.register +def log_active_threads() ``` -Check if timer is active +This functions is registered with atexit very early, meaning it will be run very late. It is the best guess to +evaluate which Threads are still running (and probably shouldn't be) - +This function is registered before all the plugins and their dependencies are loaded -#### get\_timeout + + + +## JukeBox Objects ```python -@plugin.tag -def get_timeout() +class JukeBox() ``` -Get the configured time-out + -**Returns**: +#### signal\_handler -The total wait time. (Not the remaining wait time!) +```python +def signal_handler(esignal, frame) +``` - +Signal handler for orderly shutdown -#### set\_timeout +On first Ctrl-C (or SIGTERM) orderly shutdown procedure is embarked upon. It gets allocated a time-out! +On third Ctrl-C (or SIGTERM), this is interrupted and there will be a hard exit! -```python -@plugin.tag -def set_timeout(wait_seconds: float) -``` -Set a new time-out in seconds. Re-starts the timer if already running! + +# jukebox.rpc.client - + -#### publish +# jukebox.rpc -```python -@plugin.tag -def publish() -``` + -Publish the current state and config +# jukebox.rpc.server +## Remote Procedure Call Server (RPC) - +Bind to tcp and/or websocket port and translates incoming requests to procedure calls. +Avaiable procedures to call are all functions registered with the plugin package. -#### get\_state +The protocol is loosely based on [jsonrpc](https://www.jsonrpc.org/specification) -```python -@plugin.tag -def get_state() -``` +But with different elements directly relating to the plugin concept and Python function argument options -Get the current state and config as dictionary + { + 'package' : str # The plugin package loaded from python module + 'plugin' : str # The plugin object to be accessed from the package + # (i.e. function or class instance) + 'method' : str # (optional) The method of the class instance + 'args' : [ ] # (optional) Positional arguments as list + 'kwargs' : { } # (optional) Keyword arguments as dictionary + 'as_thread': bool # (optional) start call in separate thread + 'id' : Any # (optional) Round-trip id for response (may not be None) + 'tsp' : Any # (optional) measure and return total processing time for + # the call request (may not be None) + } +**Response** - +A response will ALWAYS be send, independent of presence of 'id'. This is in difference to the +jsonrpc specification. But this is a ZeroMQB REQ/REP pattern requirement! -## GenericEndlessTimerClass Objects +If 'id' is omitted, the response will be 'None'! Unless an error occurred, then the error is returned. +The absence of 'id' indicates that the requester is not interested in the response. +If present, 'id' and 'tsp' may not be None. If they are None, there are treated as if non-existing. -```python -class GenericEndlessTimerClass(GenericTimerClass) -``` +**Sockets** -Interface for plugin / RPC accessibility for an event timer call function endlessly every m seconds +Three sockets are opened + +1. TCP (on a configurable port) +2. Websocket (on a configurable port) +3. Inproc: On ``inproc://JukeBoxRpcServer`` connection from the internal app are accepted. This is indented be + call arbitrary RPC functions from plugins that provide an interface to the outside world (e.g. GPIO). By also going though + the RPC instead of calling function directly we increase thread-safety and provide easy configurability (e.g. which + button triggers what action) - + -## GenericMultiTimerClass Objects +## RpcServer Objects ```python -class GenericMultiTimerClass(GenericTimerClass) +class RpcServer() ``` -Interface for plugin / RPC accessibility for an event timer that performs an action n times every m seconds +The RPC Server Class - + #### \_\_init\_\_ ```python -def __init__(name, - iterations: int, - wait_seconds_per_iteration: float, - callee, - args=None, - kwargs=None) +def __init__(context=None) ``` -**Arguments**: +Initialize the connections and bind to the ports -- `iterations`: Number of times callee is called -- `wait_seconds_per_iteration`: Wait in seconds before each iteration -- `callee`: A builder class that gets instantiated once as callee(*args, iterations=iterations, **kwargs). -Then with every time out iteration __call__(*args, iteration=iteration, **kwargs) is called. -'iteration' is the current iteration count in decreasing order! -- `args`: -- `kwargs`: - + -#### start +#### run ```python -@plugin.tag -def start(iterations=None, wait_seconds_per_iteration=None) +def run() ``` -Start the timer (with default or new parameters) - +The main endless loop waiting for requests and forwarding the - +call request to the plugin module -# jukebox.utils -Common utility functions + +# misc - + -#### decode\_rpc\_call +#### recursive\_chmod ```python -def decode_rpc_call(cfg_rpc_call: Dict) -> Optional[Dict] +def recursive_chmod(path, mode_files, mode_dirs) ``` -Makes sure that the core rpc call parameters have valid default values in cfg_rpc_call. +Recursively change folder and file permissions -> [!IMPORTANT] -> Leaves all other parameters in cfg_action untouched or later downstream processing! +mode_files/mode dirs can be given in octal notation e.g. 0o777 +flags from the stats module. -**Arguments**: +Reference: https://docs.python.org/3/library/os.html#os.chmod -- `cfg_rpc_call`: RPC command as configuration entry -**Returns**: + + +#### flatten + +```python +def flatten(iterable) +``` + +Flatten all levels of hierarchy in nested iterables -A fully populated deep copy of cfg_rpc_call - + -#### decode\_rpc\_command +#### getattr\_hierarchical ```python -def decode_rpc_command(cfg_rpc_cmd: Dict, - logger: logging.Logger = log) -> Optional[Dict] +def getattr_hierarchical(obj: Any, name: str) -> Any ``` -Decode an RPC Command from a config entry. - -This means +Like the builtin getattr, but descends though the hierarchy levels -* Decode RPC command alias (if present) -* Ensure all RPC call parameters have valid default values -If the command alias cannot be decoded correctly, the command is mapped to misc.empty_rpc_call -which emits a misuse warning when called -If an explicitly specified this is not done. However, it is ensured that the returned -dictionary contains all mandatory parameters for an RPC call. RPC call functions have error handling -for non-existing RPC commands and we get a clearer error message. + -**Arguments**: +# misc.simplecolors -- `cfg_rpc_cmd`: RPC command as configuration entry -- `logger`: The logger to use +Zero 3rd-party dependency module to add colors to unix terminal output -**Returns**: +Yes, there are modules out there to do the same and they have more features. +However, this is low-complexity and has zero dependencies -A decoded, fully populated deep copy of cfg_rpc_cmd - + -#### decode\_and\_call\_rpc\_command +## Colors Objects ```python -def decode_and_call_rpc_command(rpc_cmd: Dict, logger: logging.Logger = log) +class Colors() ``` -Convenience function combining decode_rpc_command and plugs.call_ignore_errors +Container class for all the colors as constants - + -#### bind\_rpc\_command +#### resolve ```python -def bind_rpc_command(cfg_rpc_cmd: Dict, - dereference=False, - logger: logging.Logger = log) +def resolve(color_name: str) ``` -Decode an RPC command configuration entry and bind it to a function +Resolve a color name into the respective color constant **Arguments**: -- `dereference`: Dereference even the call to plugs.call(...) - ``. If false, the returned function is ``plugs.call(package, plugin, method, *args, **kwargs)`` with - all checks applied at bind time - ``. If true, the returned function is ``package.plugin.method(*args, **kwargs)`` with - all checks applied at bind time. - -Setting deference to True, circumvents the dynamic nature of the plugins: the function to call - must exist at bind time and cannot change. If False, the function to call must only exist at call time. - This can be important during the initialization where package ordering and initialization means that not all - classes have been instantiated yet. With dereference=True also the plugs thread lock for serialization of calls - is circumvented. Use with care! +- `color_name`: Name of the color **Returns**: -Callable function w/o parameters which directly runs the RPC command -using plugs.call_ignore_errors +color constant - + -#### rpc\_call\_to\_str +#### print ```python -def rpc_call_to_str(cfg_rpc_call: Dict, with_args=True) -> str +def print(color: Colors, + *values, + sep=' ', + end='\n', + file=sys.stdout, + flush=False) ``` -Return a readable string of an RPC call config +Drop-in replacement for print with color choice and auto color reset for convenience -**Arguments**: +Use just as a regular print function, but with first parameter as color -- `cfg_rpc_call`: RPC call configuration entry -- `with_args`: Return string shall include the arguments of the function - + -#### generate\_cmd\_alias\_rst +# misc.inputminus -```python -def generate_cmd_alias_rst(stream) -``` +Zero 3rd-party dependency module for user prompting -Write a reference of all rpc command aliases in Restructured Text format +Yes, there are modules out there to do the same and they have more features. +However, this is low-complexity and has zero dependencies - + -#### generate\_cmd\_alias\_reference +#### input\_int ```python -def generate_cmd_alias_reference(stream) +def input_int(prompt, + blank=None, + min=None, + max=None, + prompt_color=None, + prompt_hint=False) -> int ``` -Write a reference of all rpc command aliases in text format +Request an integer input from user +**Arguments**: - +- `prompt`: The prompt to display +- `blank`: Value to return when user just hits enter. Leave at None, if blank is invalid +- `min`: Minimum valid integer value (None disables this check) +- `max`: Maximum valid integer value (None disables this check) +- `prompt_color`: Color of the prompt. Color will be reset at end of prompt +- `prompt_hint`: Append a 'hint' with [min...max, default=xx] to end of prompt -#### get\_git\_state +**Returns**: + +integer value read from user input + + + +#### input\_yesno ```python -def get_git_state() +def input_yesno(prompt, + blank=None, + prompt_color=None, + prompt_hint=False) -> bool ``` -Return git state information for the current branch +Request a yes / no choice from user +Accepts multiple input for true/false and is case insensitive - +**Arguments**: -# jukebox.rpc +- `prompt`: The prompt to display +- `blank`: Value to return when user just hits enter. Leave at None, if blank is invalid +- `prompt_color`: Color of the prompt. Color will be reset at end of prompt +- `prompt_hint`: Append a 'hint' with [y/n] to end of prompt. Default choice will be capitalized - +**Returns**: -# jukebox.rpc.client +boolean value read from user input - + -# jukebox.rpc.server +# misc.loggingext -## Remote Procedure Call Server (RPC) +## Logger -Bind to tcp and/or websocket port and translates incoming requests to procedure calls. -Avaiable procedures to call are all functions registered with the plugin package. +We use a hierarchical Logger structure based on pythons logging module. It can be finely configured with a yaml file. -The protocol is loosely based on [jsonrpc](https://www.jsonrpc.org/specification) +The top-level logger is called 'jb' (to make it short). In any module you may simple create a child-logger at any hierarchy +level below 'jb'. It will inherit settings from it's parent logger unless otherwise configured in the yaml file. +Hierarchy separator is the '.'. If the logger already exits, getLogger will return a reference to the same, else it will be +created on the spot. -But with different elements directly relating to the plugin concept and Python function argument options +Example: How to get logger and log away at your heart's content: - { - 'package' : str # The plugin package loaded from python module - 'plugin' : str # The plugin object to be accessed from the package - # (i.e. function or class instance) - 'method' : str # (optional) The method of the class instance - 'args' : [ ] # (optional) Positional arguments as list - 'kwargs' : { } # (optional) Keyword arguments as dictionary - 'as_thread': bool # (optional) start call in separate thread - 'id' : Any # (optional) Round-trip id for response (may not be None) - 'tsp' : Any # (optional) measure and return total processing time for - # the call request (may not be None) - } + >>> import logging + >>> logger = logging.getLogger('jb.awesome_module') + >>> logger.info('Started general awesomeness aura') -**Response** +Example: YAML snippet, setting WARNING as default level everywhere and DEBUG for jb.awesome_module: -A response will ALWAYS be send, independent of presence of 'id'. This is in difference to the -jsonrpc specification. But this is a ZeroMQB REQ/REP pattern requirement! + loggers: + jb: + level: WARNING + handlers: [console, debug_file_handler, error_file_handler] + propagate: no + jb.awesome_module: + level: DEBUG -If 'id' is omitted, the response will be 'None'! Unless an error occurred, then the error is returned. -The absence of 'id' indicates that the requester is not interested in the response. -If present, 'id' and 'tsp' may not be None. If they are None, there are treated as if non-existing. -**Sockets** +> [!NOTE] +> The name (and hierarchy path) of the logger can be arbitrary and must not necessarily match the module name (still makes +> sense). +> There can be multiple loggers per module, e.g. for special classes, to further control the amount of log output -Three sockets are opened -1. TCP (on a configurable port) -2. Websocket (on a configurable port) -3. Inproc: On ``inproc://JukeBoxRpcServer`` connection from the internal app are accepted. This is indented be - call arbitrary RPC functions from plugins that provide an interface to the outside world (e.g. GPIO). By also going though - the RPC instead of calling function directly we increase thread-safety and provide easy configurability (e.g. which - button triggers what action) + +## ColorFilter Objects - +```python +class ColorFilter(logging.Filter) +``` -## RpcServer Objects +This filter adds colors to the logger + +It adds all colors from simplecolors by using the color name as new keyword, +i.e. use %(colorname)c or {colorname} in the formatter string + +It also adds the keyword {levelnameColored} which is an auto-colored drop-in replacement +for the levelname depending on severity. + +Don't forget to {reset} the color settings at the end of the string. + + + + +#### \_\_init\_\_ ```python -class RpcServer() +def __init__(enable=True, color_levelname=True) ``` -The RPC Server Class +**Arguments**: +- `enable`: Enable the coloring +- `color_levelname`: Enable auto-coloring when using the levelname keyword - + -#### \_\_init\_\_ +## PubStream Objects ```python -def __init__(context=None) +class PubStream() ``` -Initialize the connections and bind to the ports +Stream handler wrapper around the publisher for logging.StreamHandler +Allows logging to send all log information (based on logging configuration) +to the Publisher. - +> [!CAUTION] +> This can lead to recursions! +> Recursions come up when +> * Publish.send / PublishServer.send also emits logs, which cause a another send, which emits a log, +> which causes a send, ..... +> * Publisher initialization emits logs, which need a Publisher instance to send logs -#### run +> [!IMPORTANT] +> To avoid endless recursions: The creation of a Publisher MUST NOT generate any log messages! Nor any of the +> functions in the send-function stack! + + + + +## PubStreamHandler Objects ```python -def run() +class PubStreamHandler(logging.StreamHandler) ``` -The main endless loop waiting for requests and forwarding the +Wrapper for logging.StreamHandler with stream = PubStream -call request to the plugin module +This serves one purpose: In logger.yaml custom handlers +can be configured (which are automatically instantiated). +Using this Handler, we can output to PubStream whithout +support code to instantiate PubStream keeping this file generic diff --git a/documentation/developers/documentation.md b/documentation/developers/documentation.md new file mode 100644 index 000000000..51cd8cda2 --- /dev/null +++ b/documentation/developers/documentation.md @@ -0,0 +1,39 @@ +# Documentation with Markdown + +We use markdown for documentation. Please add/update documentation in `documentation`. + +## Linting + +To ensure a consistent documentation we lint markdown files. + +We use [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) for linting. + +`.markdownlint-cli2.yaml` configures linting consistently for the Github Action, the pre-commit hook, manual linting and the [markdownlint extension](https://github.com/DavidAnson/vscode-markdownlint) for Visual Studio Code. + +You can start a manual check, if you call `run_markdownlint.sh`. + +If markdown files are changed and the pre-commit hook is enabled, `run_markdownlint.sh` is triggered on commits. + +After creating a PR or pushing to the repo a Github Action triggers the linter, if markdown files are changed (see `.github/workflows/markdown_v3.yml`). + +### Disabling Rules + +> [!NOTE] +> Please use disabling rules with caution and always try to fix the violation first. + +A few rules are globally disabled in `.markdownlint-cli2.yaml` (see section `config`). + +If you want to disable a rule for a specific section of a markdown file you can use + +```markdown + +section where MD010 should be ignored + +``` + +### References + +* +* Rules: + * + * diff --git a/documentation/developers/rfid/README.md b/documentation/developers/rfid/README.md index 0b2df4db3..f0db4fd6a 100644 --- a/documentation/developers/rfid/README.md +++ b/documentation/developers/rfid/README.md @@ -11,4 +11,3 @@ * [Generic Readers without HID (NFCpy)](generic_nfcpy.md) * [Mock Reader](mock_reader.md) * [Template Reader](template_reader.md) - diff --git a/documentation/developers/rfid/generic_nfcpy.md b/documentation/developers/rfid/generic_nfcpy.md index 76de98d88..27c6ececb 100644 --- a/documentation/developers/rfid/generic_nfcpy.md +++ b/documentation/developers/rfid/generic_nfcpy.md @@ -11,13 +11,14 @@ driver, and thus cannot be used with the [genericusb](genericusb.md) module. Als > The setup will do this automatically, so make sure the device is connected > before running the [RFID reader configuration tool](../coreapps.md#RFID-Reader). -# Configuration +## Configuration -The installation script will scan for compatible devices and will assist in configuration. +The installation script will scan for compatible devices and will assist in configuration. By setting `rfid > readers > generic_nfcpy > config > device_path` in `shared/settings/rfid.yaml` you can override the device location. By specifying an explicit device location it is possible to use multiple readers compatible with NFCpy. Example configuration for a usb-device with vendor ID 072f and product ID 2200: + ```yaml rfid: readers: @@ -33,4 +34,4 @@ rfid: alias: pause ``` -For possible values see the `path` parameter in this [nfcpy documentation](https://nfcpy.readthedocs.io/en/latest/modules/clf.html#nfc.clf.ContactlessFrontend.open) \ No newline at end of file +For possible values see the `path` parameter in this [nfcpy documentation](https://nfcpy.readthedocs.io/en/latest/modules/clf.html#nfc.clf.ContactlessFrontend.open) diff --git a/documentation/developers/rfid/mfrc522_spi.md b/documentation/developers/rfid/mfrc522_spi.md index 8a04f729e..f14812ed4 100644 --- a/documentation/developers/rfid/mfrc522_spi.md +++ b/documentation/developers/rfid/mfrc522_spi.md @@ -38,7 +38,7 @@ Mandatory IRQ pin. This can be any GPIO pin. Reset pin for hardware reset. This is an optional pin. If not used, -- hardware reset will only be performed by power-on-reset. This has been tested on works fine. +- hardware reset will only be performed by power-on-reset. This has been tested and works fine. - you **must** tie the reset pin of the MFRC522 board **high**! ### mode_legacy *(default=false)* @@ -57,7 +57,8 @@ If true all card read-outs will be logged, even when card is permanently on read The following pin-out is for the default SPI Bus 0 on Raspberry Pins. -*MFRC522 default wiring (spi_bus=0, spi_ce=0)* +### MFRC522 default wiring (spi_bus=0, spi_ce=0) + |Pin Board Name |Function |RPI GPIO |RPI Pin | |----------------|----------|----------|---------| |SDA |CE |GPIO8 |24 | @@ -76,4 +77,6 @@ MISO. MFRC522 boards can be picked up from many places for little money. -Good quality ones can be found e.g. here +### Cards/Tags + +Cards or tags must support 13.56 MHz. Currently, only cards/tags of the type "NXP Mifare Classic 1k(S50)", "NXP Mifare Classic 4k(S70)" and "NXP Mifare Ultralight (C)" can be used. Type "NXP Mifare NTAG2xx" or others will not work! diff --git a/documentation/developers/rfid/mock_reader.md b/documentation/developers/rfid/mock_reader.md index 4d5a3ea36..d6cac8cd0 100644 --- a/documentation/developers/rfid/mock_reader.md +++ b/documentation/developers/rfid/mock_reader.md @@ -6,7 +6,7 @@ machine - probably in a Python virtual environment. **place-capable**: yes -If you [mock the GPIO pins](../../../src/jukebox/components/gpio/gpioz/README.rst#use-mock-pins), this GUI will show the GPIO devices. +If you [mock the GPIO pins](../../builders/gpio.md#use-mock-pins), this GUI will show the GPIO devices. ![image](mock_reader.png) diff --git a/documentation/developers/rfid/pn532_i2c.md b/documentation/developers/rfid/pn532_i2c.md index d60cb2e54..33b7e3f51 100644 --- a/documentation/developers/rfid/pn532_i2c.md +++ b/documentation/developers/rfid/pn532_i2c.md @@ -29,7 +29,7 @@ You can usually pick up a board at ## Board Connections -*Default wiring* +### Default wiring | PN532 | RPI GPIO | RPI Pin | |-------|--------------|---------| @@ -45,9 +45,9 @@ PI's own voltage regulator. ## Jumpers -*Jumper settings for I2C protocol* +### Jumper settings for I2C protocol -Jumper | Position --------|---------- -SEL0 | ON -SEL1 | OFF +| Jumper | Position | +|--------|----------| +|SEL0 | ON | +|SEL1 | OFF | diff --git a/documentation/developers/rfid/template_reader.md b/documentation/developers/rfid/template_reader.md index 5b8458691..e02f56c69 100644 --- a/documentation/developers/rfid/template_reader.md +++ b/documentation/developers/rfid/template_reader.md @@ -1,9 +1,8 @@ # Template Reader -*Template for creating and integrating a new RFID Reader* - > [!NOTE] +> Template for creating and integrating a new RFID Reader. > For developers only This template provides the skeleton API for a new Reader. If you follow diff --git a/documentation/developers/status.md b/documentation/developers/status.md index 0a40f8125..1ce8706a2 100644 --- a/documentation/developers/status.md +++ b/documentation/developers/status.md @@ -166,7 +166,8 @@ Topics marked _in progress_ are already in the process of implementation by comm - [x] Publish mechanism of timer status - [x] Change multitimer function call interface such that endless timer etc. won't pass the `iteration` kwarg - [ ] Make timer settings persistent -- [ ] Idle timer +- [x] Idle timer (basic implementation covering player, SSH, config and audio content changes) +- [ ] Idle timer: Do we need further extensions? - This needs clearer specification: Idle is when no music is playing and no user interaction is taking place - i.e., needs information from RPC AND from player status. Let's do this when we see a little clearer about Spotify @@ -196,7 +197,7 @@ Topics marked _in progress_ are already in the process of implementation by comm ### Others -- [ ] MQTT +- [x] MQTT - [ ] Record and Playback using a Mic - [ ] Dot Matrix Displays diff --git a/documentation/developers/webapp.md b/documentation/developers/webapp.md index 2e8504337..30b791816 100644 --- a/documentation/developers/webapp.md +++ b/documentation/developers/webapp.md @@ -19,7 +19,7 @@ sudo apt-get -y update && sudo apt-get -y install nodejs The Web App is a React application based on [Create React App](https://create-react-app.dev/). To start a development server, run the following command: -``` +```bash cd ~/RPi-Jukebox-RFID/src/webapp npm install # Just the first time or when dependencies change npm start @@ -37,7 +37,7 @@ cd ~/RPi-Jukebox-RFID/src/webapp; \ After a successfull build you might need to restart the web server. -``` +```bash sudo systemctl restart nginx.service ``` @@ -71,6 +71,7 @@ Use the [provided script](#build-the-web-app) to rebuild the Web App. It sets th If you need to run the commands manually, make sure to have enough memory available (min. 512 MB). The following commands might help. Set the swapsize to 512 MB (and deactivate swapfactor). Adapt accordingly if you have a SD Card with small capacity. + ```bash sudo dphys-swapfile swapoff sudo sed -i "s|.*CONF_SWAPSIZE=.*|CONF_SWAPSIZE=512|g" /etc/dphys-swapfile @@ -80,6 +81,7 @@ sudo dphys-swapfile swapon ``` Set Node's maximum amount of memory. Memory must be available. + ``` bash export NODE_OPTIONS=--max-old-space-size=512 npm run build @@ -105,7 +107,6 @@ Node tried to allocate more memory than available on the system. See [JavaScript heap out of memory](#javascript-heap-out-of-memory) - ### Client network socket disconnected ``` {.bash emphasize-lines="8,9"} @@ -122,12 +123,12 @@ npm ERR! network 'proxy' config is set properly. See: 'npm help config' #### Reason -The network connection is too slow or has issues. -This tends to happen on `armv6l` devices where building takes significantly more time due to limited resources. +The network connection is too slow or has issues. +This tends to happen on `armv6l` devices where building takes significantly more time due to limited resources. #### Solution -Try to use an ethernet connection. A reboot and/or running the script multiple times might also help ([Build produces EOF errors](#build-produces-eof-errors) might occur). +Try to use an ethernet connection. A reboot and/or running the script multiple times might also help ([Build produces EOF errors](#build-produces-eof-errors) might occur). If the error still persists, try to raise the timeout for npm package resolution. @@ -144,6 +145,7 @@ 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/includes/02_helpers.sh b/installation/includes/02_helpers.sh index dfad65187..e7f3b060c 100644 --- a/installation/includes/02_helpers.sh +++ b/installation/includes/02_helpers.sh @@ -75,35 +75,41 @@ get_architecture() { echo $arch } -is_raspbian() { - if [[ $( . /etc/os-release; printf '%s\n' "$ID"; ) == *"raspbian"* ]]; then +is_debian_based() { + local os_release_id=$( . /etc/os-release; printf '%s\n' "$ID"; ) + if [[ "$os_release_id" == *"raspbian"* ]] || [[ "$os_release_id" == *"debian"* ]]; then echo true else echo false fi } -get_debian_version_number() { - source /etc/os-release - echo "$VERSION_ID" +_get_debian_version_number() { + if [ "$(is_debian_based)" = true ]; then + local debian_version_number=$( . /etc/os-release; printf '%s\n' "$VERSION_ID"; ) + echo "$debian_version_number" + else + echo "-1" + fi +} + +is_debian_version_at_least() { + local expected_version=$1 + local debian_version_number=$(_get_debian_version_number) + + if [ "$debian_version_number" -ge "$expected_version" ]; then + echo true + else + echo false + fi } _get_boot_file_path() { local filename="$1" - if [ "$(is_raspbian)" = true ]; then - local debian_version_number=$(get_debian_version_number) - - # Bullseye and lower - if [ "$debian_version_number" -le 11 ]; then - echo "/boot/${filename}" - # Bookworm and higher - elif [ "$debian_version_number" -ge 12 ]; then - echo "/boot/firmware/${filename}" - else - echo "unknown" - fi + if [ "$(is_debian_version_at_least 12)" = true ]; then + echo "/boot/firmware/${filename}" else - echo "unknown" + echo "/boot/${filename}" fi } @@ -124,7 +130,7 @@ validate_url() { download_from_url() { local url=$1 local output_filename=$2 - wget --quiet ${url} -O ${output_filename} || exit_on_error "Download failed" + wget ${url} -O ${output_filename} || exit_on_error "Download failed" return $? } @@ -393,7 +399,7 @@ verify_optional_service_enablement() { # 1 : textfile to read get_args_from_file() { local package_file="$1" - sed 's/.*#egg=//g' ${package_file} | sed -E 's/(#|=|>|<).*//g' | xargs echo + sed 's/.*#egg=//g' ${package_file} | sed -E 's/(#|=|>|<|;).*//g' | xargs echo } # Check if all passed packages are installed. Fail on first missing. @@ -433,3 +439,22 @@ verify_pip_modules() { done log " CHECK" } + +# Check if all passed modules are not installed. Fail on first found. +verify_pip_modules_not() { + local modules="$@" + log " Verify modules are not installed: '${modules}'" + + if [[ -z "${modules}" ]]; then + exit_on_error "ERROR: at least one parameter value is missing!" + fi + + local pip_list_installed=$(pip list 2>/dev/null) + for module in ${modules} + do + if [[ $(echo "${pip_list_installed}" | grep -i "^${module} ") ]]; then + exit_on_error "ERROR: ${module} is installed" + fi + done + log " CHECK" +} diff --git a/installation/install-jukebox.sh b/installation/install-jukebox.sh index 1ab96f3a1..c3610662b 100755 --- a/installation/install-jukebox.sh +++ b/installation/install-jukebox.sh @@ -86,23 +86,6 @@ Check install log for details:" exit 1 } -# Check if current distro is a 32-bit version -# Support for 64-bit Distros has not been checked (or precisely: is known not to work) -# All Raspberry Pi OS versions report as machine "armv6l" or "armv7l", if 32-bit (even the ARMv8 cores!) -_check_os_type() { - local os_type=$(uname -m) - - print_lc "\nChecking OS type '$os_type'" - - if [[ $os_type == "armv7l" || $os_type == "armv6l" ]]; then - print_lc " ... OK!\n" - else - print_lc "ERROR: Only 32-bit operating systems are supported. Please use a 32-bit version of Raspberry Pi OS!" - print_lc "For Pi 4 models or newer running a 64-bit kernels, also see this: https://github.com/MiczFlor/RPi-Jukebox-RFID/issues/2041" - exit 1 - fi -} - _check_existing_installation() { if [[ -e "${INSTALLATION_PATH}" ]]; then print_lc " @@ -154,7 +137,6 @@ _load_sources() { _setup_logging ### CHECK PREREQUISITE -_check_os_type _check_existing_installation ### RUN INSTALLATION diff --git a/installation/routines/setup_jukebox_core.sh b/installation/routines/setup_jukebox_core.sh index f5ef2eec2..f2c0e55ad 100644 --- a/installation/routines/setup_jukebox_core.sh +++ b/installation/routines/setup_jukebox_core.sh @@ -21,16 +21,40 @@ _jukebox_core_install_os_dependencies() { --allow-change-held-packages } +_jukebox_core_build_and_install_lgpio() { + local tmp_path="${HOME_PATH}/tmp" + local lg_filename="lg" + local lg_zip_filename="${lg_filename}.zip" + + sudo apt-get -y install swig unzip python3-dev python3-setuptools + mkdir -p "${tmp_path}" && cd "${tmp_path}" || exit_on_error + download_from_url "http://abyz.me.uk/lg/${lg_zip_filename}" "${lg_zip_filename}" + unzip ${lg_zip_filename} || exit_on_error + cd "${lg_filename}" || exit_on_error + make && sudo make install + cd "${INSTALLATION_PATH}" && sudo rm -rf "${tmp_path}" +} + _jukebox_core_install_python_requirements() { print_lc " Install Python requirements" - cd "${INSTALLATION_PATH}" || exit_on_error + cd "${INSTALLATION_PATH}" || exit_on_error python3 -m venv $VIRTUAL_ENV source "$VIRTUAL_ENV/bin/activate" pip install --upgrade pip - pip install --no-cache-dir -r "${INSTALLATION_PATH}/requirements.txt" + # Remove excluded libs, if installed - see https://github.com/MiczFlor/RPi-Jukebox-RFID/pull/2470 + pip uninstall -y -r "${INSTALLATION_PATH}"/requirements-excluded.txt + + # prepare lgpio build for bullseye as the binaries are broken + local pip_install_options="" + if [ "$(is_debian_version_at_least 12)" = false ]; then + _jukebox_core_build_and_install_lgpio + pip_install_options="--no-binary=lgpio" + fi + + pip install --no-cache-dir -r "${INSTALLATION_PATH}/requirements.txt" ${pip_install_options} } _jukebox_core_configure_pulseaudio() { @@ -86,7 +110,7 @@ _jukebox_core_build_and_install_pyzmq() { fi ZMQ_PREFIX="${JUKEBOX_ZMQ_PREFIX}" ZMQ_DRAFT_API=1 \ - pip install -v --no-binary pyzmq 'pyzmq<26' + pip install -v 'pyzmq<26' --no-binary pyzmq else print_lc " Skipping. pyzmq already installed" fi @@ -120,6 +144,9 @@ _jukebox_core_check() { local pip_modules=$(get_args_from_file "${INSTALLATION_PATH}/requirements.txt") verify_pip_modules pyzmq $pip_modules + local pip_modules_excluded=$(get_args_from_file "${INSTALLATION_PATH}/requirements-excluded.txt") + verify_pip_modules_not $pip_modules_excluded + log " Verify ZMQ version '${JUKEBOX_ZMQ_VERSION}'" local zmq_version=$(python -c 'import zmq; print(f"{zmq.zmq_version()}")') if [[ "${zmq_version}" != "${JUKEBOX_ZMQ_VERSION}" ]]; then diff --git a/packages-core.txt b/packages-core.txt index b2f6779a2..aa5d944c4 100644 --- a/packages-core.txt +++ b/packages-core.txt @@ -15,3 +15,4 @@ python3 python3-venv python3-dev rsync +wget diff --git a/requirements-excluded.txt b/requirements-excluded.txt new file mode 100644 index 000000000..66dcf834f --- /dev/null +++ b/requirements-excluded.txt @@ -0,0 +1,9 @@ +# Remove excluded libs, if installed +# Libraries which must be excluded. These must be removed with +# You need to uninstall these with `python -m pip uninstall -y -r requirements.txt` + +# RPi.GPIO uses direct /sys/class/gpio/ access, which is removed since kernel 6.6 (bookworm) +# see also +# - https://github.com/MiczFlor/RPi-Jukebox-RFID/pull/2470 +# - https://github.com/MiczFlor/RPi-Jukebox-RFID/discussions/2295 +RPi.GPIO diff --git a/requirements.txt b/requirements.txt index c172a7636..139c563db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,18 +10,19 @@ wheel # Jukebox Core # For USB inputs (reader, buttons) and bluetooth buttons evdev +mutagen pyalsaaudio pulsectl python-mpd2 ruamel.yaml -python-slugify # For playlistgenerator requests # For the publisher event reactor loop: tornado # RPi's GPIO packages: -RPi.GPIO +# use shim to keep current RPi.GPIO behavior also under Bookworm - see https://github.com/MiczFlor/RPi-Jukebox-RFID/issues/2313 +rpi-lgpio gpiozero # PyZMQ is a special case: @@ -37,3 +38,6 @@ mock # API docs generation pydoc-markdown + +# MQTT +paho-mqtt diff --git a/resources/default-settings/evdev.example.yaml b/resources/default-settings/evdev.example.yaml new file mode 100644 index 000000000..5fbcd57e8 --- /dev/null +++ b/resources/default-settings/evdev.example.yaml @@ -0,0 +1,59 @@ +devices: # A list of evdev devices each containing one or multiple input/output devices + joystick: # A nickname for a device + device_name: DragonRise Inc. Generic USB # Device name + exact: false # If true, the device name must match exactly, otherwise it is sufficient to contain the name + input_devices: + TogglePlayback: + type: Button + kwargs: + key_code: 299 + actions: + on_press: + alias: toggle + NextSong: + type: Button + kwargs: + key_code: 298 + actions: + on_press: + alias: next_song + PrevSong: + type: Button + kwargs: + key_code: 297 + actions: + on_press: + alias: prev_song + VolumeUp: + type: Button + kwargs: + key_code: 296 + actions: + on_press: + alias: change_volume + args: 5 + VolumeDown: + type: Button + kwargs: + key_code: 295 + actions: + on_press: + alias: change_volume + args: -5 + VolumeReset: + type: Button + kwargs: + key_code: 291 + actions: + on_press: + package: volume + plugin: ctrl + method: set_volume + args: [18] + Shutdown: + type: Button + kwargs: + key_code: 292 + actions: + on_press: + alias: shutdown \ No newline at end of file diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index c087cc024..d3326ef55 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -87,6 +87,12 @@ playermpd: update_on_startup: true check_user_rights: true mpd_conf: ~/.config/mpd/mpd.conf + # Must be one of: 'none', 'stop', 'rewind': + end_of_playlist_next_action: none + # Must be one of: 'none', 'prev', 'rewind': + stopped_prev_action: prev + # Must be one of: 'none', 'next', 'rewind': + stopped_next_action: next rpc: tcp_port: 5555 websocket_port: 5556 @@ -102,6 +108,10 @@ gpioz: enable: false config_file: ../../shared/settings/gpio.yaml timers: + idle_shutdown: + # If you want the box to shutdown on inactivity automatically, configure timeout_sec with a number of seconds (at least 60). + # Inactivity is defined as: no music playing, no active SSH sessions, no changes in configs or audio content. + timeout_sec: 0 # These timers are always disabled after start # The following values only give the default values. # These can be changed when enabling the respective timer on a case-by-case basis w/o saving @@ -147,3 +157,7 @@ sync_rfidcards: config_file: ../../shared/settings/sync_rfidcards.yaml webapp: coverart_cache_path: ../../src/webapp/build/cover-cache + # Load cover arts in Webapp. Change to false in case you have performance issue + # when handling a lot of music + # Defaults to true + show_covers: true diff --git a/run_markdownlint.sh b/run_markdownlint.sh new file mode 100755 index 000000000..0f890590e --- /dev/null +++ b/run_markdownlint.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# Runner script to ensure +# - independent from working directory + +# Change working directory to project root +SOURCE=${BASH_SOURCE[0]} +SCRIPT_DIR="$(dirname "$SOURCE")" +PROJECT_ROOT="$SCRIPT_DIR" +cd "$PROJECT_ROOT" || { echo "Could not change directory"; exit 1; } + +# Run markdownlint-cli2 +./src/webapp/node_modules/.bin/markdownlint-cli2 --config .markdownlint-cli2.yaml "#node_modules" || { echo "ERROR: markdownlint-cli2 not found"; exit 1; } diff --git a/src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/__init__.py b/src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/__init__.py index af193a37f..551759c14 100644 --- a/src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/__init__.py +++ b/src/jukebox/components/battery_monitor/batt_mon_i2c_ads1015/__init__.py @@ -37,36 +37,7 @@ class battmon_ads1015(BatteryMonitorBase.BattmonBase): """Battery Monitor based on a ADS1015 - > [!CAUTION] - > Lithium and other batteries are dangerous and must be treated with care. - > Rechargeable Lithium Ion batteries are potentially hazardous and can - > present a serious **FIRE HAZARD** if damaged, defective or improperly used. - > Do not use this circuit to a lithium ion battery without expertise and - > training in handling and use of batteries of this type. - > Use appropriate test equipment and safety protocols during development. - > There is no warranty, this may not work as expected or at all! - - This script is intended to read out the Voltage of a single Cell LiIon Battery using a CY-ADS1015 Board: - - 3.3V - + - | - .----o----. - ___ | | SDA - .--------|___|---o----o---------o AIN0 o------ - | 2MΩ | | | | SCL - | .-. | | ADS1015 o------ - --- | | --- | | - Battery - 1.5MΩ| | ---100nF '----o----' - 2.9V-4.2V| '-' | | - | | | | - === === === === - - Attention: - * the circuit is constantly draining the battery! (leak current up to: 2.1µA) - * the time between sample needs to be a minimum 1sec with this high impedance voltage divider - don't use the continuous conversion method! - + See [Battery Monitor documentation](../../builders/components/power/batterymonitor.md) """ def __init__(self, cfg): diff --git a/src/jukebox/components/battery_monitor/batt_mon_i2c_ina219/__init__.py b/src/jukebox/components/battery_monitor/batt_mon_i2c_ina219/__init__.py new file mode 100644 index 000000000..c9b51eece --- /dev/null +++ b/src/jukebox/components/battery_monitor/batt_mon_i2c_ina219/__init__.py @@ -0,0 +1,56 @@ +# RPi-Jukebox-RFID Version 3 +# Copyright (c) See file LICENSE in project root folder + +import logging +import jukebox.plugs as plugs +import jukebox.cfghandler +from ina219 import INA219 +from ina219 import DeviceRangeError +from components.battery_monitor import BatteryMonitorBase + +logger = logging.getLogger('jb.battmon.ina219') + +batt_mon = None + + +class battmon_ina219(BatteryMonitorBase.BattmonBase): + '''Battery Monitor based on a INA219 + + See [Battery Monitor documentation](../../builders/components/power/batterymonitor.md) + ''' + + def __init__(self, cfg): + super().__init__(cfg, logger) + + def init_batt_mon_hw(self, num: float, denom: float) -> None: + try: + self.adc = INA219(float(num) / 1000, busnum=1) + self.adc.configure(self.adc.RANGE_16V, self.adc.GAIN_AUTO, self.adc.ADC_32SAMP, self.adc.ADC_32SAMP) + except DeviceRangeError as e: + logger.error(f"Device range error: {e}") + raise + except Exception as e: + logger.error(f"Failed to initialize INA219: {e}") + raise + + def get_batt_voltage(self) -> int: + try: + batt_voltage_mV = self.adc.supply_voltage() * 1000.0 + return int(batt_voltage_mV) + except Exception as e: + logger.error(f"Failed to get supply voltage from INA219: {e}") + raise + + +@plugs.finalize +def finalize(): + global batt_mon + cfg = jukebox.cfghandler.get_handler('jukebox') + batt_mon = battmon_ina219(cfg) + plugs.register(batt_mon, name='batt_mon') + + +@plugs.atexit +def atexit(**ignored_kwargs): + global batt_mon + batt_mon.status_thread.cancel() diff --git a/src/jukebox/components/controls/bluetooth_audio_buttons/README.rst b/src/jukebox/components/controls/bluetooth_audio_buttons/README.rst deleted file mode 100644 index 87a7eab2b..000000000 --- a/src/jukebox/components/controls/bluetooth_audio_buttons/README.rst +++ /dev/null @@ -1,55 +0,0 @@ -When a bluetooth sound device (headphone, speakers) connects -attempt to automatically listen to it's buttons (play, next, ...) - -The bluetooth input device name is matched automatically from the -bluetooth sound card device name. During boot up, it is uncertain if the bluetooth device connects first, -or the Jukebox service is ready first. Therefore, -after service initialization, already connected bluetooth sound devices are scanned and an attempt is made -to find their input buttons. - -.. note:: If the automatic matching fails, there currently is no - manual configuration option. Open an issue ticket if you have problems with the automatic matching. - -Button key codes are standardized and by default the buttons -play, pause, next song, previous song are recognized. Volume up/down is handled independently -from this module by PulseAudio and the bluetooth audio transmission protocol. - -The module needs to be enabled in the main configuration file with: - -.. code-block:: yaml - - bluetooth_audio_buttons: - enable: true - - -Custom key bindings ---------------------- - -You may change or extend the actions assigned to a button in the configuration. If the configuration contains -a block 'mapping', the default button-action mapping is *completely* replaced with the new mapping. The definitions for -each key looks like ``key-code: {rpc_command_definition}``. -The RPC command follows the regular RPC command rules as defined in :ref:`userguide/rpc_commands:RPC Commands`. - -.. code-block:: yaml - - bluetooth_audio_buttons: - enable: true - mapping: - # Play & pause both map to toggle which is also the usual behaviour of headsets - 200: - alias: toggle - 201: - alias: toggle - # Re-map next song button, to set defined output volume (for some fun) - 163: - package: volume - plugin: ctrl - method: set_volume - args: [18] - # Re-map prev song button to shutdown - 165: - alias: shutdown - - -Key codes can be found in the log files. Press the various buttons on your headset, while watching the -logs with e.g. ``tail -f shared/logs/app.log``. Look for entries like ``No callback registered for button ...``. diff --git a/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py b/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py index 4d17f398e..f03a447cd 100644 --- a/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py +++ b/src/jukebox/components/controls/bluetooth_audio_buttons/__init__.py @@ -49,8 +49,8 @@ def activate(device_name: str, exact: bool = True, open_initial_delay: float = 0 # Do a bit of housekeeping: Delete dead threads listener = list(filter(lambda x: x.is_alive(), listener)) # Check that there is no running thread for this device already - for ll in listener: - if ll.device_request == device_name and ll.is_alive(): + for thread in listener: + if thread.device_request == device_name and thread.is_alive(): logger.debug(f"Button listener thread already active for '{device_name}'") return diff --git a/src/jukebox/components/controls/common/evdev_listener.py b/src/jukebox/components/controls/common/evdev_listener.py index a4279afda..a1f336758 100644 --- a/src/jukebox/components/controls/common/evdev_listener.py +++ b/src/jukebox/components/controls/common/evdev_listener.py @@ -160,7 +160,7 @@ def run(self): self._connect() except FileNotFoundError as e: # This error occurs, if opening the bluetooth input device fails - logger.debug(f"{e} (attempt: {idx+1}/{self.open_retry_cnt}). Retrying in {self.open_retry_delay}") + logger.debug(f"{e} (attempt: {idx + 1}/{self.open_retry_cnt}). Retrying in {self.open_retry_delay}") time.sleep(self.open_retry_delay) except AttributeError as e: # This error occurs, when the device can be found, but does not have the mandatory keys diff --git a/src/jukebox/components/controls/event_devices/__init__.py b/src/jukebox/components/controls/event_devices/__init__.py new file mode 100644 index 000000000..407a73f14 --- /dev/null +++ b/src/jukebox/components/controls/event_devices/__init__.py @@ -0,0 +1,221 @@ +""" +Plugin to register event_devices (ie USB controllers, keyboards etc) in a + generic manner. + +This effectively does: + + * parse the configured event devices from the evdev.yaml + * setup listen threads + +""" +from __future__ import annotations + +import logging +from typing import Callable +from typing import Tuple + +import jukebox.cfghandler +import jukebox.plugs as plugin +import jukebox.utils +from components.controls.common.evdev_listener import EvDevKeyListener + +logger = logging.getLogger("jb.EventDevice") +cfg_main = jukebox.cfghandler.get_handler("jukebox") +cfg_evdev = jukebox.cfghandler.get_handler("eventdevices") + +# Keep track of all active key event listener threads +# Removal of dead listener threads is done in lazy fashion: +# only on a new connect are dead threads removed +listener: list[EvDevKeyListener] = [] +# Running count of all created listener threads for unique thread naming IDs +listener_cnt = 0 + +#: Indicates that the module is enabled and loaded w/o errors +IS_ENABLED: bool = False +#: The path of the config file the event device configuration was loaded from +CONFIG_FILE: str + +# Constants +_TYPE_BUTTON = 'Button' +_ACTION_ON_PRESS = 'on_press' + +_SUPPORTED_TYPES = [_TYPE_BUTTON] +_SUPPORTED_ACTIONS = {_TYPE_BUTTON: _ACTION_ON_PRESS} + + +@plugin.register +def activate( + device_name: str, + button_callbacks: dict[int, Callable], + exact: bool = True, + mandatory_keys: set[int] | None = None, +): + """Activate an event device listener + + :param device_name: device name + :type device_name: str + :param button_callbacks: mapping of event + code to RPC + :type button_callbacks: dict[int, Callable] + :param exact: Should the device_name match exactly + (default, false) or be a substring of the name? + :type exact: bool, optional + :param mandatory_keys: Mandatory event ids the + device needs to support. Defaults to None + to require all ids from the button_callbacks + :type mandatory_keys: set[int] | None, optional + """ + global listener + global listener_cnt + logger.debug("activate event device: %s", device_name) + # Do a bit of housekeeping: Delete dead threads + listener = list(filter(lambda x: x.is_alive(), listener)) + # Check that there is no running thread for this device already + for thread in listener: + if thread.device_name_request == device_name and thread.is_alive(): + logger.debug( + "Event device listener thread already active for '%s'", + device_name, + ) + return + + listener_cnt += 1 + new_listener = EvDevKeyListener( + device_name_request=device_name, + exact_name=exact, + thread_name=f"EvDevKeyListener-{listener_cnt}", + ) + + listener.append(new_listener) + if button_callbacks is not None: + new_listener.button_callbacks = button_callbacks + if mandatory_keys is not None: + new_listener.mandatory_keys = mandatory_keys + else: + new_listener.mandatory_keys = set(button_callbacks.keys()) + new_listener.start() + + +@plugin.initialize +def initialize(): + """Initialize event device button listener from config + + Initializes event buttons from the main configuration file. + Please see the documentation `builders/event-devices.md` for a specification of the format. + """ + global IS_ENABLED + global CONFIG_FILE + IS_ENABLED = False + enable = cfg_main.setndefault('evdev', 'enable', value=False) + CONFIG_FILE = cfg_main.setndefault('evdev', 'config_file', value='../../shared/settings/evdev.yaml') + if not enable: + return + try: + jukebox.cfghandler.load_yaml(cfg_evdev, CONFIG_FILE) + except Exception as e: + logger.error(f"Disable Event Devices due to error loading evdev config file. {e.__class__.__name__}: {e}") + return + + IS_ENABLED = True + + with cfg_evdev: + for name, config in cfg_evdev.getn( + "devices", + default={}, + ).items(): + logger.debug("activate %s", name) + try: + device_name, exact, button_callbacks = parse_device_config(config) + except Exception as e: + logger.error(f"Error parsing event device config for '{name}'. {e.__class__.__name__}: {e}") + continue + + logger.debug( + f'Call activate with: "{device_name}" and exact: {exact}', + ) + activate( + device_name, + button_callbacks=button_callbacks, + exact=exact, + ) + + +def parse_device_config(config: dict) -> Tuple[str, bool, dict[int, Callable]]: + """Parse the device configuration from the config file + + :param config: The configuration of the device + :type config: dict + :return: The parsed device configuration + :rtype: Tuple[str, bool, dict[int, Callable]] + """ + device_name = config.get("device_name") + if device_name is None: + raise ValueError("'device_name' is required but missing") + exact = bool(config.get("exact", False)) + input_devices = config.get("input_devices", {}) + # Raise warning if not used config present + if 'output_devices' in config: + logger.warning( + "Output devices are not yet supported for event devices", + ) + + # Parse input devices and convert them to button mappings. + # Due to the current implementation of the Event Device Listener, + # only the 'on_press' action is supported. + button_mapping = _input_devices_to_key_mapping(input_devices) + button_callbacks: dict[int, Callable] = {} + for key, action_request in button_mapping.items(): + button_callbacks[key] = jukebox.utils.bind_rpc_command( + action_request, + dereference=False, + logger=logger, + ) + return device_name, exact, button_callbacks + + +def _input_devices_to_key_mapping(input_devices: dict) -> dict: + """Convert input devices to key mapping + + Currently this only supports 'button' input devices with the 'on_press' action. + + :param input_devices: The input devices + :type input_devices: dict + :return: The mapping of key_code to action + :rtype: dict + """ + mapping = {} + for nickname, device in input_devices.items(): + input_type = device.get('type') + if input_type not in _SUPPORTED_TYPES: + logger.warning( + f"Input '{nickname}' device type '{input_type}' is not supported", + ) + continue + + key_code = device.get('kwargs', {}).get('key_code') + if key_code is None: + logger.warning( + f"Input '{nickname}' has no key_code and cannot be mapped.", + ) + continue + + actions = device.get('actions') + + for action_name, action in actions.items(): + if action_name not in _SUPPORTED_ACTIONS[_TYPE_BUTTON]: + logger.warning( + f"Input '{nickname}' has unsupported action '{action_name}'.\n" + f"Currently supported actions: {_SUPPORTED_ACTIONS}", + ) + if action_name == _ACTION_ON_PRESS: + mapping[key_code] = action + + return mapping + + +@plugin.atexit +def atexit(**ignored_kwargs): + global listener + for ll in listener: + ll.stop() + return listener diff --git a/src/jukebox/components/hostif/linux/__init__.py b/src/jukebox/components/hostif/linux/__init__.py index 6a6590ad6..a26152eae 100644 --- a/src/jukebox/components/hostif/linux/__init__.py +++ b/src/jukebox/components/hostif/linux/__init__.py @@ -10,27 +10,12 @@ import jukebox.publishing import jukebox.speaking_text from jukebox.multitimer import GenericEndlessTimerClass -import socket logger = logging.getLogger('jb.host.lnx') cfg = jukebox.cfghandler.get_handler('jukebox') # Get the main Thread Publisher publisher = jukebox.publishing.get_publisher() -# This is a slightly dirty way of checking if we are on an RPi -# JukeBox installs the dependency RPI which has no meaning on other machines -# If it does not exist all is clear -# It could still be installed, which results in a RuntimeError when loaded on a PC -try: - import RPi.GPIO as gpio # noqa: F401 - - IS_RPI = True -except ModuleNotFoundError: - IS_RPI = False -except RuntimeError as e: - logger.info(f"You don't seem to be on a PI, because loading 'RPi.GPIO' failed: {e.__class__.__name__}: {e}") - IS_RPI = False - # In debug mode, shutdown and reboot command are not actually executed IS_DEBUG = False try: @@ -302,73 +287,60 @@ def start_autohotspot(): return 'not-installed' -@plugin.initialize -def initialize(): - wlan_power = cfg.setndefault('host', 'wlan_power', 'disable_power_down', value=True) - card = cfg.setndefault('host', 'wlan_power', 'card', value='wlan0') - if wlan_power: - wlan_disable_power_down(card) +# --------------------------------------------------------------------------- +# RPi-only stuff +# --------------------------------------------------------------------------- +THROTTLE_CODES = { + 0x1: "under-voltage detected", + 0x2: "ARM frequency capped", + 0x4: "currently throttled", + 0x8: "soft temperature limit active", + 0x10000: "under-voltage has occurred", + 0x20000: "ARM frequency capped has occurred", + 0x40000: "throttling has occurred", + 0x80000: "soft temperature limit has occurred" +} -@plugin.finalize -def finalize(): - global timer_temperature - enabled = cfg.setndefault('host', 'publish_temperature', 'enabled', value=True) - wait_time = cfg.setndefault('host', 'publish_temperature', 'timer_interval_sec', value=5) - timer_temperature = GenericEndlessTimerClass('host.timer.cputemp', wait_time, publish_cpu_temperature) - timer_temperature.__doc__ = "Endless timer for publishing CPU temperature" - # Note: Since timer_temperature is an instance of a class from a different module, - # auto-registration would register it with that module. Manually set package to this plugin module - plugin.register(timer_temperature, name='timer_temperature', package=plugin.loaded_as(__name__)) - if enabled: - publish_cpu_temperature() - timer_temperature.start() - +def command_exists(command): + ret = shutil.which(command) + return ret is not None -@plugin.atexit -def atexit(**ignored_kwargs): - global timer_temperature - timer_temperature.cancel() - return timer_temperature.timer_thread +@plugin.register +def hdmi_power_down(): + """Power down HDMI circuits to save power if no display is connected -# --------------------------------------------------------------------------- -# RPi-only stuff -# --------------------------------------------------------------------------- -if IS_RPI: # noqa: C901 - - THROTTLE_CODES = { - 0x1: "under-voltage detected", - 0x2: "ARM frequency capped", - 0x4: "currently throttled", - 0x8: "soft temperature limit active", - 0x10000: "under-voltage has occurred", - 0x20000: "ARM frequency capped has occurred", - 0x40000: "throttling has occurred", - 0x80000: "soft temperature limit has occurred" - } - - @plugin.register - def hdmi_power_down(): - """Power down HDMI circuits to save power if no display is connected - - This must be done after every reboot""" + This must be done after every reboot""" + success = False + commandname = "tvservice" + if command_exists(commandname): logger.info('Power down HDMI circuits') - ret = subprocess.run(['sudo', '/usr/bin/tvservice', '-o'], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False) + ret = subprocess.run(['sudo', commandname, '-o'], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False) if ret.returncode != 0: logger.error(f"{ret.stdout}") + else: + success = True + else: + logger.info('Power down HDMI not available on this system') - def filter_throttle_codes(code): - for error, msg in THROTTLE_CODES.items(): - if code & error > 0: - yield msg + return success - @plugin.register - def get_throttled(): + +def filter_throttle_codes(code): + for error, msg in THROTTLE_CODES.items(): + if code & error > 0: + yield msg + + +@plugin.register +def get_throttled(): + commandname = "vcgencmd" + if command_exists(commandname): # https://www.raspberrypi.org/documentation/computers/os.html#get_throttled - ret = subprocess.run(['sudo', 'vcgencmd', 'get_throttled'], - stdout=subprocess.PIPE, check=False) + ret = subprocess.run(['sudo', commandname, 'get_throttled'], + stdout=subprocess.PIPE, check=False) if ret.returncode != 0: status_string = f"Error in subprocess with code: {ret.returncode}" logger.error(status_string) @@ -384,11 +356,44 @@ def get_throttled(): else: # Decode the bit array after we have handled all the possible exceptions status_string = "Warning: " + ', '.join(filter_throttle_codes(status_code)) + else: + logger.info('Throttled state not available on this system') + status_string = "Not available" + + return status_string + + +# --------------------------------------------------------------------------- +# Init +# --------------------------------------------------------------------------- +@plugin.initialize +def initialize(): + wlan_power = cfg.setndefault('host', 'wlan_power', 'disable_power_down', value=True) + card = cfg.setndefault('host', 'wlan_power', 'card', value='wlan0') + if wlan_power: + wlan_disable_power_down(card) + hdmi_off = cfg.setndefault('host', 'rpi', 'hdmi_power_down', value=False) + if hdmi_off: + hdmi_power_down() + + +@plugin.finalize +def finalize(): + global timer_temperature + enabled = cfg.setndefault('host', 'publish_temperature', 'enabled', value=True) + wait_time = cfg.setndefault('host', 'publish_temperature', 'timer_interval_sec', value=5) + timer_temperature = GenericEndlessTimerClass('host.timer.cputemp', wait_time, publish_cpu_temperature) + timer_temperature.__doc__ = "Endless timer for publishing CPU temperature" + # Note: Since timer_temperature is an instance of a class from a different module, + # auto-registration would register it with that module. Manually set package to this plugin module + plugin.register(timer_temperature, name='timer_temperature', package=plugin.loaded_as(__name__)) + if enabled: + publish_cpu_temperature() + timer_temperature.start() - return status_string - @plugin.initialize - def rpi_initialize(): - hdmi_off = cfg.setndefault('host', 'rpi', 'hdmi_power_down', value=False) - if hdmi_off: - hdmi_power_down() +@plugin.atexit +def atexit(**ignored_kwargs): + global timer_temperature + timer_temperature.cancel() + return timer_temperature.timer_thread diff --git a/src/jukebox/components/misc.py b/src/jukebox/components/misc.py index 9995509aa..2cc260d79 100644 --- a/src/jukebox/components/misc.py +++ b/src/jukebox/components/misc.py @@ -8,8 +8,10 @@ import jukebox.plugs as plugin import jukebox.utils from jukebox.daemon import get_jukebox_daemon +import jukebox.cfghandler logger = logging.getLogger('jb.misc') +cfg = jukebox.cfghandler.get_handler('jukebox') @plugin.register @@ -105,3 +107,20 @@ def empty_rpc_call(msg: str = ''): """ if msg: logger.warning(msg) + + +@plugin.register +def get_app_settings(): + """Return settings for web app stored in jukebox.yaml""" + show_covers = cfg.setndefault('webapp', 'show_covers', value=True) + + return { + 'show_covers': show_covers + } + + +@plugin.register +def set_app_settings(settings={}): + """Set configuration settings for the web app.""" + for key, value in settings.items(): + cfg.setn('webapp', key, value=value) diff --git a/src/jukebox/components/mqtt/__init__.py b/src/jukebox/components/mqtt/__init__.py new file mode 100644 index 000000000..a588dea94 --- /dev/null +++ b/src/jukebox/components/mqtt/__init__.py @@ -0,0 +1,242 @@ +import json +import logging +import threading +from typing import Any + +import paho.mqtt.client as paho_mqtt + +import jukebox.cfghandler +import jukebox.plugs as plugs +import jukebox.publishing +import jukebox.publishing.server +import jukebox.publishing.subscriber + +from .mqtt_command_alias import legacy_mqtt_cmd, mqtt_cmd +from .mqtt_const import Mqtt_Attributes, topics_to_send +from .utils import ( + get_args, + get_current_time_milli, + get_kwargs, + get_rpc_command, + map_repeat_mode, + split_topic, +) + +logger = logging.getLogger("jb.mqtt") +cfg = jukebox.cfghandler.get_handler("jukebox") + +base_topic = cfg.setndefault("mqtt", "base_topic", value="phoniebox-dev") +mqtt_enabled = cfg.setndefault('mqtt', 'enable', value=False) is True +legacy_support_enabled = cfg.setndefault("mqtt", "enable_legacy", value=True) + + +class MQTT(threading.Thread): + """A thread for monitoring events and publishing interesting events via MQTT.""" + + _topic_name: str + _mqtt_client: paho_mqtt.Client + _attributes: dict = {} + _available_cmds = mqtt_cmd + + def __init__(self, client: paho_mqtt.Client): + super().__init__(name="MqttClient") + if mqtt_enabled: + self._mqtt_client = client + if legacy_support_enabled: + logger.info("Supporting legacy MQTT commands.") + self._available_cmds = {**mqtt_cmd, **legacy_mqtt_cmd} + + self.daemon = True + self._keep_running = True + self.listen_done = threading.Event() + self.action_done = threading.Event() + else: + logger.info("MQTT Client is disabled") + + def _subscribe(self): + logger.debug("Subscribing to MQTT topics.") + self._mqtt_client.message_callback_add(f'{base_topic}/cmd/#', self._on_cmd) + + def _on_cmd(self, client, userdata, msg): + cmd = split_topic(topic=msg.topic) + payload = msg.payload.decode("utf-8") + logger.debug(f'Received MQTT command "{cmd}" with payload "{payload}"') + try: + config = self._available_cmds.get(cmd) + if not config: + logger.warning(f'No configuration found for MQTT command "{cmd}"') + return + + rpc = get_rpc_command(config) + args = get_args(config, payload) + kwargs = get_kwargs(config, payload) + + if rpc is None: + logger.warning(f'No RPC call configured for MQTT command "{cmd}"') + return + + package = rpc.get("package") + plugin = rpc.get("plugin") + method = rpc.get("method") + + if package is None: + raise ValueError( + f'Missing "package" attribute for MQTT command "{cmd}"' + ) + elif plugin is None: + raise ValueError(f'Missing "plugin" attribute for MQTT command "{cmd}"') + elif method is None: + raise ValueError(f'Missing "method" attribute for MQTT command "{cmd}"') + else: + logger.info( + f'Executing MQTT command "{cmd}" with package="{package}",' + + f'plugin="{plugin}", method="{method}", args={args}, kwargs={kwargs}' + ) + plugs.call_ignore_errors( + package=package, + plugin=plugin, + method=method, + args=args, + kwargs=kwargs, + ) + except Exception as e: + logger.error( + f"Ignoring failed call for MQTT command '{cmd}': {e}", exc_info=True + ) + + def _publish(self, topic: str, payload: Any, *, qos=0, retain=False): + """Publish a message via MQTT.""" + logger.debug( + f'Publishing to topic "{topic}" with payload "{payload}", qos={qos}, retain={retain}' + ) + self._mqtt_client.publish( + topic=f"{base_topic}/{topic}", + payload=json.dumps(payload), + qos=qos, + retain=retain, + ) + + def _send_throttled( + self, topic: str, payload: Any, *, min_time_skip=500, qos=0, retain=False + ): + """Send an MQTT message throttled unless value has changed.""" + now = get_current_time_milli() + + if topic in self._attributes: + prev = self._attributes[topic] + time_since_last_update = now - prev["last_update"] + if prev["value"] == payload and time_since_last_update < 30000: + return + if prev["value"] != payload and time_since_last_update < min_time_skip: + return + + logger.debug( + f'Sending throttled message for topic "{topic}" with payload "{payload}"' + ) + self._attributes[topic] = {"value": payload, "last_update": now} + self._publish(topic, payload, retain=retain, qos=qos) + + def _send_player_state(self, payload: Any): + """Map player state data.""" + self._send_throttled(Mqtt_Attributes.STATE.value, payload["state"]) + for attr in ["title", "artist", "elapsed", "duration", "track", "file"]: + if attr in payload: + self._send_throttled(Mqtt_Attributes[attr.upper()].value, payload[attr]) + + self._send_throttled(Mqtt_Attributes.RANDOM.value, payload.get("random") == "1") + + repeat_active = bool(payload.get("repeat") == "1") + self._send_throttled(Mqtt_Attributes.REPEAT.value, repeat_active) + self._send_throttled( + Mqtt_Attributes.REPEAT_MODE.value, + map_repeat_mode(repeat_active, payload.get("single") == "1"), + ) + + def _send_volume(self, payload: Any): + """Map volume data.""" + logger.debug(f"Sending volume update with payload: {payload}") + if legacy_support_enabled: + self._send_throttled(Mqtt_Attributes.VOLUME.value, payload.get("volume")) + self._send_throttled(Mqtt_Attributes.MUTE.value, bool(payload.get("mute"))) + self._send_throttled("status/player/volume", payload.get("volume")) + self._send_throttled("status/player/mute", bool(payload.get("mute"))) + + def run(self) -> None: + """Main loop of the MQTT thread.""" + logger.info("Starting MQTT Thread") + self._send_throttled("state", "online", qos=1, retain=True) + self._send_throttled("version", jukebox.version(), qos=1, retain=True) # type: ignore + self._subscribe() + + sub = jukebox.publishing.subscriber.Subscriber( + "inproc://PublisherToProxy", topics_to_send + ) + while self._keep_running: + topic, payload = sub.receive() + if topic == "volume.level": + self._send_volume(payload) + elif topic == "playerstatus": + self._send_player_state(payload) + logger.info("Exiting MQTT Thread") + + def stop(self): + """Stop the MQTT thread.""" + logger.info("Stopping MQTT Thread") + self._send_throttled("state", "offline", qos=1, retain=True) + + self._keep_running = False + self.listen_done.clear() + self.action_done.set() + + +mqtt: MQTT +mqtt_client: paho_mqtt.Client + + +def on_connect(client, userdata, flags, rc): + """Start thread on successful MQTT connection.""" + global mqtt + logger.debug(f"Connected with result code {rc} to {base_topic}") + mqtt = MQTT(client) + mqtt.start() + + +@plugs.initialize +def initialize(): + """Setup connection and trigger the MQTT loop.""" + global mqtt_client + + if mqtt_enabled: + client_id = cfg.setndefault("mqtt", "client_id", value="phoniebox-future3") + username = cfg.setndefault("mqtt", "username", value="phoniebox-dev") + password = cfg.setndefault("mqtt", "password", value="phoniebox-dev") + host = cfg.setndefault("mqtt", "host", value="127.0.0.1") + port = cfg.setndefault("mqtt", "port", value=1883) + + logger.info( + f"Initializing MQTT client with client_id={client_id}, username={username}, host={host}, port={port}" + ) + mqtt_client = paho_mqtt.Client(client_id=client_id) + mqtt_client.username_pw_set(username=username, password=password) + mqtt_client.on_connect = on_connect + mqtt_client.will_set( + topic=f"{base_topic}/state", payload=json.dumps("offline"), qos=1, retain=True + ) + mqtt_client.connect(host, port, 60) + mqtt_client.loop_start() + logger.info("MQTT client initialized and loop started") + else: + logger.info("MQTT client is disabled") + + +@plugs.atexit +def atexit(signal_id: int, **ignored_kwargs): + global mqtt, mqtt_client + if mqtt_enabled: + logger.info("Executing atexit handler, stopping MQTT client") + mqtt.stop() + mqtt_client.loop_stop() + mqtt_client.disconnect() + logger.info("MQTT client stopped and disconnected") + else: + logger.info("MQTT client is disabled") diff --git a/src/jukebox/components/mqtt/mqtt_command_alias.py b/src/jukebox/components/mqtt/mqtt_command_alias.py new file mode 100644 index 000000000..0f4c02998 --- /dev/null +++ b/src/jukebox/components/mqtt/mqtt_command_alias.py @@ -0,0 +1,197 @@ +""" +This file provides definitions for MQTT to RPC command aliases + +See [RPC Commands](../../builders/rpc-commands.md) +""" + +import json + +import jukebox.plugs as plugs + +from .mqtt_const import Mqtt_Commands +from .utils import parse_repeat_mode, play_folder_recursive_args + + +def get_mute(payload) -> bool: + """Helper to toggle mute in legacy support.""" + is_mute = plugs.call_ignore_errors( + package="volume", plugin="ctrl", method="get_mute" + ) + return not is_mute + + +legacy_mqtt_cmd = { + "volumeup": {"rpc": "change_volume", "args": 1}, + "volumedown": {"rpc": "change_volume", "args": -1}, + "mute": { + "rpc": { + "package": "volume", + "plugin": "ctrl", + "method": "mute", + }, + "args": get_mute, + }, + "playerplay": {"rpc": "play"}, + "playerpause": {"rpc": "pause"}, + "playernext": {"rpc": "next_song"}, + "playerprev": {"rpc": "prev_song"}, + "playerstop": { + "rpc": { + "package": "player", + "plugin": "ctrl", + "method": "stop", + } + }, + "playerrewind": { + "rpc": { + "package": "player", + "plugin": "ctrl", + "method": "rewind", + } + }, + "playershuffle": {"rpc": "shuffle"}, + "playerreplay": { + "rpc": { + "package": "player", + "plugin": "ctrl", + "method": "replay", + } + }, + "setvolume": { + "rpc": "set_volume", + "args": int, + }, + "setmaxvolume": { + "rpc": "set_soft_max_volume", + "args": int, + }, + "shutdownafter": { + "rpc": "timer_shutdown", + "args": int, + }, + "playerstopafter": { + "rpc": "timer_stop_player", + "args": int, + }, + "playerrepeat": { + "rpc": "repeat", + "args": parse_repeat_mode, + }, + "playfolder": { + "rpc": "play_folder", + "args": str, + }, + "playfolderrecursive": { + "rpc": "play_folder", + "kwargs": play_folder_recursive_args, # kwargs: folder, recursive + }, + # "scan": {}, + # "shutdownsilent": {}, + # "disablewifi": {}, + # "setidletime": {}, + # "playerseek": {}, + # "setvolstep": {}, + # "rfid": {}, + # "gpio": {}, + # "swipecard": {}, +} + + +_player_cmds = { + Mqtt_Commands.PLAY.value: {"rpc": "play"}, + Mqtt_Commands.PLAY_FOLDER.value: { + "rpc": "play_folder", + "kwargs": json.loads, # kwargs: folder, recursive + }, + Mqtt_Commands.PLAY_ALBUM.value: { + "rpc": "play_album", + "kwargs": json.loads, # kwargs: albumartist, album + }, + Mqtt_Commands.PLAY_CARD.value: { + "rpc": "play_card", + "kwargs": json.loads, # kwargs: folder, recursive + }, + Mqtt_Commands.PLAY_SINGLE.value: { + "rpc": "play_single", + "kwargs": json.loads, # kwargs: song_url + }, + Mqtt_Commands.PAUSE.value: {"rpc": "pause"}, + Mqtt_Commands.NEXT_SONG.value: {"rpc": "next_song"}, + Mqtt_Commands.PREV_SONG.value: {"rpc": "prev_song"}, + Mqtt_Commands.STOP.value: { + "rpc": { + "package": "player", + "plugin": "ctrl", + "method": "stop", + } + }, + Mqtt_Commands.REWIND.value: { + "rpc": { + "package": "player", + "plugin": "ctrl", + "method": "rewind", + } + }, + Mqtt_Commands.SHUFFLE.value: {"rpc": "shuffle"}, + Mqtt_Commands.REPLAY.value: { + "rpc": { + "package": "player", + "plugin": "ctrl", + "method": "replay", + } + }, + Mqtt_Commands.REPEAT.value: { + "rpc": "repeat", + "kwargs": json.loads, # kwargs: option + }, +} + +_volume_cmds = { + Mqtt_Commands.CHANGE_VOLUME.value: { + "rpc": "change_volume", + "kwargs": json.loads, # kwargs: step + }, + Mqtt_Commands.SET_VOLUME.value: { + "rpc": "set_volume", + "kwargs": json.loads, # kwargs: volume + }, + Mqtt_Commands.VOLUME_MUTE.value: { + "rpc": { + "package": "volume", + "plugin": "ctrl", + "method": "mute", + }, + "kwargs": json.loads, # kwargs: mute + }, + Mqtt_Commands.SET_SOFT_MAX_VOLUME.value: { + "rpc": "set_soft_max_volume", + "kwargs": json.loads, # kwargs: max_volume + }, +} + +_system_cmd = { + Mqtt_Commands.SAY_MY_IP.value: { + "rpc": "say_my_ip", + "kwargs": json.loads, # kwargs: option + }, + Mqtt_Commands.SHUTDOWN.value: {"rpc": "shutdown"}, + Mqtt_Commands.REBOOT.value: {"rpc": "reboot"}, + Mqtt_Commands.TIMER_SHUTDOWN.value: { + "rpc": "timer_shutdown", + "kwargs": json.loads, # kwargs: value + }, + Mqtt_Commands.TIMER_STOP_PLAYER.value: { + "rpc": "timer_stop_player", + "kwargs": json.loads, # kwargs: value + }, + Mqtt_Commands.TIMER_FADE_VOLUME.value: { + "rpc": "timer_fade_volume", + "kwargs": json.loads, # kwargs: value + }, +} + +mqtt_cmd = { + **_volume_cmds, + **_system_cmd, + **_player_cmds, +} diff --git a/src/jukebox/components/mqtt/mqtt_const.py b/src/jukebox/components/mqtt/mqtt_const.py new file mode 100644 index 000000000..0fdb211b0 --- /dev/null +++ b/src/jukebox/components/mqtt/mqtt_const.py @@ -0,0 +1,51 @@ +from enum import Enum + + +class Mqtt_Attributes(Enum): + STATE = "attribute/state" + TITLE = "attribute/title" + ARTIST = "attribute/artist" + ELAPSED = "attribute/elapsed" + DURATION = "attribute/duration" + TRACK = "attribute/track" + FILE = "attribute/file" + RANDOM = "attribute/random" + REPEAT = "attribute/repeat" + REPEAT_MODE = "attribute/repeat_mode" + VOLUME = "attribute/volume" + MUTE = "attribute/mute" + + +class Mqtt_Commands(Enum): + PLAY = "play" + PLAY_FOLDER = "play_folder" + PLAY_ALBUM = "play_album" + PLAY_CARD = "play_card" + PLAY_SINGLE = "play_single" + PAUSE = "pause" + NEXT_SONG = "next_song" + PREV_SONG = "prev_song" + STOP = "stop" + REWIND = "rewind" + SHUFFLE = "shuffle" + REPLAY = "replay" + REPEAT = "repeat" + CHANGE_VOLUME = "change_volume" + SET_VOLUME = "set_volume" + VOLUME_MUTE = "volume_mute" + SET_SOFT_MAX_VOLUME = "set_soft_max_volume" + SAY_MY_IP = "say_my_ip" + SHUTDOWN = "shutdown" + REBOOT = "reboot" + TIMER_SHUTDOWN = "timer_shutdown" + TIMER_STOP_PLAYER = "timer_stop_player" + TIMER_FADE_VOLUME = "timer_fade_volume" + + +# List of topics to send +topics_to_send = ["volume.level", "playerstatus"] + +# Constants for repeat modes +REPEAT_MODE_OFF = "off" +REPEAT_MODE_SINGLE = "single" +REPEAT_MODE_PLAYLIST = "playlist" diff --git a/src/jukebox/components/mqtt/utils.py b/src/jukebox/components/mqtt/utils.py new file mode 100644 index 000000000..48843a9ac --- /dev/null +++ b/src/jukebox/components/mqtt/utils.py @@ -0,0 +1,70 @@ +import time +from typing import Callable, Optional + +from components.rpc_command_alias import cmd_alias_definitions + +from .mqtt_const import REPEAT_MODE_OFF, REPEAT_MODE_PLAYLIST, REPEAT_MODE_SINGLE + + +def play_folder_recursive_args(payload: str) -> dict: + """Create arguments for playing a folder recursively.""" + return {"folder": payload, "recursive": True} + + +def parse_repeat_mode(payload: str) -> Optional[str]: + """Parse a repeat mode command based on the given payload.""" + if payload == "single": + return "toggle_repeat_single" + elif payload == "playlist": + return "toggle_repeat" + elif payload in ["disable", "off"]: + return None + return "toggle" + + +def get_args(config: dict, payload: dict) -> Optional[dict]: + """Retrieve arguments based on the configuration and payload.""" + if "args" not in config: + return None + elif isinstance(config["args"], Callable): + return config["args"](payload) + return config["args"] + + +def get_rpc_command(config: dict) -> Optional[dict]: + """Retrieve the RPC command based on the configuration.""" + rpc_key = config.get("rpc") + if rpc_key is None: + return None + elif isinstance(config["rpc"], str): + return cmd_alias_definitions[rpc_key] + return config["rpc"] + + +def get_kwargs(config: dict, payload: dict) -> Optional[dict]: + """Retrieve keyword arguments based on the configuration and payload.""" + if "kwargs" not in config: + return None + elif isinstance(config["kwargs"], Callable): + return config["kwargs"](payload) + return config["kwargs"] + + +def get_current_time_milli() -> int: + """Get the current time in milliseconds.""" + return round(time.time() * 1000) + + +def split_topic(topic: str) -> str: + """Split an MQTT topic and return a part of it.""" + parts = topic.split("/") + return parts[2] if len(parts) == 3 else parts[1] + + +def map_repeat_mode(repeat_active: bool, single_active: bool) -> str: + """Map boolean flags to repeat mode constants.""" + if not repeat_active: + return REPEAT_MODE_OFF + if single_active: + return REPEAT_MODE_SINGLE + return REPEAT_MODE_PLAYLIST diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py index 49f630224..86dbc60ab 100644 --- a/src/jukebox/components/playermpd/__init__.py +++ b/src/jukebox/components/playermpd/__init__.py @@ -87,7 +87,7 @@ import logging import time import functools -from slugify import slugify +from pathlib import Path import components.player import jukebox.cfghandler import jukebox.utils as utils @@ -156,6 +156,31 @@ def __init__(self): self.second_swipe_action = None self.decode_2nd_swipe_option() + self.end_of_playlist_next_action = utils.get_config_action(cfg, + 'playermpd', + 'end_of_playlist_next_action', + 'none', + {'rewind': self.rewind, + 'stop': self.stop, + 'none': lambda: None}, + logger) + self.stopped_prev_action = utils.get_config_action(cfg, + 'playermpd', + 'stopped_prev_action', + 'prev', + {'rewind': self.rewind, + 'prev': self._prev_in_stopped_state, + 'none': lambda: None}, + logger) + self.stopped_next_action = utils.get_config_action(cfg, + 'playermpd', + 'stopped_next_action', + 'next', + {'rewind': self.rewind, + 'next': self._next_in_stopped_state, + 'none': lambda: None}, + logger) + self.mpd_client = mpd.MPDClient() self.coverart_cache_manager = CoverartCacheManager() @@ -327,15 +352,48 @@ def pause(self, state: int = 1): @plugs.tag def prev(self): logger.debug("Prev") + if self.mpd_status['state'] == 'stop': + logger.debug('Player is stopped, calling stopped_prev_action') + return self.stopped_prev_action() + try: + with self.mpd_lock: + self.mpd_client.previous() + except mpd.base.CommandError: + # This shouldn't happen in reality, but we still catch + # this error to avoid crashing the player thread: + logger.warning('Failed to go to previous song, ignoring') + + def _prev_in_stopped_state(self): with self.mpd_lock: - self.mpd_client.previous() + self.mpd_client.play(max(0, int(self.mpd_status['pos']) - 1)) @plugs.tag def next(self): """Play next track in current playlist""" logger.debug("Next") + if self.mpd_status['state'] == 'stop': + logger.debug('Player is stopped, calling stopped_next_action') + return self.stopped_next_action() + playlist_len = int(self.mpd_status.get('playlistlength', -1)) + current_pos = int(self.mpd_status.get('pos', 0)) + if current_pos == playlist_len - 1: + logger.debug(f'next() called during last song ({current_pos}) of ' + f'playlist (len={playlist_len}), running end_of_playlist_next_action.') + return self.end_of_playlist_next_action() + try: + with self.mpd_lock: + self.mpd_client.next() + except mpd.base.CommandError: + # This shouldn't happen in reality, but we still catch + # this error to avoid crashing the player thread: + logger.warning('Failed to go to next song, ignoring') + + def _next_in_stopped_state(self): + pos = int(self.mpd_status['pos']) + 1 + if pos > int(self.mpd_status['playlistlength']) - 1: + return self.end_of_playlist_next_action() with self.mpd_lock: - self.mpd_client.next() + self.mpd_client.play(pos) @plugs.tag def seek(self, new_time): @@ -350,7 +408,7 @@ def rewind(self): Note: Will not re-read folder config, but leave settings untouched""" logger.debug("Rewind") with self.mpd_lock: - self.mpd_client.play(1) + self.mpd_client.play(0) @plugs.tag def replay(self): @@ -521,40 +579,10 @@ def play_card(self, folder: str, recursive: bool = False): @plugs.tag def get_single_coverart(self, song_url): - """ - Saves the album art image to a cache and returns the filename. - """ - base_filename = slugify(song_url) - - try: - metadata_list = self.mpd_client.listallinfo(song_url) - metadata = {} - if metadata_list: - metadata = metadata_list[0] - - if 'albumartist' in metadata and 'album' in metadata: - base_filename = slugify(f"{metadata['albumartist']}-{metadata['album']}") - - cache_filename = self.coverart_cache_manager.find_file_by_hash(base_filename) - - if cache_filename: - return cache_filename + mp3_file_path = Path(components.player.get_music_library_path(), song_url).expanduser() + cache_filename = self.coverart_cache_manager.get_cache_filename(mp3_file_path) - # Cache file does not exist - # Fetch cover art binary - album_art_data = self.mpd_client.readpicture(song_url) - - # Save to cache - cache_filename = self.coverart_cache_manager.save_to_cache(base_filename, album_art_data) - - return cache_filename - - except mpd.base.CommandError as e: - logger.error(f"{e.__class__.__qualname__}: {e} at uri {song_url}") - except Exception as e: - logger.error(f"{e.__class__.__qualname__}: {e} at uri {song_url}") - - return "" + return cache_filename @plugs.tag def get_album_coverart(self, albumartist: str, album: str): @@ -562,6 +590,14 @@ def get_album_coverart(self, albumartist: str, album: str): return self.get_single_coverart(song_list[0]['file']) + @plugs.tag + def flush_coverart_cache(self): + """ + Deletes the Cover Art Cache + """ + + return self.coverart_cache_manager.flush_cache() + @plugs.tag def get_folder_content(self, folder: str): """ diff --git a/src/jukebox/components/playermpd/coverart_cache_manager.py b/src/jukebox/components/playermpd/coverart_cache_manager.py index a7ae12eef..f292a2bbe 100644 --- a/src/jukebox/components/playermpd/coverart_cache_manager.py +++ b/src/jukebox/components/playermpd/coverart_cache_manager.py @@ -1,26 +1,107 @@ -import os +from mutagen.mp3 import MP3 +from mutagen.id3 import ID3, APIC +from pathlib import Path +import hashlib +import logging +from queue import Queue +from threading import Thread import jukebox.cfghandler +COVER_PREFIX = 'cover' +NO_COVER_ART_EXTENSION = 'no-art' +NO_CACHE = '' +CACHE_PENDING = 'CACHE_PENDING' + +logger = logging.getLogger('jb.CoverartCacheManager') cfg = jukebox.cfghandler.get_handler('jukebox') class CoverartCacheManager: def __init__(self): coverart_cache_path = cfg.setndefault('webapp', 'coverart_cache_path', value='../../src/webapp/build/cover-cache') - self.cache_folder_path = os.path.expanduser(coverart_cache_path) + self.cache_folder_path = Path(coverart_cache_path).expanduser() + self.write_queue = Queue() + self.worker_thread = Thread(target=self.process_write_requests) + self.worker_thread.daemon = True # Ensure the thread closes with the program + self.worker_thread.start() + + def generate_cache_key(self, base_filename: str) -> str: + return f"{COVER_PREFIX}-{hashlib.sha256(base_filename.encode()).hexdigest()}" + + def get_cache_filename(self, mp3_file_path: str) -> str: + base_filename = Path(mp3_file_path).stem + cache_key = self.generate_cache_key(base_filename) + + for path in self.cache_folder_path.iterdir(): + if path.stem == cache_key: + if path.suffix == f".{NO_COVER_ART_EXTENSION}": + return NO_CACHE + return path.name - def find_file_by_hash(self, hash_value): - for filename in os.listdir(self.cache_folder_path): - if filename.startswith(hash_value): - return filename - return None + self.save_to_cache(mp3_file_path) + return CACHE_PENDING - def save_to_cache(self, base_filename, album_art_data): - mime_type = album_art_data['type'] - file_extension = 'jpg' if mime_type == 'image/jpeg' else mime_type.split('/')[-1] - cache_filename = f"{base_filename}.{file_extension}" + def save_to_cache(self, mp3_file_path: str): + self.write_queue.put(mp3_file_path) - with open(os.path.join(self.cache_folder_path, cache_filename), 'wb') as file: - file.write(album_art_data['binary']) + def _save_to_cache(self, mp3_file_path: str): + base_filename = Path(mp3_file_path).stem + cache_key = self.generate_cache_key(base_filename) + + file_extension, data = self._extract_album_art(mp3_file_path) + if file_extension == NO_COVER_ART_EXTENSION: # Check if cover has been added as separate file in folder + file_extension, data = self._get_from_filesystem(mp3_file_path) + + cache_filename = f"{cache_key}.{file_extension}" + full_path = self.cache_folder_path / cache_filename # Works due to Pathlib + + with full_path.open('wb') as file: + file.write(data) + logger.debug(f"Created file: {cache_filename}") return cache_filename + + def _extract_album_art(self, mp3_file_path: str) -> tuple: + try: + audio_file = MP3(mp3_file_path, ID3=ID3) + except Exception as e: + logger.error(f"Error reading MP3 file {mp3_file_path}: {e}") + return (NO_COVER_ART_EXTENSION, b'') + + for tag in audio_file.tags.values(): + if isinstance(tag, APIC): + if tag.mime and tag.data: + file_extension = 'jpg' if tag.mime == 'image/jpeg' else tag.mime.split('/')[-1] + return (file_extension, tag.data) + + return (NO_COVER_ART_EXTENSION, b'') + + def _get_from_filesystem(self, mp3_file_path: str) -> tuple: + path = Path(mp3_file_path) + directory = path.parent + cover_files = list(directory.glob('Cover.*')) + list(directory.glob('cover.*')) + + for file in cover_files: + if file.suffix.lower() in ['.jpg', '.jpeg', '.png']: + with file.open('rb') as img_file: + data = img_file.read() + file_extension = file.suffix[1:] # Get extension without dot + return (file_extension, data) + + return (NO_COVER_ART_EXTENSION, b'') + + def process_write_requests(self): + while True: + mp3_file_path = self.write_queue.get() + try: + self._save_to_cache(mp3_file_path) + except Exception as e: + logger.error(f"Error processing write request: {e}") + self.write_queue.task_done() + + def flush_cache(self): + for path in self.cache_folder_path.iterdir(): + if path.is_file(): + path.unlink() + logger.debug(f"Deleted cached file: {path.name}") + logger.info("Cache flushed successfully.") diff --git a/src/jukebox/components/rfid/hardware/generic_usb/generic_usb.py b/src/jukebox/components/rfid/hardware/generic_usb/generic_usb.py index f6d0a5db5..d8b159b7b 100755 --- a/src/jukebox/components/rfid/hardware/generic_usb/generic_usb.py +++ b/src/jukebox/components/rfid/hardware/generic_usb/generic_usb.py @@ -66,7 +66,7 @@ def _is_keyboard(device: evdev.InputDevice) -> bool: is_keyboard_res = mandatory_keys.issubset(device_key_list) and reserved_key.isdisjoint(device_key_list) except KeyError: is_keyboard_res = False - logger.debug(f"is_keyboard test for '{device.name}' at '{device.fn}' is '{is_keyboard_res}'") + logger.debug(f"is_keyboard test for '{device.name}' at '{device.path}' is '{is_keyboard_res}'") return is_keyboard_res @@ -140,8 +140,6 @@ def __init__(self, reader_cfg_key, logger=None): raise KeyError("Mandatory key 'device_name' not given in configuration!") if 'device_phys' not in config: self._logger.warning("Key 'device_phys' not given in configuration! Trying without...") - if 'key_capability' not in config: - self._logger.warning("Key 'key_capability' not given in configuration! Using default value: 'true'.") if 'name_is_unique' not in config: self._logger.warning("Key 'name_is_unique' not given in configuration! Using default value: 'true'.") if 'key_check_is_unique' not in config: diff --git a/src/jukebox/components/rfid/hardware/rc522_spi/rc522_spi.py b/src/jukebox/components/rfid/hardware/rc522_spi/rc522_spi.py index d9853ec3d..bb995b084 100644 --- a/src/jukebox/components/rfid/hardware/rc522_spi/rc522_spi.py +++ b/src/jukebox/components/rfid/hardware/rc522_spi/rc522_spi.py @@ -1,7 +1,6 @@ # Standard imports from python packages import logging -import RPi.GPIO as GPIO import pirc522 import jukebox.cfghandler @@ -87,7 +86,7 @@ def __init__(self, reader_cfg_key): pin_rst=pin_rst, pin_irq=pin_irq, antenna_gain=antenna_gain, - pin_mode=GPIO.BCM) + pin_mode='BCM') def cleanup(self): self.device.cleanup() diff --git a/src/jukebox/components/rfid/hardware/rc522_spi/requirements.txt b/src/jukebox/components/rfid/hardware/rc522_spi/requirements.txt index 56b9ca4dc..e6508b4f8 100644 --- a/src/jukebox/components/rfid/hardware/rc522_spi/requirements.txt +++ b/src/jukebox/components/rfid/hardware/rc522_spi/requirements.txt @@ -1,6 +1,4 @@ # RC522 related requirements # You need to install these with `python -m pip install --upgrade --force-reinstall -q -r requirements.txt` -pi-rc522==2.3.0 - - +git+https://github.com/hoffie/pi-rc522-gpiozero@1dc878c diff --git a/src/jukebox/components/rfid/hardware/rdm6300_serial/setup.inc.sh b/src/jukebox/components/rfid/hardware/rdm6300_serial/setup.inc.sh index 364f07cd9..c9935c739 100755 --- a/src/jukebox/components/rfid/hardware/rdm6300_serial/setup.inc.sh +++ b/src/jukebox/components/rfid/hardware/rdm6300_serial/setup.inc.sh @@ -1,9 +1,17 @@ #!/usr/bin/env bash +source ../../../../../../installation/includes/02_helpers.sh + echo "Entering setup.inc.sh" echo "Disabling login shell to be accessible over serial" -sudo raspi-config nonint do_serial 1 + +if [ "$(is_debian_version_at_least 12)" = true ]; then + sudo raspi-config nonint do_serial_hw 1 + sudo raspi-config nonint do_serial_cons 1 +else + sudo raspi-config nonint do_serial 1 +end echo "Enabling serial port hardware" sudo raspi-config nonint set_config_var enable_uart 1 /boot/config.txt diff --git a/src/jukebox/components/rpc_command_alias.py b/src/jukebox/components/rpc_command_alias.py index f6e238559..5a7820733 100644 --- a/src/jukebox/components/rpc_command_alias.py +++ b/src/jukebox/components/rpc_command_alias.py @@ -75,6 +75,10 @@ 'method': 'repeat', 'note': 'Repeat', 'ignore_card_removal_action': True}, + 'flush_coverart_cache': { + 'package': 'player', + 'plugin': 'ctrl', + 'method': 'flush_coverart_cache'}, # VOLUME 'set_volume': { diff --git a/src/jukebox/components/timers/__init__.py b/src/jukebox/components/timers/__init__.py index bef7ccf81..4fe70aa80 100644 --- a/src/jukebox/components/timers/__init__.py +++ b/src/jukebox/components/timers/__init__.py @@ -1,10 +1,12 @@ # RPi-Jukebox-RFID Version 3 # Copyright (c) See file LICENSE in project root folder -from jukebox.multitimer import (GenericTimerClass, GenericMultiTimerClass) import logging import jukebox.cfghandler import jukebox.plugs as plugin +from jukebox.multitimer import GenericTimerClass +from .idle_shutdown_timer import IdleShutdownTimer +from .volume_fadeout_shutdown_timer import VolumeFadoutAndShutdown logger = logging.getLogger('jb.timers') @@ -24,35 +26,18 @@ def stop_player(): plugin.call_ignore_errors('player', 'ctrl', 'stop') -class VolumeFadeOutActionClass: - def __init__(self, iterations): - self.iterations = iterations - # Get the current volume, calculate step size - self.volume = plugin.call('volume', 'ctrl', 'get_volume') - self.step = float(self.volume) / iterations - - def __call__(self, iteration): - self.volume = self.volume - self.step - logger.debug(f"Decrease volume to {self.volume} (Iteration index {iteration}/{self.iterations}-1)") - plugin.call_ignore_errors('volume', 'ctrl', 'set_volume', args=[int(self.volume)]) - if iteration == 0: - logger.debug("Shut down from volume fade out") - plugin.call_ignore_errors('host', 'shutdown') - - # --------------------------------------------------------------------------- # Create the timers # --------------------------------------------------------------------------- timer_shutdown: GenericTimerClass timer_stop_player: GenericTimerClass -timer_fade_volume: GenericMultiTimerClass +timer_fade_volume: VolumeFadoutAndShutdown +timer_idle_shutdown: IdleShutdownTimer @plugin.finalize def finalize(): - # TODO: Example with how to call the timers from RPC? - - # Create the various timers with fitting doc for plugin reference + # Shutdown Timer global timer_shutdown timeout = cfg.setndefault('timers', 'shutdown', 'default_timeout_sec', value=60 * 60) timer_shutdown = GenericTimerClass(f"{plugin.loaded_as(__name__)}.timer_shutdown", @@ -62,6 +47,7 @@ def finalize(): # auto-registration would register it with that module. Manually set package to this plugin module plugin.register(timer_shutdown, name='timer_shutdown', package=plugin.loaded_as(__name__)) + # Stop Playback Timer global timer_stop_player timeout = cfg.setndefault('timers', 'stop_player', 'default_timeout_sec', value=60 * 60) timer_stop_player = GenericTimerClass(f"{plugin.loaded_as(__name__)}.timer_stop_player", @@ -69,14 +55,18 @@ def finalize(): timer_stop_player.__doc__ = "Timer for automatic player stop" plugin.register(timer_stop_player, name='timer_stop_player', package=plugin.loaded_as(__name__)) - global timer_fade_volume - timeout = cfg.setndefault('timers', 'volume_fade_out', 'default_time_per_iteration_sec', value=15 * 60) - steps = cfg.setndefault('timers', 'volume_fade_out', 'number_of_steps', value=10) - timer_fade_volume = GenericMultiTimerClass(f"{plugin.loaded_as(__name__)}.timer_fade_volume", - steps, timeout, VolumeFadeOutActionClass) - timer_fade_volume.__doc__ = "Timer step-wise volume fade out and shutdown" + # Volume Fadeout and Shutdown Timer + timer_fade_volume = VolumeFadoutAndShutdown( + name=f"{plugin.loaded_as(__name__)}.timer_fade_volume" + ) plugin.register(timer_fade_volume, name='timer_fade_volume', package=plugin.loaded_as(__name__)) + # Idle Timer + global timer_idle_shutdown + idle_timeout = cfg.setndefault('timers', 'idle_shutdown', 'timeout_sec', value=0) + timer_idle_shutdown = IdleShutdownTimer(package=plugin.loaded_as(__name__), idle_timeout=idle_timeout) + plugin.register(timer_idle_shutdown, name='timer_idle_shutdown', package=plugin.loaded_as(__name__)) + # The idle Timer does work in a little sneaky way # Idle is when there are no calls through the plugin module # Ahh, but also when music is playing this is not idle... @@ -101,4 +91,15 @@ def atexit(**ignored_kwargs): timer_stop_player.cancel() global timer_fade_volume timer_fade_volume.cancel() - return [timer_shutdown.timer_thread, timer_stop_player.timer_thread, timer_fade_volume.timer_thread] + global timer_idle_shutdown + timer_idle_shutdown.cancel() + global timer_idle_check + timer_idle_check.cancel() + ret = [ + timer_shutdown.timer_thread, + timer_stop_player.timer_thread, + timer_fade_volume.timer_thread, + timer_idle_shutdown.timer_thread, + timer_idle_check.timer_thread + ] + return ret diff --git a/src/jukebox/components/timers/idle_shutdown_timer.py b/src/jukebox/components/timers/idle_shutdown_timer.py new file mode 100644 index 000000000..d1881b522 --- /dev/null +++ b/src/jukebox/components/timers/idle_shutdown_timer.py @@ -0,0 +1,194 @@ +# RPi-Jukebox-RFID Version 3 +# Copyright (c) See file LICENSE in project root folder + +import os +import re +import logging +import jukebox.cfghandler +import jukebox.plugs as plugin +from jukebox.multitimer import (GenericEndlessTimerClass, GenericMultiTimerClass) + + +logger = logging.getLogger('jb.timers.idle_shutdown_timer') +cfg = jukebox.cfghandler.get_handler('jukebox') + +SSH_CHILD_RE = re.compile(r'sshd: [^/].*') +PATHS = ['shared/settings', + 'shared/audiofolders'] + +IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS = 60 +EXTEND_IDLE_TIMEOUT = 60 +IDLE_CHECK_INTERVAL = 10 + + +def get_seconds_since_boot(): + # We may not have a stable clock source when there is no network + # connectivity (yet). As we only need to measure the relative time which + # has passed, we can just calculate based on the seconds since boot. + with open('/proc/uptime') as f: + line = f.read() + seconds_since_boot, _ = line.split(' ', 1) + return float(seconds_since_boot) + + +class IdleShutdownTimer: + def __init__(self, package: str, idle_timeout: int) -> None: + self.private_timer_idle_shutdown = None + self.private_timer_idle_check = None + self.idle_timeout = 0 + self.package = package + self.idle_check_interval = IDLE_CHECK_INTERVAL + + self.set_idle_timeout(idle_timeout) + self.init_idle_shutdown() + self.init_idle_check() + + def set_idle_timeout(self, idle_timeout): + try: + self.idle_timeout = int(idle_timeout) + except ValueError: + logger.warning(f'invalid timers.idle_shutdown.timeout_sec value {repr(idle_timeout)}') + + if self.idle_timeout < IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS: + logger.info('disabling idle shutdown timer; set ' + 'timers.idle_shutdown.timeout_sec to at least ' + f'{IDLE_SHUTDOWN_TIMER_MIN_TIMEOUT_SECONDS} seconds to enable') + self.idle_timeout = 0 + + # Using GenericMultiTimerClass instead of GenericTimerClass as it supports classes rather than functions + # Calling GenericMultiTimerClass with iterations=1 is the same as GenericTimerClass + def init_idle_shutdown(self): + self.private_timer_idle_shutdown = GenericMultiTimerClass( + name=f"{self.package}.private_timer_idle_shutdown", + iterations=1, + wait_seconds_per_iteration=self.idle_timeout, + callee=IdleShutdown + ) + self.private_timer_idle_shutdown.__doc__ = "Timer to shutdown after system is idle for a given time" + plugin.register(self.private_timer_idle_shutdown, name='private_timer_idle_shutdown', package=self.package) + + # Regularly check if player has activity, if not private_timer_idle_check will start/cancel private_timer_idle_shutdown + def init_idle_check(self): + idle_check_timer_instance = IdleCheck() + self.private_timer_idle_check = GenericEndlessTimerClass( + name=f"{self.package}.private_timer_idle_check", + wait_seconds_per_iteration=self.idle_check_interval, + function=idle_check_timer_instance + ) + self.private_timer_idle_check.__doc__ = 'Timer to check if system is idle' + if self.idle_timeout: + self.private_timer_idle_check.start() + + plugin.register(self.private_timer_idle_check, name='private_timer_idle_check', package=self.package) + + @plugin.tag + def start(self, wait_seconds: int): + """Sets idle_shutdown timeout_sec stored in jukebox.yaml""" + cfg.setn('timers', 'idle_shutdown', 'timeout_sec', value=wait_seconds) + plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'start') + + @plugin.tag + def cancel(self): + """Cancels all idle timers and disables idle shutdown in jukebox.yaml""" + plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'cancel') + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel') + cfg.setn('timers', 'idle_shutdown', 'timeout_sec', value=0) + + @plugin.tag + def get_state(self): + """Returns the current state of Idle Shutdown""" + idle_check_state = plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'get_state') + idle_shutdown_state = plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'get_state') + + return { + 'enabled': idle_check_state['enabled'], + 'running': idle_shutdown_state['enabled'], + 'remaining_seconds': idle_shutdown_state['remaining_seconds'], + 'wait_seconds': idle_shutdown_state['wait_seconds_per_iteration'], + } + + +class IdleCheck: + def __init__(self) -> None: + self.last_player_status = plugin.call('player', 'ctrl', 'playerstatus') + logger.debug('Started IdleCheck with initial state: {}'.format(self.last_player_status)) + + # Run function + def __call__(self): + player_status = plugin.call('player', 'ctrl', 'playerstatus') + + if self.last_player_status == player_status: + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'start') + else: + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel') + + self.last_player_status = player_status.copy() + return self.last_player_status + + +class IdleShutdown(): + files_num_entries: int = 0 + files_latest_mtime: float = 0 + + def __init__(self) -> None: + self.base_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', '..') + + def __call__(self): + logger.debug('Last checks before shutting down') + if self._has_active_ssh_sessions(): + logger.debug('Active SSH sessions found, will not shutdown now') + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'set_timeout', args=[int(EXTEND_IDLE_TIMEOUT)]) + return + # if self._has_changed_files(): + # logger.debug('Changes files found, will not shutdown now') + # plugin.call_ignore_errors( + # 'timers', + # 'private_timer_idle_shutdown', + # 'set_timeout', + # args=[int(EXTEND_IDLE_TIMEOUT)]) + # return + + logger.info('No activity, shutting down') + plugin.call_ignore_errors('timers', 'private_timer_idle_check', 'cancel') + plugin.call_ignore_errors('timers', 'private_timer_idle_shutdown', 'cancel') + plugin.call_ignore_errors('host', 'shutdown') + + @staticmethod + def _has_active_ssh_sessions(): + logger.debug('Checking for SSH activity') + with os.scandir('/proc') as proc_dir: + for proc_path in proc_dir: + if not proc_path.is_dir(): + continue + try: + with open(os.path.join(proc_path, 'cmdline')) as f: + cmdline = f.read() + except (FileNotFoundError, PermissionError): + continue + if SSH_CHILD_RE.match(cmdline): + return True + + def _has_changed_files(self): + # This is a rather expensive check, but it only runs twice + # when an idle shutdown is initiated. + # Only when there are actual changes (file transfers via + # SFTP, Samba, etc.), the check may run multiple times. + logger.debug('Scanning for file changes') + latest_mtime = 0 + num_entries = 0 + for path in PATHS: + for root, dirs, files in os.walk(os.path.join(self.base_path, path)): + for p in dirs + files: + mtime = os.stat(os.path.join(root, p)).st_mtime + latest_mtime = max(latest_mtime, mtime) + num_entries += 1 + + logger.debug(f'Completed file scan ({num_entries} entries, latest_mtime={latest_mtime})') + if self.files_latest_mtime != latest_mtime or self.files_num_entries != num_entries: + # We compare the number of entries to have a chance to detect file + # deletions as well. + self.files_latest_mtime = latest_mtime + self.files_num_entries = num_entries + return True + + return False diff --git a/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py b/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py new file mode 100644 index 000000000..550378d17 --- /dev/null +++ b/src/jukebox/components/timers/volume_fadeout_shutdown_timer.py @@ -0,0 +1,206 @@ +import logging +import time +import jukebox.cfghandler +import jukebox.plugs as plugin +from jukebox.multitimer import GenericTimerClass, GenericMultiTimerClass + + +logger = logging.getLogger('jb.timers') +cfg = jukebox.cfghandler.get_handler('jukebox') + + +class VolumeFadeoutError(Exception): + """Custom exception for volume fadeout errors""" + pass + + +class VolumeFadeoutAction: + """Handles the actual volume fade out actions""" + def __init__(self, start_volume): + self.start_volume = start_volume + # Use 12 steps for 2 minutes = 10 seconds per step + self.iterations = 12 + self.volume_step = start_volume / (self.iterations - 1) + logger.debug(f"Initialized fadeout from volume {start_volume}") + + def __call__(self, iteration, *args, **kwargs): + """Called for each timer iteration""" + # Calculate target volume for this step + target_volume = max(0, int(self.start_volume - (self.iterations - iteration - 1) * self.volume_step)) + logger.debug(f"Fading volume to {target_volume} (Step {self.iterations - iteration}/{self.iterations})") + plugin.call_ignore_errors('volume', 'ctrl', 'set_volume', args=[target_volume]) + + +class VolumeFadoutAndShutdown: + """Timer system that gracefully fades out volume before shutdown. + + This timer manages three coordinated timers for a smooth shutdown sequence: + 1. Main shutdown timer: Runs for the full duration and triggers the final shutdown + 2. Fadeout start timer: Triggers the volume fadeout 2 minutes before shutdown + 3. Volume fadeout timer: Handles the actual volume reduction in the last 2 minutes + + Example for a 5-minute (300s) timer: + - t=0s: Shutdown timer starts (300s) + Fadeout start timer starts (180s) + - t=180s: Fadeout start timer triggers volume reduction + Volume fadeout begins (12 steps over 120s) + - t=300s: Shutdown timer triggers system shutdown + + The fadeout always takes 2 minutes (120s), regardless of the total timer duration. + The minimum total duration is 2 minutes to accommodate the fadeout period. + All timers can be cancelled together using the cancel() method. + """ + + MIN_TOTAL_DURATION = 120 # 2 minutes minimum + FADEOUT_DURATION = 120 # Last 2 minutes for fadeout + + def __init__(self, name): + self.name = name + self.default_timeout = cfg.setndefault('timers', 'volume_fadeout', 'default_timeout_sec', value=600) + + self.shutdown_timer = None + self.fadeout_start_timer = None + self.fadeout_timer = None + + self._reset_state() + + def _reset_state(self): + """Reset internal state variables""" + self.start_time = None + self.total_duration = None + self.current_volume = None + self.fadeout_started = False + + def _start_fadeout(self): + """Callback for fadeout_start_timer - initiates the volume fadeout""" + logger.info("Starting volume fadeout sequence") + self.fadeout_started = True + + # Get current volume at start of fadeout + self.current_volume = plugin.call('volume', 'ctrl', 'get_volume') + if self.current_volume <= 0: + logger.warning("Volume already at 0, waiting for shutdown") + return + + # Start the fadeout timer + self.fadeout_timer = GenericMultiTimerClass( + name=f"{self.name}_fadeout", + iterations=12, # 12 steps over 2 minutes = 10 seconds per step + wait_seconds_per_iteration=10, + callee=lambda iterations: VolumeFadeoutAction(self.current_volume) + ) + self.fadeout_timer.start() + + def _shutdown(self): + """Callback for shutdown_timer - performs the actual shutdown""" + logger.info("Timer complete, initiating shutdown") + plugin.call_ignore_errors('host', 'shutdown') + + @plugin.tag + def start(self, wait_seconds=None): + """Start the coordinated timer system + + Args: + wait_seconds (float): Total duration until shutdown (optional) + + Raises: + VolumeFadeoutError: If duration too short + """ + # Cancel any existing timers + self.cancel() + + # Use provided duration or default + duration = wait_seconds if wait_seconds is not None else self.default_timeout + + # Validate duration + if duration < self.MIN_TOTAL_DURATION: + raise VolumeFadeoutError(f"Duration must be at least {self.MIN_TOTAL_DURATION} seconds") + + self.start_time = time.time() + self.total_duration = duration + + # Start the main shutdown timer + self.shutdown_timer = GenericTimerClass( + name=f"{self.name}_shutdown", + wait_seconds=duration, + function=self._shutdown + ) + + # Start the fadeout start timer + fadeout_start_time = duration - self.FADEOUT_DURATION + self.fadeout_start_timer = GenericTimerClass( + name=f"{self.name}_fadeout_start", + wait_seconds=fadeout_start_time, + function=self._start_fadeout + ) + + logger.info( + f"Starting timer system: {fadeout_start_time}s until fadeout starts, " + f"total duration {duration}s" + ) + + self.shutdown_timer.start() + self.fadeout_start_timer.start() + + @plugin.tag + def cancel(self): + """Cancel all active timers""" + if self.shutdown_timer and self.shutdown_timer.is_alive(): + logger.info("Cancelling shutdown timer") + self.shutdown_timer.cancel() + + if self.fadeout_start_timer and self.fadeout_start_timer.is_alive(): + logger.info("Cancelling fadeout start timer") + self.fadeout_start_timer.cancel() + + if self.fadeout_timer and self.fadeout_timer.is_alive(): + logger.info("Cancelling volume fadeout") + self.fadeout_timer.cancel() + + self._reset_state() + + @plugin.tag + def is_alive(self): + """Check if any timer is currently active""" + return ( + (self.shutdown_timer and self.shutdown_timer.is_alive()) + or (self.fadeout_start_timer and self.fadeout_start_timer.is_alive()) + or (self.fadeout_timer and self.fadeout_timer.is_alive()) + ) + + @plugin.tag + def get_state(self): + """Get the current state of the timer system""" + if not self.is_alive() or not self.start_time: + return { + 'enabled': False, + 'type': 'VolumeFadoutAndShutdown', + 'total_duration': None, + 'remaining_seconds': 0, + 'progress_percent': 0, + 'error': None + } + + # Use the main shutdown timer for overall progress + elapsed = time.time() - self.start_time + remaining = max(0, self.total_duration - elapsed) + progress = min(100, (elapsed / self.total_duration) * 100 if self.total_duration else 0) + + return { + 'enabled': True, + 'type': 'VolumeFadoutAndShutdown', + 'total_duration': self.total_duration, + 'remaining_seconds': remaining, + 'progress_percent': progress, + 'fadeout_started': self.fadeout_started, + 'error': None + } + + @plugin.tag + def get_config(self): + """Get the current configuration""" + return { + 'default_timeout': self.default_timeout, + 'min_duration': self.MIN_TOTAL_DURATION, + 'fadeout_duration': self.FADEOUT_DURATION + } diff --git a/src/jukebox/components/volume/__init__.py b/src/jukebox/components/volume/__init__.py index 9dd827e4a..b99e94616 100644 --- a/src/jukebox/components/volume/__init__.py +++ b/src/jukebox/components/volume/__init__.py @@ -424,7 +424,7 @@ def _publish_outputs(self, pulse_inst: pulsectl.Pulse): def _set_output(self, pulse_inst: pulsectl.Pulse, sink_index: int): error_state = 1 if not 0 <= sink_index < len(self._sink_list): - logger.error(f"Sink index '{sink_index}' out of range (0..{len(self._sink_list)-1}). " + logger.error(f"Sink index '{sink_index}' out of range (0..{len(self._sink_list) - 1}). " f"Did you configure your secondary output device?") else: # Before we switch the sink, check the new sinks volume levels... diff --git a/src/jukebox/jukebox/daemon.py b/src/jukebox/jukebox/daemon.py index e847428e2..1334e89f7 100755 --- a/src/jukebox/jukebox/daemon.py +++ b/src/jukebox/jukebox/daemon.py @@ -70,7 +70,7 @@ def signal_handler(self, esignal, frame): # systemd: By default, a SIGTERM is sent, followed by 90 seconds of waiting followed by a SIGKILL. # Pressing Ctrl-C gives SIGINT self._signal_cnt += 1 - timeout: float = 10.0 + timeout: float = 5.0 time_start = time.time_ns() msg = f"Received signal '{signal.Signals(esignal).name}'. Count = {self._signal_cnt}" print(msg) diff --git a/src/jukebox/jukebox/multitimer.py b/src/jukebox/jukebox/multitimer.py index b03aae83f..facb2cce8 100644 --- a/src/jukebox/jukebox/multitimer.py +++ b/src/jukebox/jukebox/multitimer.py @@ -1,11 +1,28 @@ -# RPi-Jukebox-RFID Version 3 -# Copyright (c) See file LICENSE in project root folder +"""MultiTimer Module -"""Multitimer Module""" +This module provides timer functionality with support for single, multiple, and endless iterations. +It includes three main timer classes: +- MultiTimer: The base timer implementation using threading +- GenericTimerClass: A single-event timer with plugin/RPC support +- GenericEndlessTimerClass: An endless repeating timer +- GenericMultiTimerClass: A multi-iteration timer with callback builder support + +Example usage: + # Single event timer + timer = GenericTimerClass("my_timer", 5.0, my_function) + timer.start() + + # Endless timer + endless_timer = GenericEndlessTimerClass("endless", 1.0, update_function) + endless_timer.start() + + # Multi-iteration timer + multi_timer = GenericMultiTimerClass("counter", 5, 1.0, CounterCallback) + multi_timer.start() +""" import threading -from typing import ( - Callable) +from typing import Callable, Optional, Any, Dict import logging import jukebox.cfghandler import jukebox.plugs as plugin @@ -18,18 +35,40 @@ class MultiTimer(threading.Thread): - """Call a function after a specified number of seconds, repeat that iteration times + """A threaded timer that calls a function after specified intervals. - May be cancelled during any of the wait times. - Function is called with keyword parameter 'iteration' (which decreases down to 0 for the last iteration) + This timer supports both limited iterations and endless execution modes. + In limited iteration mode, it counts down from iterations-1 to 0. + In endless mode (iterations < 0), it runs indefinitely until cancelled. - If iterations is negative, an endlessly repeating timer is created (which needs to be cancelled with cancel()) + The timer can be cancelled at any time using the cancel() method. - Initiates start and publishing by calling self.publish_callback + Attributes: + interval (float): Time in seconds between function calls + iterations (int): Number of times to call the function. Use negative for endless mode + function (Callable): Function to call on each iteration + args (list): Positional arguments to pass to the function + kwargs (dict): Keyword arguments to pass to the function + publish_callback (Optional[Callable]): Function to call on timer start/stop for state publishing - Note: Inspired by threading.Timer and generally using the same API""" + Example: + def my_func(iteration, x, y=10): + print(f"Iteration {iteration}: {x} + {y}") - def __init__(self, interval, iterations, function: Callable, args=None, kwargs=None): + timer = MultiTimer(2.0, 5, my_func, args=[5], kwargs={'y': 20}) + timer.start() + """ + + def __init__(self, interval: float, iterations: int, function: Callable, args=None, kwargs=None): + """Initialize the timer. + + Args: + interval: Seconds between function calls + iterations: Number of iterations (-1 for endless) + function: Function to call each iteration + args: Positional arguments for function + kwargs: Keyword arguments for function + """ super().__init__() self.interval = interval self.iterations = iterations @@ -43,14 +82,19 @@ def __init__(self, interval, iterations, function: Callable, args=None, kwargs=N def cancel(self): """Stop the timer if it hasn't finished all iterations yet.""" logger.debug(f"Cancel timer '{self.name}.") - # Assignment to _cmd_cancel is atomic -> OK for threads self._cmd_cancel = True self.event.set() def trigger(self): + """Trigger the next function call immediately.""" self.event.set() def run_endless(self): + """Run the timer in endless mode. + + The function is called every interval seconds with iteration=-1 + until cancelled. + """ while True: self.event.wait(self.interval) if self.event.is_set(): @@ -58,10 +102,14 @@ def run_endless(self): break else: self.event.clear() - # logger.debug(f"Execute timer action of '{self.name}'.") self.function(iteration=-1, *self.args, **self.kwargs) def run_limited(self): + """Run the timer for a limited number of iterations. + + The function is called every interval seconds with iteration + counting down from iterations-1 to 0. + """ for iteration in range(self.iterations - 1, -1, -1): self.event.wait(self.interval) if self.event.is_set(): @@ -69,10 +117,15 @@ def run_limited(self): break else: self.event.clear() - # logger.debug(f"Execute timer action #{iteration} of '{self.name}'.") self.function(*self.args, iteration=iteration, **self.kwargs) def run(self): + """Start the timer execution. + + This is called automatically when start() is called. + The timer runs in either endless or limited mode based on + the iterations parameter. + """ if self.publish_callback is not None: self.publish_callback() if self.iterations < 0: @@ -88,15 +141,43 @@ def run(self): class GenericTimerClass: + """A single-event timer with plugin/RPC support. + + This class provides a high-level interface for creating and managing + single-execution timers. It includes support for: + - Starting/stopping/toggling the timer + - Publishing timer state + - Getting remaining time + - Adjusting timeout duration + + The timer automatically handles the 'iteration' parameter internally, + so callback functions don't need to handle it. + + Attributes: + name (str): Identifier for the timer + _wait_seconds (float): Interval between function calls + _function (Callable): Wrapped function to call + _iterations (int): Number of iterations (1 for single-event) + + Example: + def update_display(message): + print(message) + + timer = GenericTimerClass("display_timer", 5.0, update_display, + args=["Hello World"]) + timer.start() """ - Interface for plugin / RPC accessibility for a single event timer - """ - def __init__(self, name, wait_seconds: float, function, args=None, kwargs=None): - """ - :param wait_seconds: The time in seconds to wait before calling function - :param function: The function to call with args and kwargs. - :param args: Parameters for function call - :param kwargs: Parameters for function call + + def __init__(self, name: str, wait_seconds: float, function: Callable, + args: Optional[list] = None, kwargs: Optional[dict] = None): + """Initialize the timer. + + Args: + name: Timer identifier + wait_seconds: Time to wait before function call + function: Function to call + args: Positional arguments for function + kwargs: Keyword arguments for function """ self.timer_thread = None self.args = args if args is not None else [] @@ -104,21 +185,25 @@ def __init__(self, name, wait_seconds: float, function, args=None, kwargs=None): self._wait_seconds = wait_seconds self._start_time = 0 # Hide away the argument 'iteration' that is passed from MultiTimer to function - # for a single event Timer (and also endless timers, as the inherit from here) self._function = lambda iteration, *largs, **lkwargs: function(*largs, **lkwargs) self._iterations = 1 self._name = name self._publish_core() @plugin.tag - def start(self, wait_seconds=None): - """Start the timer (with default or new parameters)""" + def start(self, wait_seconds: Optional[float] = None): + """Start the timer with optional new wait time. + + Args: + wait_seconds: Optional new interval to use + """ if self.is_alive(): - logger.error(f"Timer '{self._name}' started! Ignoring start command.") + logger.info(f"Timer '{self._name}' started! Ignoring start command.") return if wait_seconds is not None: self._wait_seconds = wait_seconds - self.timer_thread = MultiTimer(self._wait_seconds, self._iterations, self._function, *self.args, **self.kwargs) + self.timer_thread = MultiTimer(self._wait_seconds, self._iterations, + self._function, self.args, self.kwargs) self.timer_thread.daemon = True self.timer_thread.publish_callback = self._publish_core if self._name is not None: @@ -128,13 +213,13 @@ def start(self, wait_seconds=None): @plugin.tag def cancel(self): - """Cancel the timer""" + """Cancel the timer if it's running.""" if self.is_alive(): self.timer_thread.cancel() @plugin.tag def toggle(self): - """Toggle the activation of the timer""" + """Toggle between started and stopped states.""" if self.is_alive(): self.timer_thread.cancel() else: @@ -142,27 +227,42 @@ def toggle(self): @plugin.tag def trigger(self): - """Trigger the next target execution before the time is up""" + """Trigger the function call immediately.""" if self.is_alive(): self.timer_thread.trigger() @plugin.tag - def is_alive(self): - """Check if timer is active""" + def is_alive(self) -> bool: + """Check if timer is currently running. + + Returns: + bool: True if timer is active, False otherwise + """ if self.timer_thread is None: return False return self.timer_thread.is_alive() @plugin.tag - def get_timeout(self): - """Get the configured time-out + def get_timeout(self) -> float: + """Get the configured timeout interval. - :return: The total wait time. (Not the remaining wait time!)""" + Returns: + float: The wait time in seconds + """ return self._wait_seconds @plugin.tag - def set_timeout(self, wait_seconds: float): - """Set a new time-out in seconds. Re-starts the timer if already running!""" + def set_timeout(self, wait_seconds: float) -> float: + """Set a new timeout interval. + + If the timer is running, it will be restarted with the new interval. + + Args: + wait_seconds: New interval in seconds + + Returns: + float: The new wait time + """ self._wait_seconds = wait_seconds if self.is_alive(): self.cancel() @@ -173,85 +273,181 @@ def set_timeout(self, wait_seconds: float): @plugin.tag def publish(self): - """Publish the current state and config""" + """Publish current timer state.""" self._publish_core() @plugin.tag - def get_state(self): - """Get the current state and config as dictionary""" + def get_state(self) -> Dict[str, Any]: + """Get the current timer state. + + Returns: + dict: Timer state including: + - enabled: Whether timer is running + - remaining_seconds: Time until next function call + - wait_seconds: Configured interval + - type: Timer class name + """ remaining_seconds = max( 0, self.get_timeout() - (int(time()) - self._start_time) ) - return {'enabled': self.is_alive(), - 'remaining_seconds': remaining_seconds, - 'wait_seconds': self.get_timeout(), - 'type': 'GenericTimerClass'} + return { + 'enabled': self.is_alive(), + 'remaining_seconds': remaining_seconds, + 'wait_seconds': self.get_timeout(), + 'type': 'GenericTimerClass' + } - def _publish_core(self, enabled=None): - """Internal publish function with override for enabled + def _publish_core(self, enabled: Optional[bool] = None): + """Internal method to publish timer state. - Enable override is required as this is called from inside the timer when it finishes - This means the timer is still running, but it is the last thing it does. - Otherwise it is not possible to detect the timer change at the end""" + Args: + enabled: Override for enabled state + """ if self._name is not None: state = self.get_state() if enabled is not None: state['enabled'] = enabled logger.debug(f"{self._name}: State = {state}") - # This function may be called from different threads, - # so always freshly get the correct publisher instance publishing.get_publisher().send(self._name, state) class GenericEndlessTimerClass(GenericTimerClass): + """An endless repeating timer. + + This timer runs indefinitely until explicitly cancelled. + It inherits all functionality from GenericTimerClass but + sets iterations to -1 for endless mode. + + Example: + def heartbeat(): + print("Ping") + + timer = GenericEndlessTimerClass("heartbeat", 1.0, heartbeat) + timer.start() """ - Interface for plugin / RPC accessibility for an event timer call function endlessly every m seconds - """ - def __init__(self, name, wait_seconds_per_iteration: float, function, args=None, kwargs=None): - # Remove the necessity for the 'iterations' keyword that is added by GenericTimerClass + + def __init__(self, name: str, wait_seconds_per_iteration: float, + function: Callable, args=None, kwargs=None): + """Initialize endless timer. + + Args: + name: Timer identifier + wait_seconds_per_iteration: Interval between calls + function: Function to call repeatedly + args: Positional arguments for function + kwargs: Keyword arguments for function + """ super().__init__(name, wait_seconds_per_iteration, function, args, kwargs) # Negative iteration count causes endless looping self._iterations = -1 - def get_state(self): - return {'enabled': self.is_alive(), - 'wait_seconds_per_iteration': self.get_timeout(), - 'type': 'GenericEndlessTimerClass'} + @plugin.tag + def get_state(self) -> Dict[str, Any]: + """Get current timer state. + + Returns: + dict: Timer state including: + - enabled: Whether timer is running + - wait_seconds_per_iteration: Interval between calls + - type: Timer class name + """ + return { + 'enabled': self.is_alive(), + 'wait_seconds_per_iteration': self.get_timeout(), + 'type': 'GenericEndlessTimerClass' + } class GenericMultiTimerClass(GenericTimerClass): + """A multi-iteration timer with callback builder support. + + This timer executes a specified number of iterations with a callback + that's created for each full cycle. It's useful when you need stateful + callbacks or complex iteration handling. + + The callee parameter should be a class or function that: + 1. Takes iterations as a parameter during construction + 2. Returns a callable that accepts an iteration parameter + + Example: + class CountdownCallback: + def __init__(self, iterations): + self.total = iterations + + def __call__(self, iteration): + print(f"{iteration} of {self.total} remaining") + + timer = GenericMultiTimerClass("countdown", 5, 1.0, CountdownCallback) + timer.start() """ - Interface for plugin / RPC accessibility for an event timer that performs an action n times every m seconds - """ - def __init__(self, name, iterations: int, wait_seconds_per_iteration: float, callee, args=None, kwargs=None): - """ - :param iterations: Number of times callee is called - :param wait_seconds_per_iteration: Wait in seconds before each iteration - :param callee: A builder class that gets instantiated once as callee(*args, iterations=iterations, **kwargs). - Then with every time out iteration __call__(*args, iteration=iteration, **kwargs) is called. - 'iteration' is the current iteration count in decreasing order! - :param args: - :param kwargs: + + def __init__(self, name: str, iterations: int, wait_seconds_per_iteration: float, + callee: Callable, args=None, kwargs=None): + """Initialize multi-iteration timer. + + Args: + name: Timer identifier + iterations: Total number of iterations + wait_seconds_per_iteration: Interval between calls + callee: Callback builder class/function + args: Positional arguments for callee + kwargs: Keyword arguments for callee """ - super().__init__(name, wait_seconds_per_iteration, None, None, None) + # Initialize with a placeholder function - we'll set the real one in start() + super().__init__(name, wait_seconds_per_iteration, lambda: None, None, None) self.class_args = args if args is not None else [] self.class_kwargs = kwargs if kwargs is not None else {} self._iterations = iterations self._callee = callee @plugin.tag - def start(self, iterations=None, wait_seconds_per_iteration=None): - """Start the timer (with default or new parameters)""" + def start(self, iterations: Optional[int] = None, + wait_seconds_per_iteration: Optional[float] = None): + """Start the timer with optional new parameters. + + Args: + iterations: Optional new iteration count + wait_seconds_per_iteration: Optional new interval + """ if iterations is not None: self._iterations = iterations - self._function = self._callee(*self.class_args, iterations=self._iterations, **self.class_kwargs) + + def create_callback(): + instance = self._callee(*self.class_args, iterations=self._iterations, + **self.class_kwargs) + return lambda iteration, *args, **kwargs: instance(*args, + iteration=iteration, + **kwargs) + + self._function = create_callback() super().start(wait_seconds_per_iteration) @plugin.tag - def get_state(self): - return {'enabled': self.is_alive(), - 'wait_seconds_per_iteration': self.get_timeout(), - 'iterations': self._iterations, - 'type': 'GenericMultiTimerClass'} + def get_state(self) -> Dict[str, Any]: + """Get current timer state. + + Returns: + dict: Timer state including: + - enabled: Whether timer is running + - wait_seconds_per_iteration: Interval between calls + - remaining_seconds_current_iteration: Time until next call + - remaining_seconds: Total time remaining + - iterations: Total iteration count + - type: Timer class name + """ + remaining_seconds_current_iteration = max( + 0, + self.get_timeout() - (int(time()) - self._start_time) + ) + remaining_seconds = (self.get_timeout() * self._iterations + remaining_seconds_current_iteration) + + return { + 'enabled': self.is_alive(), + 'wait_seconds_per_iteration': self.get_timeout(), + 'remaining_seconds_current_iteration': remaining_seconds_current_iteration, + 'remaining_seconds': remaining_seconds, + 'iterations': self._iterations, + 'type': 'GenericMultiTimerClass' + } diff --git a/src/jukebox/jukebox/playlistgenerator.py b/src/jukebox/jukebox/playlistgenerator.py index b9f0223c6..e7f5b50d3 100755 --- a/src/jukebox/jukebox/playlistgenerator.py +++ b/src/jukebox/jukebox/playlistgenerator.py @@ -275,7 +275,12 @@ def get_directory_content(self, path='.'): logger.error(f" {e.__class__.__name__}: {e}") else: for m in content: - self.playlist.append({'type': TYPE_DECODE[m.filetype], 'name': m.name, 'path': m.path}) + self.playlist.append({ + 'type': TYPE_DECODE[m.filetype], + 'name': m.name, + 'path': m.path, + 'relpath': os.path.relpath(m.path, self._music_library_base_path) + }) def _parse_nonrecusive(self, path='.'): return [x.path for x in self._get_directory_content(path) if x.filetype != TYPE_DIR] @@ -294,7 +299,7 @@ def _parse_recursive(self, path='.'): return recursive_playlist def parse(self, path='.', recursive=False): - """Parse the folder ``path`` and create a playlist from it's content + """Parse the folder ``path`` and create a playlist from its content :param path: Path to folder **relative** to ``music_library_base_path`` :param recursive: Parse folder recursivley, or stay in top-level folder diff --git a/src/jukebox/jukebox/utils.py b/src/jukebox/jukebox/utils.py index dbd647490..4cc0270ae 100644 --- a/src/jukebox/jukebox/utils.py +++ b/src/jukebox/jukebox/utils.py @@ -183,6 +183,19 @@ def rpc_call_to_str(cfg_rpc_call: Dict, with_args=True) -> str: return readable +def get_config_action(cfg, section, option, default, valid_actions_dict, logger): + """ + Looks up the given {section}.{option} config option and returns + the associated entry from valid_actions_dict, if valid. Falls back to the given + default otherwise. + """ + action = cfg.setndefault(section, option, value='').lower() + if action not in valid_actions_dict: + logger.error(f"Config {section}.{option} must be one of {valid_actions_dict.keys()}. Using default '{default}'") + action = default + return valid_actions_dict[action] + + def indent(doc, spaces=4): lines = doc.split('\n') for i in range(0, len(lines)): diff --git a/src/jukebox/jukebox/version.py b/src/jukebox/jukebox/version.py index d62ef3e93..a7f37c5e6 100644 --- a/src/jukebox/jukebox/version.py +++ b/src/jukebox/jukebox/version.py @@ -1,8 +1,8 @@ VERSION_MAJOR = 3 -VERSION_MINOR = 5 -VERSION_PATCH = 3 -VERSION_EXTRA = "" +VERSION_MINOR = 6 +VERSION_PATCH = 0 +VERSION_EXTRA = "alpha" # build a version string in compliance with the SemVer specification # https://semver.org/#semantic-versioning-specification-semver diff --git a/src/jukebox/run_configure_audio.py b/src/jukebox/run_configure_audio.py index 93f0a4c6a..191a25e5d 100644 --- a/src/jukebox/run_configure_audio.py +++ b/src/jukebox/run_configure_audio.py @@ -192,7 +192,7 @@ def query_sinks(pulse_config: PaConfigClass): # noqa: C901 if sink_is_equalizer(primary_signal_chain[sidx - 1]): pulse_config.enable_equalizer = False print(f"\n*** Equalizer already configured for '{pulse_config.primary}' with name\n" - f" '{primary_signal_chain[sidx-1].name}'. Shifting entry point...") + f" '{primary_signal_chain[sidx - 1].name}'. Shifting entry point...") pulse_config.primary = primary_signal_chain[sidx - 1].name sidx -= 1 except ValueError: diff --git a/src/webapp/package-lock.json b/src/webapp/package-lock.json index 814090555..218e67ab7 100644 --- a/src/webapp/package-lock.json +++ b/src/webapp/package-lock.json @@ -28,6 +28,9 @@ "react-scripts": "^5.0.1", "url": "^0.11.3", "uuid": "^9.0.1" + }, + "devDependencies": { + "markdownlint-cli2": "^0.12.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -4042,6 +4045,18 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" }, + "node_modules/@sindresorhus/merge-streams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-1.0.0.tgz", + "integrity": "sha512-rUV5WyJrJLoloD4NDN1V1+LDMDWOa4OTsT4yYJwQNpTU6FWxkxHpL7eu4w+DmiH8x/EAM1otkPE1+LaspIbplw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "1.8.6", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", @@ -12284,6 +12299,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -12525,6 +12546,15 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -12663,11 +12693,165 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.0.0.tgz", + "integrity": "sha512-seFjF0FIcPt4P9U39Bq1JYblX0KZCjDLFFQPHpL5AzHpqPEKtosxmdq/LTVZnjfH7tjt9BxStm+wXcDBNuYmzw==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/markdownlint": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.33.0.tgz", + "integrity": "sha512-4lbtT14A3m0LPX1WS/3d1m7Blg+ZwiLq36WvjQqFGsX3Gik99NV+VXp/PW3n+Q62xyPdbvGOCfjPqjW+/SKMig==", + "dev": true, + "dependencies": { + "markdown-it": "14.0.0", + "markdownlint-micromark": "0.1.8" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.12.1.tgz", + "integrity": "sha512-RcK+l5FjJEyrU3REhrThiEUXNK89dLYNJCYbvOUKypxqIGfkcgpz8g08EKqhrmUbYfYoLC5nEYQy53NhJSEtfQ==", + "dev": true, + "dependencies": { + "globby": "14.0.0", + "jsonc-parser": "3.2.0", + "markdownlint": "0.33.0", + "markdownlint-cli2-formatter-default": "0.0.4", + "micromatch": "4.0.5", + "yaml": "2.3.4" + }, + "bin": { + "markdownlint-cli2": "markdownlint-cli2.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2-formatter-default": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.4.tgz", + "integrity": "sha512-xm2rM0E+sWgjpPn1EesPXx5hIyrN2ddUnUwnbCsD/ONxYtw3PX6LydvdH6dciWAoFDpwzbHM1TO7uHfcMd6IYg==", + "dev": true, + "peerDependencies": { + "markdownlint-cli2": ">=0.0.4" + } + }, + "node_modules/markdownlint-cli2/node_modules/globby": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.0.tgz", + "integrity": "sha512-/1WM/LNHRAOH9lZta77uGbq0dAEQM+XjNesWwhlERDVenqothRbnzTrL3/LrIoEPPjeUHC3vrS6TwoyxeHs7MQ==", + "dev": true, + "dependencies": { + "@sindresorhus/merge-streams": "^1.0.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdownlint-cli2/node_modules/path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdownlint-cli2/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdownlint-cli2/node_modules/yaml": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", + "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/markdownlint-micromark": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/markdownlint-micromark/-/markdownlint-micromark-0.1.8.tgz", + "integrity": "sha512-1ouYkMRo9/6gou9gObuMDnvZM8jC/ly3QCFQyoSPCS2XV1ZClU0xpKbL1Ar3bWWRT1RnBZkWUEiNKrI2CwiBQA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, "node_modules/mdn-data": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==" }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -14889,6 +15073,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", @@ -17220,6 +17413,12 @@ "node": ">=4.2.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -17280,6 +17479,18 @@ "node": ">=4" } }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", diff --git a/src/webapp/package.json b/src/webapp/package.json index 115b66852..1619a8c60 100644 --- a/src/webapp/package.json +++ b/src/webapp/package.json @@ -47,5 +47,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "markdownlint-cli2": "^0.12.1" } } diff --git a/src/webapp/public/locales/de/translation.json b/src/webapp/public/locales/de/translation.json index f3bd89782..1c6729415 100644 --- a/src/webapp/public/locales/de/translation.json +++ b/src/webapp/public/locales/de/translation.json @@ -18,9 +18,9 @@ "shutdown": "Herunterfahren", "reboot": "Neustarten", "say_my_ip": "IP Addresse vorlesen", - "timer_shutdown": "Shut Down", - "timer_stop_player": "Stop player", - "timer_fade_volume": "Fade volume", + "timer_shutdown": "Herunterfahren", + "timer_stop_player": "Player stoppen", + "timer_fade_volume": "Lautstärke ausblenden und herunterfahren", "toggle_output": "Audio-Ausgang umschalten", "sync_rfidcards_all": "Alle Audiodateien und Karteneinträge synchronisieren", "sync_rfidcards_change_on_rfid_scan": "Aktivierung ändern für 'on RFID scan'", @@ -138,8 +138,8 @@ "title": "Wähle ein Album, einen Ordner oder einen Song aus" }, "folders": { - "no-music": "Keine Musik vorhanden!", - "empty-folder": "Dieser Ordner ist leer!", + "no-music": "☝️ Keine Musik vorhanden!", + "empty-folder": "Dieser Ordner ist leer! 🙈", "show-folder-content": "Zeige den Ordnerinhalt an", "back-button-label": "Zurück" }, @@ -219,15 +219,42 @@ "why": "Warum?", "control-label": "Auto Hotspot" }, + "general": { + "title": "Allgmeine Einstellungen", + "show_covers": { + "title": "Cover anzeigen" + } + }, "timers": { "option-label-timeslot": "{{value}} min", "option-label-off": "Aus", "title": "Timer", - "stop-player": "Wiedergabe stoppen", - "shutdown": "Herunterfahren", - "fade-volume": "Lautstärke ausblenden", - "idle-shutdown": "Leerlaufabschaltung", - "ended": "Beendet" + "stop-player": { + "title": "Wiedergabe stoppen", + "label": "Stoppt die Wiedergabe nach Ablauf des Timers." + }, + "shutdown": { + "title": "Herunterfahren", + "label": "Fährt die Phoniebox nach Ablauf des Timers herunter." + }, + "fade-volume": { + "title": "Lautstärke ausblenden", + "label": "Blendet die Lautstärke zum Ende des Timers langsam aus und fährt dann die Phoniebox herunter." + }, + "idle-shutdown": { + "title": "Leerlaufabschaltung", + "label": "Fährt die Phoniebox herunter, nachdem sie für eine bestimmte Zeit im Leerlauf war." + }, + "set": "Timer erstellen", + "cancel": "Abbrechen", + "paused": "Pausiert", + "ended": "Beendet", + "dialog": { + "title": "{{value}} Timer erstellen", + "description": "Wähle die Anzahl der Minuten nachdem die Aktion ausgeführt werden soll.", + "start": "Timer starten", + "cancel": "Abbrechen" + } }, "secondswipe": { "title": "Erneute Aktivierung (Second Swipe)", diff --git a/src/webapp/public/locales/en/translation.json b/src/webapp/public/locales/en/translation.json index 348d3771d..20a28bdac 100644 --- a/src/webapp/public/locales/en/translation.json +++ b/src/webapp/public/locales/en/translation.json @@ -20,7 +20,7 @@ "say_my_ip": "Say IP address", "timer_shutdown": "Shut Down", "timer_stop_player": "Stop player", - "timer_fade_volume": "Fade volume", + "timer_fade_volume": "Fade volume and Shut Down", "toggle_output": "Toggle audio output", "sync_rfidcards_all": "Sync all audiofiles and card entries", "sync_rfidcards_change_on_rfid_scan": "Change activation of 'on RFID scan'", @@ -138,8 +138,8 @@ "title": "Select an album, folder or song" }, "folders": { - "no-music": "No music found!", - "empty-folder": "This folder is empty!", + "no-music": "☝️ No music found!", + "empty-folder": "This folder is empty! 🙈", "show-folder-content": "Show folder content", "back-button-label": "Back" }, @@ -219,15 +219,42 @@ "why": "Why?", "control-label": "Auto Hotspot" }, + "general": { + "title": "General Settings", + "show_covers": { + "title": "Show Cover Art" + } + }, "timers": { "option-label-timeslot": "{{value}} min", "option-label-off": "Off", "title": "Timers", - "stop-player": "Stop player", - "shutdown": "Shut Down", - "fade-volume": "Fade volume", - "idle-shutdown": "Idle Shut Down", - "ended": "Done" + "stop-player": { + "title": "Stop player", + "label": "Stops playback after the timer has ended." + }, + "shutdown": { + "title": "Shut Down", + "label": "Forces the Phoniebox to shut down after the timer has ended." + }, + "fade-volume": { + "title": "Fade volume and Shut Down", + "label": "Slowly fades out volume towards the end of the timer and then shuts down the Phoniebox." + }, + "idle-shutdown": { + "title": "Idle Shut Down", + "label": "Shuts down the Phoniebox after the Phoniebox was idle for a given time." + }, + "ended": "Done", + "set": "Set timer", + "cancel": "Cancel", + "paused": "Paused", + "dialog": { + "title": "Set {{value}} timer", + "description": "Choose the amount of minutes you want the action to be performed.", + "start": "Start timer", + "cancel": "Cancel" + } }, "secondswipe": { "title": "Second Swipe", diff --git a/src/webapp/src/App.js b/src/webapp/src/App.js index 99272db64..a51529381 100644 --- a/src/webapp/src/App.js +++ b/src/webapp/src/App.js @@ -2,6 +2,7 @@ import React, { Suspense } from 'react'; import Grid from '@mui/material/Grid'; +import AppSettingsProvider from './context/appsettings'; import PubSubProvider from './context/pubsub'; import PlayerProvider from './context/player'; import Router from './router'; @@ -10,15 +11,17 @@ function App() { return ( - - - + + + + + ); diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index 8c844d8da..1e984997e 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -120,6 +120,7 @@ const commands = { _package: 'volume', plugin: 'ctrl', method: 'set_volume', + argKeys: ['volume'], }, getVolume: { _package: 'volume', @@ -215,6 +216,25 @@ const commands = { }, + 'timer_idle_shutdown.cancel': { + _package: 'timers', + plugin: 'timer_idle_shutdown', + method: 'cancel', + }, + 'timer_idle_shutdown.get_state': { + _package: 'timers', + plugin: 'timer_idle_shutdown', + method: 'get_state', + }, + 'timer_idle_shutdown': { + _package: 'timers', + plugin: 'timer_idle_shutdown', + method: 'start', + argKeys: ['wait_seconds'], + }, + + + // Host getAutohotspotStatus: { _package: 'host', @@ -250,6 +270,18 @@ const commands = { argKeys: ['option'], }, + // Misc + getAppSettings: { + _package: 'misc', + plugin: 'get_app_settings' + }, + + setAppSettings: { + _package: 'misc', + plugin: 'set_app_settings', + argKeys: ['settings'], + }, + // Synchronisation 'sync_rfidcards_all': { _package: 'sync_rfidcards', diff --git a/src/webapp/src/components/Cards/controls/actions/play-music/selected-folder.js b/src/webapp/src/components/Cards/controls/actions/play-music/selected-folder.js index bf1976fac..3191c36cf 100644 --- a/src/webapp/src/components/Cards/controls/actions/play-music/selected-folder.js +++ b/src/webapp/src/components/Cards/controls/actions/play-music/selected-folder.js @@ -8,7 +8,6 @@ import { import NoMusicSelected from './no-music-selected'; import FolderTypeAvatar from '../../../../Library/lists/folders/folder-type-avatar'; -import { DEFAULT_AUDIO_DIR } from '../../../../../config'; const SelectedFolder = ({ values: [folder] }) => { // TODO: Implement type correctly @@ -19,7 +18,7 @@ const SelectedFolder = ({ values: [folder] }) => { - + ); diff --git a/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js b/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js index 75882dd0d..71f6ba315 100644 --- a/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js +++ b/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js @@ -1,4 +1,4 @@ -import React, { forwardRef, useEffect, useState } from 'react'; +import React, { forwardRef, useContext, useEffect, useState } from 'react'; import { Link, useLocation, @@ -15,6 +15,7 @@ import { import noCover from '../../../../../assets/noCover.jpg'; +import AppSettingsContext from '../../../../../context/appsettings/context'; import request from '../../../../../utils/request'; const AlbumListItem = ({ albumartist, album, isButton = true }) => { @@ -22,6 +23,14 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => { const { search: urlSearch } = useLocation(); const [coverImage, setCoverImage] = useState(noCover); + const { + settings, + } = useContext(AppSettingsContext); + + const { + show_covers, + } = settings; + useEffect(() => { const getCoverArt = async () => { const { result } = await request('getAlbumCoverArt', { @@ -29,11 +38,13 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => { album: album }); if (result) { - setCoverImage(`/cover-cache/${result}`); + if(result !== 'CACHE_PENDING') { + setCoverImage(`/cover-cache/${result}`); + } }; } - if (albumartist && album) { + if (albumartist && album && show_covers) { getCoverArt(); } }, [albumartist, album]); @@ -59,9 +70,11 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => { key={album} > - - - + {show_covers && + + + + } { const { t } = useTranslation(); - const { type, name, path } = folder; + const { type, name, relpath } = folder; const playItem = () => { switch(type) { - case 'directory': return request('play_folder', { folder: path, recursive: true }); - case 'file': return request('play_single', { song_url: path.replace(`${DEFAULT_AUDIO_DIR}/`, '') }); + case 'directory': return request('play_folder', { folder: relpath, recursive: true }); + case 'file': return request('play_single', { song_url: relpath }); // TODO: Add missing Podcast // TODO: Add missing Stream default: return; @@ -35,8 +34,8 @@ const FolderListItem = ({ const registerItemToCard = () => { switch(type) { - case 'directory': return registerMusicToCard('play_folder', { folder: path, recursive: true }); - case 'file': return registerMusicToCard('play_single', { song_url: path.replace(`${DEFAULT_AUDIO_DIR}/`, '') }); + case 'directory': return registerMusicToCard('play_folder', { folder: relpath, recursive: true }); + case 'file': return registerMusicToCard('play_single', { song_url: relpath }); // TODO: Add missing Podcast // TODO: Add missing Stream default: return; @@ -50,7 +49,7 @@ const FolderListItem = ({ type === 'directory' ? diff --git a/src/webapp/src/components/Library/lists/folders/folder-list.js b/src/webapp/src/components/Library/lists/folders/folder-list.js index 36a9bcebd..3222e4234 100644 --- a/src/webapp/src/components/Library/lists/folders/folder-list.js +++ b/src/webapp/src/components/Library/lists/folders/folder-list.js @@ -1,12 +1,17 @@ import React, { memo } from 'react'; import { dropLast } from "ramda"; +import { useTranslation } from 'react-i18next'; -import { List } from '@mui/material'; +import { + List, + ListItem, + Typography, +} from '@mui/material'; import FolderListItem from './folder-list-item'; import FolderListItemBack from './folder-list-item-back'; -import { ROOT_DIRS } from '../../../../config'; +import { ROOT_DIR } from '../../../../config'; const FolderList = ({ dir, @@ -14,13 +19,14 @@ const FolderList = ({ isSelecting, registerMusicToCard, }) => { + const { t } = useTranslation(); + const getParentDir = (dir) => { - // TODO: ROOT_DIRS should be removed after paths are relative const decodedDir = decodeURIComponent(dir); - if (ROOT_DIRS.includes(decodedDir)) return undefined; + if (decodedDir === ROOT_DIR) return undefined; - const parentDir = dropLast(1, decodedDir.split('/')).join('/'); + const parentDir = dropLast(1, decodedDir.split('/')).join('/') || ROOT_DIR; return parentDir; } @@ -29,11 +35,14 @@ const FolderList = ({ return ( {parentDir && - + + } + {folders.length === 0 && + + {t('library.folders.empty-folder')} + } - {folders.map((folder, key) => + {folders.length > 0 && folders.map((folder, key) => { const { t } = useTranslation(); - const { dir = './' } = useParams(); + const { dir = ROOT_DIR } = useParams(); const [folders, setFolders] = useState([]); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -49,9 +51,8 @@ const Folders = ({ if (isLoading) return ; if (error) return {t('library.loading-error')}; - if (!filteredFolders.length) { - if (musicFilter) return {`☝️ ${t('library.folders.no-music')}`}; - return {`${t('library.folders.empty-folder')} 🙈`}; + if (musicFilter && !filteredFolders.length) { + return {t('library.folders.no-music')}; } return ( diff --git a/src/webapp/src/components/Library/lists/index.js b/src/webapp/src/components/Library/lists/index.js index 22970d9a9..e2b7a2d46 100644 --- a/src/webapp/src/components/Library/lists/index.js +++ b/src/webapp/src/components/Library/lists/index.js @@ -28,7 +28,7 @@ const LibraryLists = () => { const [cardId] = useState(searchParams.get('cardId')); const [musicFilter, setMusicFilter] = useState(''); - const handleMusicFolder = (event) => { + const handleMusicFilter = (event) => { setMusicFilter(event.target.value); }; @@ -49,7 +49,7 @@ const LibraryLists = () => { {isSelecting && } { const [coverImage, setCoverImage] = useState(undefined); const [backgroundImage, setBackgroundImage] = useState('none'); + const { + settings, + } = useContext(AppSettingsContext); + + const { show_covers } = settings; + useEffect(() => { const getCoverArt = async () => { const { result } = await request('getSingleCoverArt', { song_url: file }); @@ -30,7 +37,7 @@ const Player = () => { }; } - if (file) { + if (file && show_covers) { getCoverArt(); } }, [file]); diff --git a/src/webapp/src/components/Settings/autohotspot.js b/src/webapp/src/components/Settings/autohotspot.js index 0bcbf97db..b9d04199f 100644 --- a/src/webapp/src/components/Settings/autohotspot.js +++ b/src/webapp/src/components/Settings/autohotspot.js @@ -16,7 +16,7 @@ import { SwitchWithLoader } from '../general'; import request from '../../utils/request'; -const helpUrl = 'https://rpi-jukebox-rfid.readthedocs.io/en/latest/userguide/autohotspot.html'; +const helpUrl = 'https://github.com/MiczFlor/RPi-Jukebox-RFID/blob/future3/main/documentation/builders/autohotspot.md'; const SettingsAutoHotpot = () => { const { t } = useTranslation(); diff --git a/src/webapp/src/components/Settings/general/index.js b/src/webapp/src/components/Settings/general/index.js new file mode 100644 index 000000000..790043778 --- /dev/null +++ b/src/webapp/src/components/Settings/general/index.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useTheme } from '@mui/material/styles'; + +import { + Card, + CardContent, + CardHeader, + Divider, + Grid, +} from '@mui/material'; +import ShowCovers from './show-covers'; + +const SettingsGeneral = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const spacer = { marginBottom: theme.spacing(2) } + + return ( + + + + + .MuiGrid-root:not(:last-child)': spacer }} + > + + + + + ); +}; + +export default SettingsGeneral; diff --git a/src/webapp/src/components/Settings/general/show-covers.js b/src/webapp/src/components/Settings/general/show-covers.js new file mode 100644 index 000000000..a3b31f4e0 --- /dev/null +++ b/src/webapp/src/components/Settings/general/show-covers.js @@ -0,0 +1,56 @@ +import React, { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + Box, + Grid, + Switch, + Typography, +} from '@mui/material'; + +import AppSettingsContext from '../../../context/appsettings/context'; +import request from '../../../utils/request'; + +const ShowCovers = () => { + const { t } = useTranslation(); + + const { + settings, + setSettings, + } = useContext(AppSettingsContext); + + const { + show_covers, + } = settings; + + const updateShowCoversSetting = async (show_covers) => { + await request('setAppSettings', { settings: { show_covers }}); + } + + const handleSwitch = (event) => { + setSettings({ show_covers: event.target.checked}); + updateShowCoversSetting(event.target.checked); + } + + return ( + + + + {t(`settings.general.show_covers.title`)} + + + + + + + ); +}; + +export default ShowCovers; diff --git a/src/webapp/src/components/Settings/index.js b/src/webapp/src/components/Settings/index.js index 1bc599fc1..75ce7840f 100644 --- a/src/webapp/src/components/Settings/index.js +++ b/src/webapp/src/components/Settings/index.js @@ -2,9 +2,10 @@ import React from 'react'; import { Grid } from '@mui/material'; +import SettingsAudio from './audio/index'; import SettingsAutoHotspot from './autohotspot'; +import SettingsGeneral from './general'; import SettingsSecondSwipe from './secondswipe'; -import SettingsAudio from './audio/index'; import SettingsStatus from './status/index'; import SettingsTimers from './timers/index'; import SystemControls from './systemcontrols'; @@ -28,6 +29,9 @@ const Settings = () => { + + + diff --git a/src/webapp/src/components/Settings/timers/index.js b/src/webapp/src/components/Settings/timers/index.js index c47a42573..a143a3557 100644 --- a/src/webapp/src/components/Settings/timers/index.js +++ b/src/webapp/src/components/Settings/timers/index.js @@ -1,21 +1,18 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useTheme } from '@mui/material/styles'; - import { Card, CardContent, CardHeader, Divider, Grid, + List, } from '@mui/material'; import Timer from './timer'; const SettingsTimers = () => { const { t } = useTranslation(); - const theme = useTheme(); - const spacer = { marginBottom: theme.spacing(2) } return ( @@ -24,15 +21,13 @@ const SettingsTimers = () => { /> - .MuiGrid-root:not(:last-child)': spacer }} - > - - - - {/* */} + + + + + + + diff --git a/src/webapp/src/components/Settings/timers/set-timer-dialog.js b/src/webapp/src/components/Settings/timers/set-timer-dialog.js new file mode 100644 index 000000000..c948916e8 --- /dev/null +++ b/src/webapp/src/components/Settings/timers/set-timer-dialog.js @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Grid, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +import { + SliderTimer +} from '../../general'; + +export default function SetTimerDialog({ + type, + enabled, + setTimer, + cancelTimer, + waitSeconds, + setWaitSeconds, +}) { + const { t } = useTranslation(); + const theme = useTheme(); + + const [dialogOpen, setDialogOpen] = useState(false); + + const handleClickOpen = () => { + setWaitSeconds(0); + setDialogOpen(true); + }; + + const handleCancel = () => { + setDialogOpen(false); + }; + + const handleSetTimer = () => { + setTimer(waitSeconds) + setDialogOpen(false); + } + + return ( + + {!enabled && + + } + {enabled && + + } + + + {t('settings.timers.dialog.title', { value: t(`settings.timers.${type}.title`)} )} + + + + {t('settings.timers.dialog.description')} + + + { setWaitSeconds(value) }} + /> + + + + + + + + + ); +} diff --git a/src/webapp/src/components/Settings/timers/timer.js b/src/webapp/src/components/Settings/timers/timer.js index 20b990edc..28625f86a 100644 --- a/src/webapp/src/components/Settings/timers/timer.js +++ b/src/webapp/src/components/Settings/timers/timer.js @@ -1,116 +1,138 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; - -import { - Box, - Grid, - Switch, - Typography, -} from '@mui/material'; -import { useTheme } from '@mui/material/styles'; - +import { Box, ListItem, ListItemText, Typography } from '@mui/material'; +import { Countdown } from '../../general'; +import SetTimerDialog from './set-timer-dialog'; import request from '../../../utils/request'; -import { - Countdown, - SliderTimer -} from '../../general'; - -const Timer = ({ type }) => { - const { t } = useTranslation(); - const theme = useTheme(); - // Constants +// Custom hook to manage timer state and logic +const useTimer = (type) => { const pluginName = `timer_${type.replace('-', '_')}`; + const [timerState, setTimerState] = useState({ + error: null, + enabled: false, + isLoading: true, + status: { enabled: false }, + waitSeconds: 0, + running: true + }); - // State - const [error, setError] = useState(null); - const [enabled, setEnabled] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [status, setStatus] = useState({ enabled: false }); - const [waitSeconds, setWaitSeconds] = useState(0); - - // Requests - const cancelTimer = async () => { - await request(`${pluginName}.cancel`); - setStatus({ enabled: false }); - }; + const fetchTimerStatus = useCallback(async () => { + try { + const { result: timerStatus, error: timerStatusError } = await request(`${pluginName}.get_state`); - const setTimer = async (event, wait_seconds) => { - await cancelTimer(); + if (timerStatusError) { + throw timerStatusError; + } - if (wait_seconds > 0) { - await request(pluginName, { wait_seconds } ); - fetchTimerStatus(); + setTimerState(prev => ({ + ...prev, + status: timerStatus, + enabled: timerStatus?.enabled, + running: timerStatus.running ?? true, + error: null, + isLoading: false + })); + } catch (error) { + setTimerState(prev => ({ + ...prev, + enabled: false, + error, + isLoading: false + })); } - } - - const fetchTimerStatus = useCallback(async () => { - const { - result: timerStatus, - error: timerStatusError - } = await request(`${pluginName}.get_state`); + }, [pluginName]); - if(timerStatusError) { - setEnabled(false); - return setError(timerStatusError); + const cancelTimer = async () => { + try { + await request(`${pluginName}.cancel`); + setTimerState(prev => ({ ...prev, enabled: false })); + } catch (error) { + setTimerState(prev => ({ ...prev, error })); } + }; - setStatus(timerStatus); - setEnabled(timerStatus?.enabled); - setWaitSeconds(timerStatus?.wait_seconds || 0); - }, [pluginName]); - + const setTimer = async (wait_seconds) => { + try { + await cancelTimer(); + if (wait_seconds > 0) { + await request(pluginName, { wait_seconds }); + await fetchTimerStatus(); + } + } catch (error) { + setTimerState(prev => ({ ...prev, error })); + } + }; - // Event Handlers - const handleSwitch = (event) => { - setEnabled(event.target.checked); - setWaitSeconds(0); // Always start the slider at 0 - cancelTimer(); - } + const setWaitSeconds = (seconds) => { + setTimerState(prev => ({ ...prev, waitSeconds: seconds })); + }; - // Effects useEffect(() => { fetchTimerStatus(); - setIsLoading(false); }, [fetchTimerStatus]); + return { + ...timerState, + setTimer, + cancelTimer, + setWaitSeconds + }; +}; + +// Separate component for timer actions +const TimerActions = ({ enabled, running, status, error, isLoading, type, onSetTimer, onCancelTimer, waitSeconds, onSetWaitSeconds }) => { + const { t } = useTranslation(); + + return ( + + {enabled && running && ( + onCancelTimer()} + stringEnded={t('settings.timers.ended')} + /> + )} + {enabled && !running && ( + {t('settings.timers.paused')} + )} + {error && ⚠️} + {!isLoading && ( + + )} + + ); +}; + +const Timer = ({ type }) => { + const { t } = useTranslation(); + const timer = useTimer(type); + return ( - - - - {t(`settings.timers.${type}`)} - - - {status?.enabled && - setEnabled(false)} - stringEnded={t('settings.timers.ended')} - /> - } - {error && - ⚠️ - } - - - - {enabled && - - - + } - + > + + ); }; diff --git a/src/webapp/src/components/general/Countdown.js b/src/webapp/src/components/general/Countdown.js index 84e91cb88..a3eab33e1 100644 --- a/src/webapp/src/components/general/Countdown.js +++ b/src/webapp/src/components/general/Countdown.js @@ -23,7 +23,7 @@ const Countdown = ({ onEnd, seconds, stringEnded = undefined }) => { } }, [onEndCallback, time]); - if (time) return toHHMMSS(time); + if (time) return toHHMMSS(Math.round(time)); if (stringEnded) return stringEnded; return toHHMMSS(0); } diff --git a/src/webapp/src/config.js b/src/webapp/src/config.js index 482db205e..46a6ec1df 100644 --- a/src/webapp/src/config.js +++ b/src/webapp/src/config.js @@ -17,9 +17,7 @@ const SUBSCRIPTIONS = [ 'volume.level', ]; -const DEFAULT_AUDIO_DIR = '../../shared/audiofolders'; -const ROOT_DIRS = ['./', DEFAULT_AUDIO_DIR]; - +const ROOT_DIR = './'; // TODO: The reason why thos commands are empty objects is due to a legacy // situation where titles associated with those commands were stored here @@ -83,11 +81,10 @@ const JUKEBOX_ACTIONS_MAP = { const TIMER_STEPS = [0, 2, 5, 10, 15, 20, 30, 45, 60, 120, 180, 240]; export { - DEFAULT_AUDIO_DIR, JUKEBOX_ACTIONS_MAP, PUBSUB_ENDPOINT, REQRES_ENDPOINT, - ROOT_DIRS, + ROOT_DIR, SUBSCRIPTIONS, TIMER_STEPS, } diff --git a/src/webapp/src/context/appsettings/context.js b/src/webapp/src/context/appsettings/context.js new file mode 100644 index 000000000..f2650d210 --- /dev/null +++ b/src/webapp/src/context/appsettings/context.js @@ -0,0 +1,7 @@ +import { createContext } from 'react'; + +const AppSettingsContext = createContext({ + showCovers: true, +}); + +export default AppSettingsContext; diff --git a/src/webapp/src/context/appsettings/index.js b/src/webapp/src/context/appsettings/index.js new file mode 100644 index 000000000..1fa34914d --- /dev/null +++ b/src/webapp/src/context/appsettings/index.js @@ -0,0 +1,33 @@ +import React, { useEffect, useState } from 'react'; + +import AppSettingsContext from './context'; +import request from '../../utils/request'; + +const AppSettingsProvider = ({ children }) => { + const [settings, setSettings] = useState({}); + + useEffect(() => { + const loadAppSettings = async () => { + const { result, error } = await request('getAppSettings'); + if(result) setSettings(result); + if(error) { + console.error('Error loading AppSettings'); + } + } + + loadAppSettings(); + }, []); + + const context = { + setSettings, + settings, + }; + + return( + + { children } + + ) +}; + +export default AppSettingsProvider; diff --git a/test/evdev/test_evdev_init.py b/test/evdev/test_evdev_init.py new file mode 100644 index 000000000..c37f6ea85 --- /dev/null +++ b/test/evdev/test_evdev_init.py @@ -0,0 +1,185 @@ +""" Tests for the evdev __init__ module +""" +import sys +import unittest +from unittest.mock import patch +from unittest.mock import MagicMock + +# Before importing the module, the jukebox.plugs decorators need to be patched +# to not try to register the plugins +import jukebox.plugs as plugin + + +def dummy_decorator(fkt): + return fkt + + +plugin.register = dummy_decorator +plugin.initialize = dummy_decorator +plugin.atexit = dummy_decorator + +# Mock the jukebox.publishing module to prevent issues with zmq +# which is currently hard to install(see issue #2050) +# and not installed properly for CI +sys.modules['jukebox.publishing'] = MagicMock() + +# Import uses the patched decorators +from components.controls.event_devices import _input_devices_to_key_mapping # noqa: E402 +from components.controls.event_devices import parse_device_config # noqa: E402 + + +class TestInputDevicesToKeyMapping(unittest.TestCase): + def test_mapping_with_supported_input_type_and_key_code(self): + input_devices = { + 'device1': { + 'type': 'Button', + 'kwargs': { + 'key_code': 123 + }, + 'actions': { + 'on_press': 'action1' + } + }, + 'device2': { + 'type': 'Button', + 'kwargs': { + 'key_code': 456 + }, + 'actions': { + 'on_press': 'action2' + } + } + } + + expected_mapping = { + 123: 'action1', + 456: 'action2' + } + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, expected_mapping) + + def test_mapping_with_missing_type(self): + input_devices = { + 'device1': { + 'kwargs': { + 'key_code': 123 + }, + 'actions': { + 'on_press': 'action1' + } + } + } + + expected_mapping = {} + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, expected_mapping) + + def test_mapping_with_unsupported_input_type(self): + input_devices = { + 'device1': { + 'type': 'unknown', + 'kwargs': { + 'key_code': 'A' + }, + 'actions': { + 'on_press': 'action1' + } + } + } + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, {}) + + def test_mapping_with_missing_key_code(self): + input_devices = { + 'device1': { + 'type': 'button', + 'kwargs': {}, + 'actions': { + 'on_press': 'action1' + } + } + } + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, {}) + + def test_mapping_with_unsupported_action(self): + input_devices = { + 'device1': { + 'type': 'button', + 'kwargs': { + 'key_code': 'A' + }, + 'actions': { + 'unknown_action': 'action1' + } + } + } + + mapping = _input_devices_to_key_mapping(input_devices) + + self.assertEqual(mapping, {}) + + +class TestParseDeviceConfig(unittest.TestCase): + @patch('components.controls.event_devices.jukebox.utils.bind_rpc_command') + def test_parse_device_config(self, bind_rpc_command_mock): + config = { + "device_name": "Test Device", + "exact": True, + "input_devices": { + 'device1': { + 'type': 'Button', + 'kwargs': {'key_code': 123}, + 'actions': { + 'on_press': 'action1' + } + } + } + } + + device_name, exact, button_callbacks = parse_device_config(config) + self.assertEqual(device_name, "Test Device") + self.assertEqual(exact, True) + self.assertEqual(button_callbacks, { + 123: bind_rpc_command_mock.return_value, + }) + + def test_parse_device_config_missing_input_devices(self): + config = { + "device_name": "Test Device", + "exact": True + } + device_name, exact, button_callbacks = parse_device_config(config) + self.assertEqual(device_name, "Test Device") + self.assertEqual(exact, True) + self.assertEqual(button_callbacks, {}) + + def test_parse_device_config_missing_device_name(self): + config = { + "exact": True, + "input_devices": {} + } + self.assertRaises(ValueError, parse_device_config, config) + + def test_parse_device_config_missing_exact(self): + """Test that the default value for exact is False""" + config = { + "device_name": "Test Device", + "input_devices": {} + } + device_name, exact, button_callbacks = parse_device_config(config) + self.assertEqual(device_name, "Test Device") + self.assertEqual(exact, False) + self.assertEqual(button_callbacks, {}) + + +if __name__ == '__main__': + unittest.main()