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];