diff --git a/docs/sphinx/index.rst b/docs/sphinx/index.rst
index ea2555032..a83777673 100644
--- a/docs/sphinx/index.rst
+++ b/docs/sphinx/index.rst
@@ -34,6 +34,7 @@ Welcome to RPi Jukebox RFID's documentation!
userguide/rpc_command_alias_reference
userguide/carddatabase
userguide/autohotspot
+ userguide/sync_rfidcards
.. toctree::
diff --git a/docs/sphinx/userguide/sync_rfidcards.rst b/docs/sphinx/userguide/sync_rfidcards.rst
new file mode 100644
index 000000000..5cb554acb
--- /dev/null
+++ b/docs/sphinx/userguide/sync_rfidcards.rst
@@ -0,0 +1,71 @@
+Syncronisation RFID Cards
+*************************
+
+This component handles the synchronisation of RFID cards (audiofolder and card database entries).
+
+It allows to manage card database entries and audiofiles of one to many Phonieboxes
+in a central place (e.g. NAS, primary Phoniebox etc.) in the network,
+but allows to play the audio offline once the data has synced.
+The synchronisation can be initiated with the command ``sync-all``
+and optionally on every RFID scan for a particular CardID and its corresponding audiofolder.
+To execute the ``sync-all`` command, bind a RFID card to the command.
+For the "RFID scan sync" feature, activate the option in the configuration
+or bind a RFID card to the command for dynamic activation or deactivation.
+
+Synchronisation
+---------------
+
+The synchronisation will be FROM a server TO the Phoniebox, overriding existing files.
+A local configuration will be lost after the synchronization.
+If you want to make the initial setup e.g. via WebUi copy the files and use it as a base for the server.
+
+To access the files on the server, 2 modes are supported: SSH or MOUNT.
+Please make sure you have the correct access rights to the source and use key-based authentication for SSH.
+
+RFID scan sync
+^^^^^^^^^^^^^^
+If the feature "RFID scan sync" is activated, there will be a check on every RFID scan against the server
+if a matching card entry and audiofolder is available. If so, changes will be synced.
+The playback will be delayed for the time the data is transfered (see "sync-all" to use a full synchronization if a lot of new files have been added).
+If the server is not reachable, the check will be aborted after the timeout.
+Therfore, an unreachable server will cause a delay (see commands to toggle activation state).
+Deleted card entries / audiofolders (not the contained items) will not be purged locally if deleted on remote.
+This is also true for changed card entries (the old audiofolder / -files will remain). To remove not existing items us a "sync-all".
+
+Configuration
+-------------
+
+Set the corresponding setting in ``shared\settings\jukebox.yaml`` to activate this feature.
+
+.. code-block:: yaml
+
+ modules:
+ named:
+ ...
+ sync_rfidcards: synchronisation.rfidcards
+
+ ...
+ sync_rfidcards:
+ enable: false
+ config_file: ../../shared/settings/sync_rfidcards.yaml
+
+The settings file (``shared\settings\sync_rfidcards.yaml``) contains the following configuration
+
+.. code-block:: yaml
+
+ sync_rfidcards:
+ # Holds the activation state of the optional feature "RFID scan sync". Values are "TRUE" or "FALSE"
+ on_rfid_scan_enabled: true # bool
+ # Server Access mode. MOUNT or SSH
+ mode: mount # 'mount' or 'ssh'
+ credentials:
+ # IP or hostname of the server (used to check connectivity and for SSH mode). e.g. "192.168.0.2" or "myhomeserver.local"
+ server: ''
+ # Port (used to check connectivity and for SSH mode). e.g. "80" or "22"
+ port: # int
+ # Timeout to reach the server (in seconds) (used to check connectivity). e.g. 1
+ timeout: 1 # int
+ # Path to the shared files to sync (without trailing slash) (remote path for SSH mode or local path for MOUNT mode). e.g. "/mnt/Phoniebox"
+ path: ''
+ # Username if SSH mode is used.
+ username: ''
diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml
index 23b528e59..6e565c684 100644
--- a/resources/default-settings/jukebox.default.yaml
+++ b/resources/default-settings/jukebox.default.yaml
@@ -18,6 +18,7 @@ modules:
host: hostif.linux
bluetooth_audio_buttons: controls.bluetooth_audio_buttons
gpio: gpio.gpioz.plugin
+ sync_rfidcards: synchronisation.rfidcards
others:
- music_cover_art
- misc
@@ -142,3 +143,6 @@ speaking_text:
speak_punct: False
# Must be one of: female, male, croak, whisper
voice: female
+sync_rfidcards:
+ enable: false
+ config_file: ../../shared/settings/sync_rfidcards.yaml
diff --git a/resources/default-settings/sync_rfidcards.default.yaml b/resources/default-settings/sync_rfidcards.default.yaml
new file mode 100644
index 000000000..54f4b6d9b
--- /dev/null
+++ b/resources/default-settings/sync_rfidcards.default.yaml
@@ -0,0 +1,9 @@
+sync_rfidcards:
+ on_rfid_scan_enabled: true # bool
+ mode: mount # 'mount' or 'ssh'
+ credentials:
+ server: ''
+ port: # int
+ timeout: 1 # int
+ path: ''
+ username: ''
diff --git a/src/jukebox/components/gpio/gpioz/plugin/connectivity.py b/src/jukebox/components/gpio/gpioz/plugin/connectivity.py
index c6f29b80c..3e5baea2d 100644
--- a/src/jukebox/components/gpio/gpioz/plugin/connectivity.py
+++ b/src/jukebox/components/gpio/gpioz/plugin/connectivity.py
@@ -19,6 +19,7 @@
import components.gpio.gpioz.plugin
from components.gpio.gpioz.core.output_devices import LED, PWMLED, Buzzer, TonalBuzzer, RGBLED
from components.gpio.gpioz.core.converter import VolumeToRGB
+from components.rfid.reader import RfidCardDetectState
logger = logging.getLogger('gpioz')
@@ -61,10 +62,10 @@ def register_rfid_callback(device):
- :class:`components.gpio.gpioz.core.output_devices.TonalBuzzer`
"""
- def rfid_callback(card_id: str, state: int):
- if state == 0:
+ def rfid_callback(card_id: str, state: RfidCardDetectState):
+ if state == RfidCardDetectState.isRegistered:
device.flash(on_time=0.1, n=1, tone=BUZZ_TONE)
- elif state == 1:
+ elif state == RfidCardDetectState.isUnkown:
device.flash(on_time=0.1, off_time=0.1, n=3, tone=BUZZ_TONE)
components.rfid.reader.rfid_card_detect_callbacks.register(
diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py
index 04f0643c1..ecac65ab8 100644
--- a/src/jukebox/components/playermpd/__init__.py
+++ b/src/jukebox/components/playermpd/__init__.py
@@ -95,7 +95,7 @@
import misc
from jukebox.NvManager import nv_manager
-
+from .playcontentcallback import PlayContentCallbacks, PlayCardState
logger = logging.getLogger('jb.PlayerMPD')
cfg = jukebox.cfghandler.get_handler('jukebox')
@@ -284,6 +284,12 @@ def update(self):
state = self.mpd_client.update()
return state
+ @plugs.tag
+ def update_wait(self):
+ state = self.update()
+ self._db_wait_for_update(state)
+ return state
+
@plugs.tag
def play(self):
with self.mpd_lock:
@@ -444,9 +450,17 @@ def play_card(self, folder: str, recursive: bool = False):
is_second_swipe = self.music_player_status['player_status']['last_played_folder'] == folder
if self.second_swipe_action is not None and is_second_swipe:
logger.debug('Calling second swipe action')
+
+ # run callbacks before second_swipe_action is invoked
+ play_card_callbacks.run_callbacks(folder, PlayCardState.secondSwipe)
+
self.second_swipe_action()
else:
logger.debug('Calling first swipe action')
+
+ # run callbacks before play_folder is invoked
+ play_card_callbacks.run_callbacks(folder, PlayCardState.firstSwipe)
+
self.play_folder(folder, recursive)
@plugs.tag
@@ -585,12 +599,33 @@ def set_volume(self, volume):
self.mpd_client.setvol(volume)
return self.get_volume()
+ def _db_wait_for_update(self, update_id: int):
+ logger.debug("Waiting for update to finish")
+ while self._db_is_updating(update_id):
+ # a little throttling
+ time.sleep(0.1)
+
+ def _db_is_updating(self, update_id: int):
+ with self.mpd_lock:
+ _status = self.mpd_client.status()
+ _cur_update_id = _status.get('updating_db')
+ if _cur_update_id is not None and int(_cur_update_id) <= int(update_id):
+ return True
+ else:
+ return False
+
# ---------------------------------------------------------------------------
# Plugin Initializer / Finalizer
# ---------------------------------------------------------------------------
player_ctrl: PlayerMPD
+#: Callback handler instance for play_card events.
+#: - is executed when play_card function is called
+#: States:
+#: - See :class:`PlayCardState`
+#: See :class:`PlayContentCallbacks`
+play_card_callbacks: PlayContentCallbacks[PlayCardState]
@plugs.initialize
@@ -599,6 +634,9 @@ def initialize():
player_ctrl = PlayerMPD()
plugs.register(player_ctrl, name='ctrl')
+ global play_card_callbacks
+ play_card_callbacks = PlayContentCallbacks[PlayCardState]('play_card_callbacks', logger, context=player_ctrl.mpd_lock)
+
# Update mpc library
library_update = cfg.setndefault('playermpd', 'library', 'update_on_startup', value=True)
if library_update:
diff --git a/src/jukebox/components/playermpd/playcontentcallback.py b/src/jukebox/components/playermpd/playcontentcallback.py
new file mode 100644
index 000000000..a60452a23
--- /dev/null
+++ b/src/jukebox/components/playermpd/playcontentcallback.py
@@ -0,0 +1,37 @@
+
+from enum import Enum
+from typing import Callable, Generic, TypeVar
+
+from jukebox.callingback import CallbackHandler
+
+
+class PlayCardState(Enum):
+ firstSwipe = 0,
+ secondSwipe = 1
+
+
+STATE = TypeVar('STATE', bound=Enum)
+
+
+class PlayContentCallbacks(Generic[STATE], CallbackHandler):
+ """
+ Callbacks are executed in various play functions
+ """
+
+ def register(self, func: Callable[[str, STATE], None]):
+ """
+ Add a new callback function :attr:`func`.
+
+ Callback signature is
+
+ .. py:function:: func(folder: str, state: STATE)
+ :noindex:
+
+ :param folder: relativ path to folder to play
+ :param state: indicator of the state inside the calling
+ """
+ super().register(func)
+
+ def run_callbacks(self, folder: str, state: STATE):
+ """:meta private:"""
+ super().run_callbacks(folder, state)
diff --git a/src/jukebox/components/rfid/reader/__init__.py b/src/jukebox/components/rfid/reader/__init__.py
index f213ed55c..9245a127a 100644
--- a/src/jukebox/components/rfid/reader/__init__.py
+++ b/src/jukebox/components/rfid/reader/__init__.py
@@ -3,6 +3,7 @@
import time
import importlib
from typing import Callable
+from enum import Enum
import jukebox.plugs as plugs
import jukebox.cfghandler
@@ -20,15 +21,18 @@
cfg_cards = jukebox.cfghandler.get_handler('cards')
-class ServiceIsRunningCallbacks(CallbackHandler):
- """
- Callbacks are executed when
+class RfidCardDetectState(Enum):
+ received = 0,
+ isRegistered = 1
+ isUnkown = 2
+
- * valid rfid card detect
- * unknown card detect
+class RfidCardDetectCallbacks(CallbackHandler):
+ """
+ Callbacks are executed if rfid card is detected
"""
- def register(self, func: Callable[[str, int], None]):
+ def register(self, func: Callable[[str, RfidCardDetectState], None]):
"""
Add a new callback function :attr:`func`.
@@ -38,18 +42,18 @@ def register(self, func: Callable[[str, int], None]):
:noindex:
:param card_id: Card ID
- :param state: 0 if card id is registered, 1 if card id is unknown
+ :param state: See :class:`RfidCardDetectState`
"""
super().register(func)
- def run_callbacks(self, card_id: str, state: int):
+ def run_callbacks(self, card_id: str, state: RfidCardDetectState):
""":meta private:"""
super().run_callbacks(card_id, state)
#: Callback handler instance for rfid_card_detect_callbacks events.
-#: See :class:`ServiceIsRunningCallbacks`
-rfid_card_detect_callbacks: ServiceIsRunningCallbacks = ServiceIsRunningCallbacks('rfid_card_detect_callbacks', log)
+#: See :class:`RfidCardDetectCallbacks`
+rfid_card_detect_callbacks: RfidCardDetectCallbacks = RfidCardDetectCallbacks('rfid_card_detect_callbacks', log)
class CardRemovalTimerClass(threading.Thread):
@@ -175,6 +179,10 @@ def run(self): # noqa: C901
# (3) Check if this card is in the card database
# TODO: This card config read is not thread safe
+
+ # run callbacks on successfull read before card_entry is processed
+ rfid_card_detect_callbacks.run_callbacks(card_id, RfidCardDetectState.received)
+
card_entry = cfg_cards.get(card_id, default=None)
if card_entry is not None:
@@ -207,12 +215,12 @@ def run(self): # noqa: C901
# dodgy cards database entry
# TODO: This call happens from the reader thread, which is not necessarily what we want ...
# TODO: Change to RPC call to transfer execution into main thread
- rfid_card_detect_callbacks.run_callbacks(card_id, 0)
+ rfid_card_detect_callbacks.run_callbacks(card_id, RfidCardDetectState.isRegistered)
plugs.call_ignore_errors(card_action['package'], card_action['plugin'], card_action['method'],
args=card_action['args'], kwargs=card_action['kwargs'])
else:
- rfid_card_detect_callbacks.run_callbacks(card_id, 1)
+ rfid_card_detect_callbacks.run_callbacks(card_id, RfidCardDetectState.isUnkown)
self._logger.info(f"Unknown card: '{card_id}'")
self.publisher.send(self.topic, card_id)
elif self._cfg_log_ignored_cards is True:
diff --git a/src/jukebox/components/rpc_command_alias.py b/src/jukebox/components/rpc_command_alias.py
index fb4c2f4cf..bb484891a 100644
--- a/src/jukebox/components/rpc_command_alias.py
+++ b/src/jukebox/components/rpc_command_alias.py
@@ -113,6 +113,19 @@
'method': 'start',
'title': 'Start the stop music timer',
'ignore_card_removal_action': True},
+ # SYNCHRONISATION
+ 'sync_rfidcards_all': {
+ 'package': 'sync_rfidcards',
+ 'plugin': 'ctrl',
+ 'method': 'sync_all',
+ 'title': 'Sync all audiofiles and card entries',
+ 'ignore_card_removal_action': True},
+ 'sync_rfidcards_change_on_rfid_scan': {
+ 'package': 'sync_rfidcards',
+ 'plugin': 'ctrl',
+ 'method': 'sync_change_on_rfid_scan',
+ 'title': "Change activation of 'on RFID scan'",
+ 'ignore_card_removal_action': True},
}
# TODO: Transfer RFID command from v2.3...
diff --git a/src/jukebox/components/synchronisation/__init__.py b/src/jukebox/components/synchronisation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/jukebox/components/synchronisation/rfidcards/__init__.py b/src/jukebox/components/synchronisation/rfidcards/__init__.py
new file mode 100644
index 000000000..0fa0969a9
--- /dev/null
+++ b/src/jukebox/components/synchronisation/rfidcards/__init__.py
@@ -0,0 +1,358 @@
+"""
+Handles the synchronisation of RFID cards (audiofolder and card database entries).
+
+sync-all -> all card entries and audiofolders are synced from remote including deletions
+sync-on-scan -> only the entry and audiofolder for the cardId will be synced from remote.
+ Deletions are only performed on files and subfolder inside the audiofolder.
+ A deletion of the audiofolder itself on remote side will not be propagated.
+
+card database:
+On synchronisation the remote file will not be synced with the original cards database, but rather a local copy.
+If a full sync is performed, the state is written back to the original file.
+If a single card sync is performed, only the state of the specific cardId is updated in the original file.
+This is done to allow to play audio offline.
+Otherwise we would also update other cardIds where the audiofolders have not been synced yet.
+The local copy is kept to reduce unnecessary syncing.
+
+"""
+
+import logging
+import subprocess
+import components.player
+import components.playermpd
+import components.rfid.reader
+import components.synchronisation.syncutils as syncutils
+import jukebox.cfghandler
+import jukebox.plugs as plugs
+import socket
+import os
+import shutil
+
+from components.rfid.reader import RfidCardDetectState
+from components.playermpd.playcontentcallback import PlayCardState
+
+
+logger = logging.getLogger('jb.sync_rfidcards')
+
+cfg_main = jukebox.cfghandler.get_handler('jukebox')
+cfg_sync_rfidcards = jukebox.cfghandler.get_handler('sync_rfidcards')
+cfg_cards = jukebox.cfghandler.get_handler('cards')
+
+
+class SyncRfidcards:
+ """Control class for sync RFID cards functionality"""
+
+ def __init__(self):
+ with cfg_main:
+ self._sync_enabled = cfg_main.setndefault('sync_rfidcards', 'enable', value=False) is True
+ if self._sync_enabled:
+ logger.info("Sync RFID cards activated")
+ with cfg_main:
+ config_file = cfg_main.setndefault('sync_rfidcards', 'config_file',
+ value='../../shared/settings/sync_rfidcards.yaml')
+ try:
+ cfg_sync_rfidcards.load(config_file)
+ except Exception as e:
+ logger.error(f"Error loading sync_rfidcards config file. {e.__class__.__name__}: {e}")
+ return
+
+ with cfg_sync_rfidcards:
+ self._sync_on_rfid_scan_enabled = (
+ cfg_sync_rfidcards.getn('sync_rfidcards', 'on_rfid_scan_enabled', default=False)
+ is True)
+ if not self._sync_on_rfid_scan_enabled:
+ logger.info("Sync on RFID scan deactivated")
+ self._sync_mode = cfg_sync_rfidcards.getn('sync_rfidcards', 'mode')
+ self._sync_remote_server = cfg_sync_rfidcards.getn('sync_rfidcards', 'credentials', 'server')
+ self._sync_remote_port = int(cfg_sync_rfidcards.getn('sync_rfidcards', 'credentials', 'port'))
+ self._sync_remote_timeout = int(cfg_sync_rfidcards.getn('sync_rfidcards', 'credentials', 'timeout'))
+ self._sync_remote_path = cfg_sync_rfidcards.getn('sync_rfidcards', 'credentials', 'path')
+
+ self._sync_is_mode_ssh = self._sync_mode == "ssh"
+ if self._sync_is_mode_ssh:
+ self._sync_remote_ssh_user = cfg_sync_rfidcards.getn('sync_rfidcards', 'credentials', 'username')
+
+ components.rfid.reader.rfid_card_detect_callbacks.register(self._rfid_callback)
+ components.playermpd.play_card_callbacks.register(self._play_card_callback)
+ else:
+ logger.info("Sync RFID cards deactivated")
+
+ def __exit__(self):
+ cfg_sync_rfidcards.save(only_if_changed=True)
+
+ def _rfid_callback(self, card_id: str, state: RfidCardDetectState):
+ if state == RfidCardDetectState.received:
+ self.sync_card_database(card_id)
+
+ def _play_card_callback(self, folder: str, state: PlayCardState):
+ if state == PlayCardState.firstSwipe:
+ self.sync_folder(folder)
+
+ @plugs.tag
+ def sync_change_on_rfid_scan(self, option: str = 'toggle') -> None:
+ """
+ Change activation of 'on_rfid_scan_enabled'
+
+ :param option: Must be one of 'enable', 'disable', 'toggle'
+ """
+ if self._is_sync_enabled():
+
+ if option == 'enable':
+ _new_state = True
+ elif option == 'disable':
+ _new_state = False
+ elif option == 'toggle':
+ _new_state = not self._sync_on_rfid_scan_enabled
+ else:
+ logger.error(f"Invalid value '{option}' for 'option' in 'sync_change_on_rfid_scan'")
+ _new_state = None
+
+ if _new_state is not None:
+ cfg_sync_rfidcards.setn('sync_rfidcards', 'on_rfid_scan_enabled', value=_new_state)
+ self._sync_on_rfid_scan_enabled = _new_state
+
+ logger.info(f"Changed 'on_rfid_scan_enabled' to '{_new_state}'")
+
+ @plugs.tag
+ def sync_all(self) -> bool:
+ """
+ Sync all audiofolder and cardids from the remote server.
+ Removes local entries not existing at the remote server.
+ """
+ _files_synced = False
+
+ if self._is_sync_enabled():
+ logger.info("Syncing all")
+ _database_synced = self._sync_card_database()
+ _folder_synced = self._sync_folder('')
+ _files_synced = _database_synced or _folder_synced
+
+ return _files_synced
+
+ @plugs.tag
+ def sync_card_database(self, card_id: str) -> bool:
+ """
+ Sync the card database from the remote server, if existing.
+ If card_id is provided only this entry is updated.
+
+ :param card_id: The cardid to update
+ """
+ _files_synced = False
+
+ if self._is_sync_enabled_on_rfid_scan():
+ _files_synced = self._sync_card_database(card_id)
+
+ return _files_synced
+
+ @plugs.tag
+ def sync_folder(self, folder: str) -> bool:
+ """
+ Sync the folder from the remote server, if existing
+
+ :param folder: Folder path relative to music library path
+ """
+ _files_synced = False
+
+ if self._is_sync_enabled_on_rfid_scan():
+ _files_synced = self._sync_folder(folder)
+
+ return _files_synced
+
+ def _is_sync_enabled(self) -> bool:
+ if self._sync_enabled:
+ return True
+
+ logger.debug("Sync RFID cards deactivated")
+ return False
+
+ def _is_sync_enabled_on_rfid_scan(self) -> bool:
+ if self._is_sync_enabled():
+ if self._sync_on_rfid_scan_enabled:
+ return True
+
+ logger.debug("Sync on RFID scan deactivated")
+
+ return False
+
+ def _sync_card_database(self, card_id: str = None) -> bool:
+ _card_database_path = cfg_cards.loaded_from
+ logger.info(f"Syncing card database: {_card_database_path}")
+
+ if not self._is_server_reachable():
+ return False
+
+ _sync_remote_path_settings = os.path.join(self._sync_remote_path, "settings")
+ _card_database_file = os.path.basename(_card_database_path)
+ _card_database_dir = os.path.dirname(_card_database_path)
+ _src_path = os.path.join(_sync_remote_path_settings, _card_database_file)
+ # Sync the card database to a temp file to handle changes of single card ids correctly.
+ # This file is kept to reduce unnecessary syncing!
+ _dst_path = os.path.join(_card_database_dir, "sync_temp_" + _card_database_file)
+
+ _files_synced = False
+ if self._is_file_remote(_src_path):
+ _files_synced = self._sync_paths(_src_path, _dst_path)
+
+ if os.path.isfile(_dst_path):
+ # Check even if nothing has been synced,
+ # as the original card database could have been changed locally (e.g. WebUi)
+ if card_id is not None:
+ # This ConfigHandler is explicitly instantiated and only used to read the synced temp database file
+ _cfg_cards_temp = jukebox.cfghandler.ConfigHandler("sync_temp_cards")
+ with _cfg_cards_temp:
+ _cfg_cards_temp.load(_dst_path)
+ _card_entry = _cfg_cards_temp.get(card_id, default=None)
+ if _card_entry is not None:
+ with cfg_cards:
+ cfg_cards[card_id] = _card_entry
+ if cfg_cards.is_modified():
+ cfg_cards.save(only_if_changed=True)
+ _files_synced = True
+ logger.info(f"Updated entry '{card_id}' in '{_card_database_path}'")
+ else:
+ # overwrite original file with synced state
+ with cfg_cards:
+ shutil.copy2(_dst_path, _card_database_path)
+ cfg_cards.load(_card_database_path)
+ logger.info(f"Updated '{_card_database_path}'")
+
+ else:
+ logger.warn(f"Card database does not exist remote: {_src_path}")
+
+ return _files_synced
+
+ def _sync_folder(self, folder: str) -> bool:
+ logger.info(f"Syncing Folder '{folder}'")
+
+ if not self._is_server_reachable():
+ return False
+
+ _sync_remote_path_audio = os.path.join(self._sync_remote_path, "audiofolders")
+ _music_library_path = components.player.get_music_library_path()
+ _cleaned_foldername = syncutils.clean_foldername(_music_library_path, folder)
+ _src_path = syncutils.ensure_trailing_slash(os.path.join(_sync_remote_path_audio, _cleaned_foldername))
+ # TODO fix general absolut/relativ folder path handling
+ _dst_path = syncutils.ensure_trailing_slash(os.path.join(_music_library_path, folder))
+
+ _files_synced = False
+ if self._is_dir_remote(_src_path):
+ _files_synced = self._sync_paths(_src_path, _dst_path)
+
+ if _files_synced:
+ logger.debug('Files synced: update database')
+ plugs.call_ignore_errors('player', 'ctrl', 'update_wait')
+
+ else:
+ logger.warn(f"Folder does not exist remote: {_src_path}")
+
+ return _files_synced
+
+ def _sync_paths(self, src_path: str, dst_path: str) -> bool:
+ _files_synced = False
+ logger.debug(f"Src: '{src_path}' -> Dst: '{dst_path}'")
+
+ if dst_path.endswith('/'):
+ os.makedirs(dst_path, exist_ok=True)
+
+ if self._sync_is_mode_ssh:
+ _user = self._sync_remote_ssh_user
+ _host = self._sync_remote_server
+ _port = self._sync_remote_port
+
+ _paths = ['-e', f"ssh -p {_port}", f"{_user}@{_host}:'{src_path}'", dst_path]
+
+ else:
+ _paths = [src_path, dst_path]
+
+ _run_params = (['rsync',
+ '--compress', '--recursive', '--itemize-changes',
+ '--safe-links', '--times', '--omit-dir-times',
+ '--delete', '--prune-empty-dirs',
+ '--exclude=folder.conf', # exclude if existing from v2.x
+ '--exclude=.*', '--exclude=.*/', '--exclude=@*/', '--cvs-exclude'
+ ] + _paths)
+
+ _runresult = subprocess.run(_run_params, shell=False, check=False, capture_output=True, text=True)
+
+ if _runresult.returncode == 0 and _runresult.stdout != '':
+ logger.debug(f"Synced:\n{_runresult.stdout}")
+ _files_synced = True
+ if _runresult.stderr != '':
+ logger.error(f"Sync Error: {_runresult.stderr}")
+
+ return _files_synced
+
+ def _is_server_reachable(self) -> bool:
+ _host = self._sync_remote_server
+ _port = self._sync_remote_port
+ _timeout = self._sync_remote_timeout
+
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.settimeout(_timeout)
+ result = sock.connect_ex((_host, _port))
+ except Exception as e:
+ logger.error(f"Server not reachable: {_host}:{_port}. {e.__class__.__name__}: {e}")
+ return False
+
+ _server_reachable = result == 0
+ if not _server_reachable:
+ logger.error(f"Server not reachable: {_host}:{_port}. errorcode: {result}")
+
+ return _server_reachable
+
+ def _is_file_remote(self, path: str) -> bool:
+ if self._sync_is_mode_ssh:
+ _user = self._sync_remote_ssh_user
+ _host = self._sync_remote_server
+ _port = self._sync_remote_port
+
+ _runresult = subprocess.run(['ssh',
+ f"{_user}@{_host}", f"-p {_port}",
+ '[', '-f', f"'{path}'", ']'],
+ shell=False, check=False, capture_output=True, text=True)
+
+ _result = _runresult.returncode == 0
+
+ else:
+ _result = os.path.isfile(path)
+
+ return _result
+
+ def _is_dir_remote(self, path: str) -> bool:
+ if self._sync_is_mode_ssh:
+ _user = self._sync_remote_ssh_user
+ _host = self._sync_remote_server
+ _port = self._sync_remote_port
+
+ _runresult = subprocess.run(['ssh',
+ f"{_user}@{_host}", f"-p {_port}",
+ '[', '-d', f"'{path}'", ']'],
+ shell=False, check=False, capture_output=True, text=True)
+
+ _result = _runresult.returncode == 0
+
+ else:
+ _result = os.path.isdir(path)
+
+ return _result
+
+# ---------------------------------------------------------------------------
+# Plugin Initializer / Finalizer
+# ---------------------------------------------------------------------------
+
+
+sync_rfidcards_ctrl: SyncRfidcards
+
+
+@plugs.initialize
+def initialize():
+ global sync_rfidcards_ctrl
+ sync_rfidcards_ctrl = SyncRfidcards()
+ plugs.register(sync_rfidcards_ctrl, name='ctrl')
+
+
+@plugs.atexit
+def atexit(**ignored_kwargs):
+ global sync_rfidcards_ctrl
+ return sync_rfidcards_ctrl.__exit__()
diff --git a/src/jukebox/components/synchronisation/syncutils.py b/src/jukebox/components/synchronisation/syncutils.py
new file mode 100644
index 000000000..b7d381914
--- /dev/null
+++ b/src/jukebox/components/synchronisation/syncutils.py
@@ -0,0 +1,21 @@
+def clean_foldername(lib_path: str, folder: str) -> str:
+ _folder = folder.removeprefix(lib_path)
+ _folder = remove_leading_slash(remove_trailing_slash(_folder))
+ return _folder
+
+
+def ensure_trailing_slash(path: str) -> str:
+ _path = path
+ if not _path.endswith('/'):
+ _path = _path + '/'
+ return _path
+
+
+def remove_trailing_slash(path: str) -> str:
+ _path = path.removesuffix('/')
+ return _path
+
+
+def remove_leading_slash(path: str) -> str:
+ _path = path.removeprefix('/')
+ return _path
diff --git a/src/webapp/public/locales/de/translation.json b/src/webapp/public/locales/de/translation.json
index b0b08546f..e2424b649 100644
--- a/src/webapp/public/locales/de/translation.json
+++ b/src/webapp/public/locales/de/translation.json
@@ -21,7 +21,9 @@
"timer_shutdown": "Shut Down",
"timer_stop_player": "Stop player",
"timer_fade_volume": "Fade volume",
- "toggle_output": "Audio-Ausgang umschalten"
+ "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' "
}
},
"controls-selector": {
@@ -34,7 +36,8 @@
"play_music": "Musik abspielen",
"audio": "Audio & Lautstärke",
"host": "System",
- "timers": "Timer"
+ "timers": "Timer",
+ "synchronisation": "Synchronisation"
}
},
"actions": {
@@ -58,6 +61,14 @@
},
"timers": {
"description": "Wähle die Anzahl der Minuten nachdem die Aktion ausgeführt werden soll."
+ },
+ "synchronisation": {
+ "rfidcards": {
+ "description": "Wähle den zu setzenden Status.",
+ "label-toggle": "Umschalten",
+ "label-enable": "Aktivieren",
+ "label-disable": "Deaktivieren"
+ }
}
}
},
diff --git a/src/webapp/public/locales/en/translation.json b/src/webapp/public/locales/en/translation.json
index ad278d226..d2e89adf1 100644
--- a/src/webapp/public/locales/en/translation.json
+++ b/src/webapp/public/locales/en/translation.json
@@ -21,7 +21,9 @@
"timer_shutdown": "Shut Down",
"timer_stop_player": "Stop player",
"timer_fade_volume": "Fade volume",
- "toggle_output": "Toggle audio output"
+ "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'"
}
},
"controls-selector": {
@@ -34,7 +36,8 @@
"play_music": "Play music",
"audio": "Audio & Volume",
"host": "System",
- "timers": "Timers"
+ "timers": "Timers",
+ "synchronisation": "Synchronisation"
}
},
"actions": {
@@ -58,6 +61,14 @@
},
"timers": {
"description": "Choose the amount of minutes you want the action to be performed."
+ },
+ "synchronisation": {
+ "rfidcards": {
+ "description": "Choose the state to set.",
+ "label-toggle": "Toggle",
+ "label-enable": "Enable",
+ "label-disable": "Disable"
+ }
}
}
},
diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js
index 229daf43f..bd8fd782e 100644
--- a/src/webapp/src/commands/index.js
+++ b/src/webapp/src/commands/index.js
@@ -237,6 +237,19 @@ const commands = {
plugin: 'say_my_ip',
argKeys: ['option'],
},
+
+ // Synchronisation
+ 'sync_rfidcards_all': {
+ _package: 'sync_rfidcards',
+ plugin: 'ctrl',
+ method: 'sync_all'
+ },
+ 'sync_rfidcards_change_on_rfid_scan': {
+ _package: 'sync_rfidcards',
+ plugin: 'ctrl',
+ method: 'sync_change_on_rfid_scan',
+ argKeys: ['option']
+ },
};
export default commands;
diff --git a/src/webapp/src/components/Cards/controls/actions/synchronisation/index.js b/src/webapp/src/components/Cards/controls/actions/synchronisation/index.js
new file mode 100644
index 000000000..f554da1a3
--- /dev/null
+++ b/src/webapp/src/components/Cards/controls/actions/synchronisation/index.js
@@ -0,0 +1,30 @@
+import React from 'react';
+
+import CommandSelector from '../../command-selector';
+import ChangeOnRfidScan from './rfidcards/change-on-rfid-scan-options';
+
+import { getActionAndCommand } from '../../../utils';
+
+const SelectSynchronisation = ({
+ actionData,
+ handleActionDataChange,
+}) => {
+ const { command } = getActionAndCommand(actionData);
+
+ return (
+ <>
+
+ {command === 'sync_rfidcards_change_on_rfid_scan' &&
+
+ }
+ >
+ );
+};
+
+export default SelectSynchronisation;
diff --git a/src/webapp/src/components/Cards/controls/actions/synchronisation/rfidcards/change-on-rfid-scan-options.js b/src/webapp/src/components/Cards/controls/actions/synchronisation/rfidcards/change-on-rfid-scan-options.js
new file mode 100644
index 000000000..6d2b2fde6
--- /dev/null
+++ b/src/webapp/src/components/Cards/controls/actions/synchronisation/rfidcards/change-on-rfid-scan-options.js
@@ -0,0 +1,67 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+ Grid,
+ FormControl,
+ FormControlLabel,
+ Radio,
+ RadioGroup,
+ Typography,
+} from '@mui/material';
+
+import {
+ getActionAndCommand,
+ getArgsValues,
+} from '../../../../utils';
+
+
+const ChangeOnRfidScan = ({
+ actionData,
+ handleActionDataChange,
+}) => {
+ const { t } = useTranslation();
+
+ const { action, command } = getActionAndCommand(actionData);
+ const [option] = getArgsValues(actionData);
+
+ const onChange = (event, option) => {
+ handleActionDataChange(action, command, { option })
+ };
+
+ return (
+
+
+
+ {t('cards.controls.actions.synchronisation.rfidcards.description')}
+
+
+
+ }
+ label={t('cards.controls.actions.synchronisation.rfidcards.label-toggle')}
+ value="toggle"
+ />
+ }
+ label={t('cards.controls.actions.synchronisation.rfidcards.label-enable')}
+ value="enable"
+ />
+ }
+ label={t('cards.controls.actions.synchronisation.rfidcards.label-disable')}
+ value="disable"
+ />
+
+
+
+
+ );
+};
+
+export default ChangeOnRfidScan;
diff --git a/src/webapp/src/components/Cards/controls/controls-selector.js b/src/webapp/src/components/Cards/controls/controls-selector.js
index fd14facc0..eea2d5c67 100644
--- a/src/webapp/src/components/Cards/controls/controls-selector.js
+++ b/src/webapp/src/components/Cards/controls/controls-selector.js
@@ -12,6 +12,7 @@ import SelectTimers from './actions/timers';
import SelectAudio from './actions/audio';
import { buildActionData } from '../utils';
import SelectHost from './actions/host';
+import SelectSynchronisation from './actions/synchronisation';
const ControlsSelector = ({
actionData,
@@ -80,9 +81,16 @@ const ControlsSelector = ({
handleActionDataChange={handleActionDataChange}
/>
}
+
+ {actionData.action === 'synchronisation' &&
+
+ }
);
};
-export default ControlsSelector;
\ No newline at end of file
+export default ControlsSelector;
diff --git a/src/webapp/src/config.js b/src/webapp/src/config.js
index 041484c6d..4383a7897 100644
--- a/src/webapp/src/config.js
+++ b/src/webapp/src/config.js
@@ -65,6 +65,14 @@ const JUKEBOX_ACTIONS_MAP = {
timer_fade_volume: {},
}
},
+
+ // Synchronisation
+ synchronisation: {
+ commands: {
+ sync_rfidcards_all: {},
+ sync_rfidcards_change_on_rfid_scan: {},
+ }
+ },
}
const TIMER_STEPS = [0, 2, 5, 10, 15, 20, 30, 45, 60, 120, 180, 240];